feat: target saved seat settings by node ID (#2051)

This commit is contained in:
Brad Vincent authored and GitHub committed 2026-10-25 18:57:24 -07:00
1 parent 4e3ba99a4f
commit c21e4d7994
6 files changed
+337 -28

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
# Seat settings by node ID
Clients that present a specific seat can save its later-launch settings using its durable `nodeId`. The node ID is distinct from the current session and native conversation. These routes never launch, stop, or resume the seat.
| Read or save | Node-addressed route |
| --- | --- |
| Status | `GET /api/seat/status/node/:nodeId` |
| Native permissions | `POST /api/seat/set-permissions/node/:nodeId` |
| Permission policy | `POST /api/seat/set-policy/node/:nodeId` |
| Model | `POST /api/seat/set-model/node/:nodeId` |
| Effort | `POST /api/seat/set-effort/node/:nodeId` |
The POST bodies and responses follow the corresponding name-addressed routes. A successful save's `seat.nodeId` and `seat.rigId` identify the node actually updated. The exact status response adds `node_id`, `contract: "seat-node.v1"`, and `supported_actions` listing the four setters. Existing name status responses retain their shape.
An unknown, deleted, or archived node returns `seat_not_found` (HTTP 404) at node resolution. After awaited validation of a native Claude permission mode or Codex profile returns, the exact node-ID path checks availability again before saving; an unavailable node then returns `seat_not_found` without a setting or audit write. Existing native validation may refuse a changed target before this check: for example, Claude's managed context guard can report `permission_selection_refused` during preparation. The daemon never resolves a node ID through a reusable seat name. Setting writes have no general session precondition; a changed occupant on the same available node can receive a later save.
For example, `POST /api/seat/set-model/node/<nodeId>` accepts the existing `{ "model": "...", "reason": "...", "operator": "..." }` body. The existing audit actor, reason, native validation, permission precedence, and terminal policy rules still apply. The older `/:seatRef` routes remain available, including their name ambiguity behavior.
@@ -99,6 +99,9 @@ interface ResolvedSeat {
nodeId: string;
}
/** Setting writes can address either a legacy seat name or one immutable node ID. */
export type SeatSettingTarget = { seatRef: string; nodeId?: never } | { nodeId: string; seatRef?: never };
export interface SeatRefusal {
ok: false;
code:
@@ -370,13 +373,13 @@ export class SeatLifecycleService {
}
}
async setModel(input: { seatRef: string; model: string; reason: string; operator?: string | null }): Promise<SetModelResult> {
async setModel(input: SeatSettingTarget & { model: string; reason: string; operator?: string | null }): Promise<SetModelResult> {
const required = this.requireReason(input.reason);
if (required) return required;
if (!input.model?.trim()) {
return { ok: false, code: "missing_model", message: "A target model id is required (--model)." };
}
const resolved = this.resolveSeat(input.seatRef);
const resolved = this.resolveSettingTarget(input);
if ("code" in resolved) return resolved;
const model = input.model.trim();
@@ -521,7 +524,7 @@ export class SeatLifecycleService {
/** OPR.0.7.0.7 — save one seat's effort for its next managed continuation. Like set-model it
* validates, writes, and persists an audited event in the same transaction; it never restarts
* the seat or touches its peers, model or account. `effort: null` clears the explicit choice. */
async setEffort(input: { seatRef: string; effort: string | null; reason: string; operator?: string | null }): Promise<SetEffortResult> {
async setEffort(input: SeatSettingTarget & { effort: string | null; reason: string; operator?: string | null }): Promise<SetEffortResult> {
const required = this.requireReason(input.reason);
if (required) return required;
const requested = input.effort === null ? null : input.effort.trim();
@@ -532,12 +535,12 @@ export class SeatLifecycleService {
return { ok: false, code: "invalid_effort", message: `"${requested}" isn't an effort level: use the runtime's level name, such as low, medium or high.` };
}
}
const resolved = this.resolveSeat(input.seatRef);
const resolved = this.resolveSettingTarget(input);
if ("code" in resolved) return resolved;
const seat = this.describe(resolved);
const runtime = resolved.entry.runtime ?? "unknown";
if (runtime !== "claude-code" && runtime !== "codex" && runtime !== "pi") {
return { ok: false, code: "effort_unsupported_runtime", message: `Seat "${input.seatRef}" runs ${runtime}; reasoning effort applies to claude-code, codex and pi seats only.` };
return { ok: false, code: "effort_unsupported_runtime", message: `Seat "${this.settingTargetLabel(input)}" runs ${runtime}; reasoning effort applies to claude-code, codex and pi seats only.` };
}
const from = this.readEffort(seat.nodeId);
@@ -567,7 +570,7 @@ export class SeatLifecycleService {
if (refused) return refused;
if (persisted) this.eventBus.notifySubscribers(persisted);
if (!changed) {
return { ok: false, code: "effort_unavailable", message: `The effort choice for seat "${input.seatRef}" was not saved (no node row was updated); nothing changed.` };
return { ok: false, code: "effort_unavailable", message: `The effort choice for seat "${this.settingTargetLabel(input)}" was not saved (no node row was updated); nothing changed.` };
}
}
const saved = this.readEffort(seat.nodeId) ?? from;
@@ -747,7 +750,7 @@ export class SeatLifecycleService {
return probe.state === "present" ? { state: "yes" } : { state: "no", detail: "tmux session absent" };
}
async setPermissions(input: { seatRef: string; mode?: string; rules?: unknown; codexPermissions?: unknown; codexProfile?: string; reason: string; actor: string }): Promise<
async setPermissions(input: SeatSettingTarget & { mode?: string; rules?: unknown; codexPermissions?: unknown; codexProfile?: string; reason: string; actor: string }): Promise<
| { ok: true; seat: SeatDescriptor; from: unknown; to: unknown; changed: boolean; effect: string; replaced?: unknown; observation?: unknown }
| SeatRefusal
> {
@@ -755,14 +758,15 @@ export class SeatLifecycleService {
const required = this.requireReason(input.reason);
if (required) return required;
if (!input.actor.trim()) return { ok: false, code: "permission_selection_refused", message: "Sender identity is required for permission audit." };
const resolved = this.resolveSeat(input.seatRef);
const resolved = this.resolveSettingTarget(input);
if ("code" in resolved) return resolved;
const seat = this.describe(resolved);
const runtime = resolved.entry.runtime ?? "unknown";
const choices = [input.mode, input.rules, input.codexPermissions, input.codexProfile].filter(value => value !== undefined).length;
if (choices !== 1) return { ok: false, code: "permission_selection_refused", message: "Choose exactly one of mode, rules, codex permissions or codex profile." };
if (input.codexPermissions !== undefined) return this.setCodexPermissions(seat, runtime, input.codexPermissions, input.actor.trim(), input.reason.trim());
if (input.codexProfile !== undefined) return this.setCodexProfile(seat, runtime, input.codexProfile, resolved.entry.cwd ?? undefined, input.actor.trim(), input.reason.trim());
if (input.codexProfile !== undefined) return this.setCodexProfile(seat, runtime, input.codexProfile, resolved.entry.cwd ?? undefined,
input.actor.trim(), input.reason.trim(), input.nodeId !== undefined);
if (input.rules !== undefined) {
if (runtime !== "claude-code") return { ok: false, code: "permission_selection_refused", message: "claude_permissions is only supported for claude-code seats." };
try {
@@ -799,6 +803,13 @@ export class SeatLifecycleService {
const store = new NativePermissionStore(this.db);
let persisted: PersistedEvent | null = null;
const result = this.db.transaction(() => {
if (dynamic && input.nodeId !== undefined) {
// Claude preparation awaits; an exact node target must still belong to an active rig.
const available = this.db.prepare(`SELECT 1 FROM nodes n JOIN rigs r ON r.id = n.rig_id
WHERE n.id = ? AND r.archived_at IS NULL`).get(seat.nodeId);
if (!available) return { ok: false as const, code: "seat_not_found" as const,
message: `Node "${seat.nodeId}" no longer exists in an active rig.`, guidance: SEAT_LOOKUP_GUIDANCE };
}
launch?.assertCurrent();
const currentRuntime = this.db.prepare("SELECT runtime FROM nodes WHERE id = ?").get(seat.nodeId) as { runtime: string } | undefined;
if (currentRuntime?.runtime !== runtime) throw new Error("Seat runtime changed while checking native options; selection was not changed.");
@@ -860,7 +871,8 @@ export class SeatLifecycleService {
/** A Codex seat's named profile. It doesn't replace a retained conversation's approval; an envelope does. The check
* speaks only for the config home this seat's launches use, and a real load failure keeps the previous profile. */
private async setCodexProfile(seat: SeatDescriptor, runtime: string, value: string, cwd: string | undefined, actor: string, reason: string):
private async setCodexProfile(seat: SeatDescriptor, runtime: string, value: string, cwd: string | undefined, actor: string, reason: string,
exactNodeTarget: boolean):
Promise<{ ok: true; seat: SeatDescriptor; from: unknown; to: unknown; changed: boolean; effect: string; observation?: unknown } | SeatRefusal> {
if (runtime !== "codex") return { ok: false, code: "permission_selection_refused", message: "A Codex profile is only supported for codex seats." };
const name = value.trim();
@@ -870,8 +882,9 @@ export class SeatLifecycleService {
}
const observer = this.runtimeAdapters["codex"] as unknown as Partial<CodexProfileObserver> | undefined;
// The check is evidence: if it fails unexpectedly, record that instead of failing the request.
const observed = to !== null && cwd && observer?.observeProfile
? await observer.observeProfile(to, cwd, seat.nodeId).catch((error: unknown) => ({ vantage: "unestablished" as const, home: null, file: "unverified" as const,
const checksProfile = to !== null && cwd && observer?.observeProfile;
const observed = checksProfile
? await observer!.observeProfile!(to, cwd, seat.nodeId).catch((error: unknown) => ({ vantage: "unestablished" as const, home: null, file: "unverified" as const,
load: "not_run" as const, detail: `The profile check couldn't run (${(error as Error).message}); each launch runs the native check.`, observedAt: new Date().toISOString() }))
: undefined;
if (observed?.load === "failed") {
@@ -880,6 +893,12 @@ export class SeatLifecycleService {
try {
let persisted: PersistedEvent | null = null;
const result = this.db.transaction(() => {
if (exactNodeTarget && checksProfile) {
const available = this.db.prepare(`SELECT 1 FROM nodes n JOIN rigs r ON r.id = n.rig_id
WHERE n.id = ? AND r.archived_at IS NULL`).get(seat.nodeId);
if (!available) return { ok: false as const, code: "seat_not_found" as const,
message: `Node "${seat.nodeId}" no longer exists in an active rig.`, guidance: SEAT_LOOKUP_GUIDANCE };
}
const row = this.db.prepare("SELECT codex_config_profile FROM nodes WHERE id = ? AND runtime = 'codex'").get(seat.nodeId) as { codex_config_profile: string | null } | undefined;
if (!row) throw new Error("Seat runtime changed; the profile was not changed.");
const from = row.codex_config_profile;
@@ -911,7 +930,7 @@ export class SeatLifecycleService {
* resume and launches that read the seat's stored state use that provenance, while a launch from a
* spec that declares a policy re-applies the spec's. The ref is validated exactly as a spec
* member's is; the running process is untouched. */
async setPolicy(input: { seatRef: string; ref: string; reason: string; actor: string }): Promise<SetPolicyResult> {
async setPolicy(input: SeatSettingTarget & { ref: string; reason: string; actor: string }): Promise<SetPolicyResult> {
const required = this.requireReason(input.reason);
if (required) return required;
const invalid = validatePermissionPolicyRef(input.ref, "permission_policy");
@@ -926,7 +945,7 @@ export class SeatLifecycleService {
guidance: "Use builtin:<name> or none here, or declare the custom ref in the rig spec.",
};
}
const resolved = this.resolveSeat(input.seatRef);
const resolved = this.resolveSettingTarget(input);
if ("code" in resolved) return resolved;
const seat = this.describe(resolved);
if (resolved.entry.runtime === "terminal") {
@@ -2169,6 +2188,21 @@ export class SeatLifecycleService {
return row ?? null;
}
private settingTargetLabel(target: SeatSettingTarget): string {
return target.nodeId === undefined ? target.seatRef : target.nodeId;
}
private resolveSettingTarget(target: SeatSettingTarget): ResolvedSeat | SeatRefusal {
if (target.nodeId === undefined) return this.resolveSeat(target.seatRef);
const nodeId = target.nodeId.trim();
if (!nodeId) return { ok: false, code: "seat_ref_required", message: "node ID is required", guidance: SEAT_LOOKUP_GUIDANCE };
const row = this.db.prepare(`SELECT n.rig_id FROM nodes n JOIN rigs r ON r.id = n.rig_id
WHERE n.id = ? AND r.archived_at IS NULL`).get(nodeId) as { rig_id: string } | undefined;
const entry = row && getNodeInventory(this.db, row.rig_id).find(candidate => candidate.nodeId === nodeId);
if (!entry) return { ok: false, code: "seat_not_found", message: `Node "${nodeId}" not found in an active rig.`, guidance: SEAT_LOOKUP_GUIDANCE };
return { entry, nodeId };
}
/** The ONE resolution path, mirroring SeatStatusService.findMatches semantics
* (seat-status-service.ts) so every seat verb resolves identically: a canonical
* `name@rig` ref scopes to that rig's inventory; a bare ref scans all rigs;
@@ -105,6 +105,16 @@ export class SeatStatusService {
return { ok: true, status: this.projectStatus(ref, matches[0]!.entry) };
}
getStatusByNodeId(nodeId: string): SeatStatusResult {
const id = nodeId.trim();
if (!id) return { ok: false, code: "seat_ref_required", message: "node ID is required", guidance: SEAT_LOOKUP_GUIDANCE };
const row = this.rigRepo.db.prepare(`SELECT n.rig_id FROM nodes n JOIN rigs r ON r.id = n.rig_id
WHERE n.id = ? AND r.archived_at IS NULL`).get(id) as { rig_id: string } | undefined;
const entry = row && this.entriesForRig(row.rig_id).find(candidate => candidate.nodeId === id);
if (!entry) return { ok: false, code: "seat_not_found", message: `Node "${id}" not found in an active rig.`, guidance: SEAT_LOOKUP_GUIDANCE };
return { ok: true, status: this.projectStatus(entry.canonicalSessionName ?? deriveCanonicalFromEntry(entry) ?? entry.logicalId, entry) };
}
private findMatches(ref: string): SeatMatch[] {
// OPR.0.4.6.MH1 FR-8: the shared parse contract (greedy first-@ rig).
const parsed = parseSessionName(ref);
+38 -12
View File
@@ -122,6 +122,19 @@ seatRoutes.get("/status/:seatRef", (c) => {
return c.json(result, 404);
});
seatRoutes.get("/status/node/:nodeId", (c) => {
const rigRepo = c.get("rigRepo" as never) as RigRepository;
const nodeId = c.req.param("nodeId")!.trim();
const result = new SeatStatusService({ rigRepo, compactionEnforcer: c.get("compactionEnforcer" as never) as ClaudeCompactionEnforcer | undefined }).getStatusByNodeId(nodeId);
if (!result.ok) return c.json(result, result.code === "seat_ref_required" ? 400 : 404);
const guard = (c.get("tmuxAdapter" as never) as TmuxAdapter | undefined)?.deliveryGuard;
const runtimeSwitch = runtimeSwitchSummary(rigRepo.db, { nodeId });
return c.json({ ...result.status, node_id: nodeId, contract: "seat-node.v1",
supported_actions: ["set-permissions", "set-policy", "set-model", "set-effort"],
...(runtimeSwitch ? { runtimeSwitch } : {}),
...(guard ? { typingGuard: { ...guard.preference(nodeId), heldCount: new OutboxHandler(guard.db).heldForNode(nodeId, 1).total } } : {}) });
});
/** The handover's dependencies, which a runtime switch's successor launch shares. */
function handoverDeps(c: Context): ConstructorParameters<typeof SeatHandoverService>[0] {
const rigRepo = c.get("rigRepo" as never) as RigRepository;
@@ -368,7 +381,12 @@ seatRoutes.post("/set-terminal-policy/:seatRef", async c => {
return c.json(result, result.ok ? 200 : result.code === "invalid_terminal_policy" || result.code === "actor_required" ? 400 : seatLifecycleStatus(result.code));
});
seatRoutes.post("/set-permissions/:seatRef", async (c) => {
function settingTarget(c: Context) {
const nodeId = c.req.param("nodeId");
return nodeId !== undefined ? { nodeId } : { seatRef: c.req.param("seatRef")! };
}
const setPermissionsHandler = async (c: Context) => {
const body = await c.req.json<Record<string, unknown>>().catch(() => ({} as Record<string, unknown>));
const actor = transportSenderSession(c) ?? (typeof body?.operator === "string" ? body.operator.trim() : undefined);
if (!actor) return c.json({ error: "Sender identity is required; pass --operator <address> when running outside a managed seat." }, 400);
@@ -382,34 +400,40 @@ seatRoutes.post("/set-permissions/:seatRef", async (c) => {
const choice = typeof body.mode === "string" ? { mode: body.mode } : body.rules !== undefined ? { rules: body.rules }
: body.codexPermissions !== undefined ? { codexPermissions: body.codexPermissions } : { codexProfile: body.codexProfile as string };
const result = await seatLifecycleService(c).setPermissions({
seatRef: c.req.param("seatRef"), ...choice, reason: body.reason, actor,
...settingTarget(c), ...choice, reason: body.reason, actor,
});
return c.json(result, result.ok ? 200 : seatLifecycleStatus(result.code));
});
};
seatRoutes.post("/set-permissions/:seatRef", setPermissionsHandler);
seatRoutes.post("/set-permissions/node/:nodeId", setPermissionsHandler);
// `rig policy apply <name> --seat`: re-point an existing seat's stored permission_policy; the running process is unchanged.
seatRoutes.post("/set-policy/:seatRef", async (c) => {
const setPolicyHandler = async (c: Context) => {
const body = await c.req.json<Record<string, unknown>>().catch(() => ({} as Record<string, unknown>));
const actor = transportSenderSession(c) ?? (typeof body?.operator === "string" ? body.operator.trim() : undefined);
if (!actor) return c.json({ error: "Sender identity is required; pass --operator <address> when running outside a managed seat." }, 400);
if (!body || Array.isArray(body) || typeof body.ref !== "string") return c.json({ error: "ref is required" }, 400);
const result = await seatLifecycleService(c).setPolicy({
seatRef: c.req.param("seatRef"), ref: body.ref, reason: typeof body.reason === "string" ? body.reason : "", actor,
...settingTarget(c), ref: body.ref, reason: typeof body.reason === "string" ? body.reason : "", actor,
});
return c.json(result, result.ok ? 200 : seatLifecycleStatus(result.code));
});
};
seatRoutes.post("/set-policy/:seatRef", setPolicyHandler);
seatRoutes.post("/set-policy/node/:nodeId", setPolicyHandler);
seatRoutes.post("/set-model/:seatRef", async (c) => {
const setModelHandler = async (c: Context) => {
const body: Record<string, unknown> = await c.req.json().catch(() => ({}));
const result = await seatLifecycleService(c).setModel({
seatRef: c.req.param("seatRef")!,
...settingTarget(c),
model: typeof body["model"] === "string" ? body["model"] : "",
reason: typeof body["reason"] === "string" ? body["reason"] : "",
operator: transportSenderSession(c) ?? (typeof body["operator"] === "string" ? body["operator"] : null),
});
if (result.ok) return c.json(result);
return c.json(result, seatLifecycleStatus(result.code));
});
};
seatRoutes.post("/set-model/:seatRef", setModelHandler);
seatRoutes.post("/set-model/node/:nodeId", setModelHandler);
seatRoutes.post("/set-cwd/:seatRef", async (c) => {
const value: unknown = await c.req.json().catch(() => null);
@@ -425,19 +449,21 @@ seatRoutes.post("/set-cwd/:seatRef", async (c) => {
// OPR.0.7.0.7 — save (or, with clear: true, remove) one seat's effort choice for its next
// managed continuation. A distinct route fails closed against daemons without it.
seatRoutes.post("/set-effort/:seatRef", async (c) => {
const setEffortHandler = async (c: Context) => {
const body: Record<string, unknown> = await c.req.json().catch(() => ({}));
const clear = body["clear"] === true;
if (clear && body["effort"] !== undefined) return c.json({ ok: false, code: "invalid_effort", message: "Pass an effort level or clear, not both." }, 400);
const result = await seatLifecycleService(c).setEffort({
seatRef: c.req.param("seatRef")!,
...settingTarget(c),
effort: clear ? null : typeof body["effort"] === "string" ? body["effort"] : "",
reason: typeof body["reason"] === "string" ? body["reason"] : "",
operator: transportSenderSession(c) ?? (typeof body["operator"] === "string" ? body["operator"] : null),
});
if (result.ok) return c.json(result);
return c.json(result, seatLifecycleStatus(result.code));
});
};
seatRoutes.post("/set-effort/:seatRef", setEffortHandler);
seatRoutes.post("/set-effort/node/:nodeId", setEffortHandler);
// OPR.0.7.0.7 — read one seat's effort four ways: requested, saved, emitted at launch, observed natively.
seatRoutes.get("/effort/:seatRef", async (c) => {
+42 -2
View File
@@ -14,10 +14,17 @@ import type { NodeBinding } from "../src/domain/runtime-adapter.js";
import { NO_SESSION_BUS } from "../src/domain/seat-runtime-dir.js";
import { SEAT_ENV_KEYS_VAR } from "../src/domain/seat-env.js";
import { shellQuote } from "../src/adapters/shell-quote.js";
import { createFullTestDb } from "./helpers/test-app.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { EventBus } from "../src/domain/event-bus.js";
import { SeatLifecycleService } from "../src/domain/seat-lifecycle-service.js";
// No provider, tmux or startup. Config-selection cases execute only a private
// fake Claude through the generated shell command; other child calls are mocked.
vi.mock("node:child_process", () => ({ execFile: vi.fn() }));
vi.mock("node:child_process", async importOriginal => ({
...await importOriginal<typeof import("node:child_process")>(), execFile: vi.fn(),
}));
// Simulate eligibility for inert fixtures; path resolution/stat/replacement use
// private real files. Config-selection cases additionally execute their fake.
vi.mock("node:fs", async importOriginal => {
@@ -62,7 +69,7 @@ function fixture() {
const deps: Record<string,unknown> = { rigRepo: { db }, sessionRegistry: { db }, eventBus, tmuxAdapter: tmux, runtimeAdapters: { "claude-code": adapter } };
// Use the production route constructor and its actual adapter/service wiring.
const service = seatLifecycleService({ get: key => deps[key] });
vi.spyOn(service as any, "resolveSeat").mockReturnValue({ nodeId: "node", entry: { runtime: "claude-code", cwd } });
vi.spyOn(service as any, "resolveSeat").mockReturnValue({ nodeId: "node", entry: { rigId: "rig", runtime: "claude-code", cwd } });
vi.spyOn(service as any, "describe").mockReturnValue({ nodeId: "node", rigId: "rig", logicalId: "owner", rigName: "rig" });
vi.mocked(execFile).mockImplementation(((file: string, _args: string[], options: any, done: any) => {
expect(file).toBe(executable); expect(options.cwd).toBe(cwd); expect(options.env.PATH).toBe(path.join(cwd,"bin") + path.delimiter + daemonBin);
@@ -354,6 +361,39 @@ else console.log(JSON.stringify(Object.fromEntries(${JSON.stringify(paneNames)}
expect(f.db.prepare("SELECT COUNT(*) n FROM events").get()).toEqual({ n: 1 }); expect(f.calls).toEqual([]);
expect(JSON.stringify(f.db.prepare("SELECT * FROM events").all())).not.toContain(f.env.ANTHROPIC_API_KEY);
});
it("keeps node-ID permission saves bound to the real Claude generation check during help", async () => {
const f = fixture();
const db = createFullTestDb(); open.push(db);
const rigRepo = new RigRepository(db), registry = new SessionRegistry(db), eventBus = new EventBus(db);
const rig = rigRepo.createRig("node-rig");
const node = rigRepo.addNode(rig.id, "worker", { runtime: "claude-code", cwd: f.cwd });
registry.registerSession(node.id, "worker@node-rig");
registry.updateBinding(node.id, { attachmentType: "tmux", tmuxSession: "worker@node-rig", tmuxPane: "%1" });
const managed = new ClaudeManagedLaunch(db, f.env, f.renderer);
const service = new SeatLifecycleService({ db, rigRepo, sessionRegistry: registry, eventBus, tmuxAdapter: f.tmux,
runtimeAdapters: { "claude-code": { claudeManagedLaunch: managed } as never } });
const choice = { nodeId: node.id, mode: "auto", actor: "operator", reason: "reviewed native choice" };
let replaceDuringHelp = true;
vi.mocked(execFile).mockImplementation(((file: string, _args: string[], options: any, done: any) => {
expect(file).toBe(f.executable);
expect(options.cwd).toBe(f.cwd);
if (replaceDuringHelp) {
replaceDuringHelp = false;
registry.registerSession(node.id, "worker-next@node-rig", "handover");
}
done(null, help);
}) as any);
expect(await service.setPermissions(choice)).toMatchObject({ ok: false, code: "permission_selection_refused" });
expect(db.prepare("SELECT * FROM node_permission_selections WHERE node_id=?").all(node.id)).toEqual([]);
expect(db.prepare("SELECT * FROM events WHERE type='node.permissions_changed'").all()).toEqual([]);
// A fresh request prepares against the current generation and can save the choice.
expect(await service.setPermissions(choice)).toMatchObject({ ok: true, changed: true, seat: { nodeId: node.id, rigId: rig.id } });
expect(db.prepare("SELECT mode, actor FROM node_permission_selections WHERE node_id=?").get(node.id)).toEqual({ mode: "auto", actor: "operator" });
expect(db.prepare("SELECT COUNT(*) n FROM events WHERE type='node.permissions_changed'").get()).toEqual({ n: 1 });
expect(f.calls).toEqual([]);
});
it("does not try a daemon executable just because its vocabulary is newer", async () => {
const f = fixture(); vi.stubEnv("PATH", f.daemonBin);
vi.mocked(execFile).mockImplementation(((file: string, _args: string[], _options: any, done: any) => {
@@ -0,0 +1,182 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type Database from "better-sqlite3";
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
import { SeatLifecycleService } from "../src/domain/seat-lifecycle-service.js";
import type { SeatDeliveryGuard } from "../src/domain/seat-delivery-guard.js";
describe("node-addressed seat settings", () => {
let db: Database.Database;
let app: ReturnType<typeof createTestApp>;
beforeEach(() => { db = createFullTestDb(); app = createTestApp(db); });
afterEach(() => db.close());
function add(rigName: string) {
const rig = app.rigRepo.createRig(rigName);
const node = app.rigRepo.addNode(rig.id, "worker", { runtime: "codex", model: "old" });
return { rig, node };
}
async function post(verb: string, target: string, body: Record<string, unknown>) {
return app.app.request(`/api/seat/${verb}/${target}`, { method: "POST",
headers: { "content-type": "application/json", "x-openrig-session": "operator@control" },
body: JSON.stringify({ ...body, reason: "Reviewed setting" }) });
}
const verbs = [
{ name: "set-permissions", body: { mode: "floor" }, event: "node.permissions_changed" },
{ name: "set-policy", body: { ref: "builtin:yolo" }, event: "node.policy_changed" },
{ name: "set-model", body: { model: "new" }, event: "node.model_changed" },
{ name: "set-effort", body: { effort: "high" }, event: "node.effort_changed" },
];
it.each(verbs)("$name binds to the exact node amid ambiguous names and leaves history untouched", async ({ name, body, event }) => {
const selected = add("one"), other = add("two");
const otherBefore = db.prepare("SELECT model, effort_choice, permission_policy FROM nodes WHERE id=?").get(other.node.id);
const session = app.sessionRegistry.registerSession(selected.node.id, "worker@one");
app.sessionRegistry.updateBinding(selected.node.id, { attachmentType: "tmux", tmuxSession: "worker@one", tmuxPane: "%1" });
const before = ["sessions", "bindings", "occupant_tenures"].map(t => db.prepare(`SELECT * FROM ${t}`).all());
const ambiguous = await post(name, "worker", body);
expect(ambiguous.status).toBe(409);
expect(await ambiguous.json()).toMatchObject({ code: "seat_ambiguous" });
const response = await post(name, `node/${selected.node.id}`, body);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ ok: true, changed: true,
seat: { nodeId: selected.node.id, rigId: selected.rig.id } });
expect(["sessions", "bindings", "occupant_tenures"].map(t => db.prepare(`SELECT * FROM ${t}`).all())).toEqual(before);
expect(db.prepare("SELECT id FROM sessions WHERE id=?").get(session.id)).toBeDefined();
const events = db.prepare("SELECT payload FROM events WHERE type=?").all(event) as Array<{ payload: string }>;
expect(events).toHaveLength(1);
expect(JSON.parse(events[0]!.payload)).toMatchObject({ nodeId: selected.node.id, rigId: selected.rig.id,
reason: "Reviewed setting", [name === "set-permissions" ? "actor" : "operator"]: "operator@control" });
expect(db.prepare("SELECT model, effort_choice, permission_policy FROM nodes WHERE id=?").get(other.node.id)).toEqual(otherBefore);
expect(db.prepare("SELECT COUNT(*) n FROM node_permission_selections WHERE node_id=?").get(other.node.id)).toEqual({ n: 0 });
});
it.each(verbs)("$name refuses deleted and archived IDs even with reusable names", async ({ name, body }) => {
const selected = add("one");
const replacement = add("two");
app.rigRepo.deleteNode(selected.node.id);
const missing = await post(name, `node/${selected.node.id}`, body);
expect(missing.status).toBe(404);
expect(await missing.json()).toMatchObject({ code: "seat_not_found" });
expect(app.rigRepo.getRig(replacement.rig.id)!.nodes[0]!.id).toBe(replacement.node.id);
app.rigRepo.archiveRig(replacement.rig.id);
const archived = await post(name, `node/${replacement.node.id}`, body);
expect(archived.status).toBe(404);
expect(await archived.json()).toMatchObject({ code: "seat_not_found" });
expect(db.prepare("SELECT COUNT(*) AS n FROM events WHERE node_id IN (?,?)").get(selected.node.id, replacement.node.id)).toEqual({ n: 0 });
});
it.each(verbs)("$name never retargets a deleted ID when the same rig reuses its name", async ({ name, body, event }) => {
const original = add("one");
app.rigRepo.deleteNode(original.node.id);
const replacement = app.rigRepo.addNode(original.rig.id, "worker", { runtime: "codex", model: "old" });
const oldId = await post(name, `node/${original.node.id}`, body);
expect(oldId.status).toBe(404);
expect(await oldId.json()).toMatchObject({ code: "seat_not_found" });
expect(db.prepare("SELECT COUNT(*) n FROM events WHERE type=?").get(event)).toEqual({ n: 0 });
const legacy = await post(name, "worker@one", body);
expect(legacy.status).toBe(200);
expect(await legacy.json()).toMatchObject({ ok: true, changed: true,
seat: { nodeId: replacement.id, rigId: original.rig.id } });
const rows = db.prepare("SELECT node_id FROM events WHERE type=?").all(event);
expect(rows).toEqual([{ node_id: replacement.id }]);
});
it("reads the exact node status and advertises the additive contract", async () => {
const chosen = add("one"); add("two");
const exact = await app.app.request(`/api/seat/status/node/${chosen.node.id}`);
expect(exact.status).toBe(200);
expect(await exact.json()).toMatchObject({ node_id: chosen.node.id, rig_id: chosen.rig.id,
contract: "seat-node.v1", supported_actions: verbs.map(v => v.name) });
const legacy = await app.app.request("/api/seat/status/worker");
expect(legacy.status).toBe(409);
const named = await app.app.request("/api/seat/status/worker@one");
expect(named.status).toBe(200);
expect(await named.json()).not.toHaveProperty("contract");
app.rigRepo.archiveRig(chosen.rig.id);
expect((await app.app.request(`/api/seat/status/node/${chosen.node.id}`)).status).toBe(404);
});
it("normalizes an encoded padded ID in exact status and guard enrichment", async () => {
const selected = add("one");
db.exec("CREATE TABLE outbox_entries (delivery_state TEXT, guard_binding TEXT, ts_dispatched TEXT, outbox_id TEXT)");
db.prepare("INSERT INTO outbox_entries (delivery_state, guard_binding, ts_dispatched, outbox_id) VALUES ('retained', ?, '', 'held')")
.run(JSON.stringify({ nodeId: selected.node.id }));
const preference = vi.fn((nodeId: string) => ({ nodeId, desired: false, effective: false, pending: false,
desiredMode: "off", effectiveMode: "off" }));
app.tmuxAdapter.deliveryGuard = { db, preference } as unknown as SeatDeliveryGuard;
const response = await app.app.request(`/api/seat/status/node/${encodeURIComponent(` ${selected.node.id} `)}`);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ node_id: selected.node.id, rig_id: selected.rig.id,
typingGuard: { nodeId: selected.node.id, heldCount: 1 } });
expect(preference).toHaveBeenCalledExactlyOnceWith(selected.node.id);
});
it.each(verbs)("$name refuses a formerly valid node after archive, even for the same value", async ({ name, body, event }) => {
const selected = add("one");
expect((await post(name, `node/${selected.node.id}`, body)).status).toBe(200);
const noOp = await post(name, `node/${selected.node.id}`, body);
expect(noOp.status).toBe(200);
expect(await noOp.json()).toMatchObject({ ok: true, changed: false, seat: { nodeId: selected.node.id } });
const count = db.prepare("SELECT COUNT(*) n FROM events WHERE type=?").get(event);
app.rigRepo.archiveRig(selected.rig.id);
const refusal = await post(name, `node/${selected.node.id}`, body);
expect(refusal.status).toBe(404);
expect(await refusal.json()).toMatchObject({ code: "seat_not_found" });
expect(db.prepare("SELECT COUNT(*) n FROM events WHERE type=?").get(event)).toEqual(count);
});
it.each(["delete", "archive"] as const)(
"refuses an unavailable exact node after awaited Claude preparation: %s", async change => {
const rig = app.rigRepo.createRig("claude-rig");
const node = app.rigRepo.addNode(rig.id, "worker", { runtime: "claude-code", cwd: "/old" });
let enter!: () => void, release!: () => void;
const entered = new Promise<void>(resolve => { enter = resolve; });
const pending = new Promise<void>(resolve => { release = resolve; });
const service = new SeatLifecycleService({ db, rigRepo: app.rigRepo, sessionRegistry: app.sessionRegistry,
eventBus: app.eventBus, tmuxAdapter: app.tmuxAdapter,
runtimeAdapters: { "claude-code": { claudeManagedLaunch: { prepare: async () => {
enter(); await pending;
// This no-op assertion isolates the added node eligibility check. S03 separately
// verifies that real Claude preparation refuses generation changes during help.
return { assertCurrent: () => {} };
} } } as never } });
const saving = service.setPermissions({ nodeId: node.id, mode: "acceptEdits", reason: "native choice", actor: "operator@control" });
await entered;
if (change === "delete") app.rigRepo.deleteNode(node.id);
else app.rigRepo.archiveRig(rig.id);
release();
const result = await saving;
expect(result).toMatchObject({ ok: false, code: "seat_not_found" });
expect(db.prepare("SELECT COUNT(*) n FROM events WHERE type='node.permissions_changed'").get())
.toEqual({ n: 0 });
expect(db.prepare("SELECT COUNT(*) n FROM node_permission_selections WHERE node_id=?").get(node.id))
.toEqual({ n: 0 });
});
it.each(["delete", "archive"] as const)(
"refuses an unavailable exact node after awaited Codex profile observation: %s", async change => {
const selected = add("one");
db.prepare("UPDATE nodes SET cwd='/old' WHERE id=?").run(selected.node.id);
let enter!: () => void, release!: () => void;
const entered = new Promise<void>(resolve => { enter = resolve; });
const pending = new Promise<void>(resolve => { release = resolve; });
const service = new SeatLifecycleService({ db, rigRepo: app.rigRepo, sessionRegistry: app.sessionRegistry,
eventBus: app.eventBus, tmuxAdapter: app.tmuxAdapter,
runtimeAdapters: { codex: { observeProfile: async () => {
enter(); await pending;
return { vantage: "configured_codex_home", home: "/home/codex", file: "absent", load: "ok",
detail: "Profile file absent", observedAt: new Date().toISOString() };
} } as never } });
const saving = service.setPermissions({ nodeId: selected.node.id, codexProfile: "reviewed", reason: "native choice", actor: "operator@control" });
await entered;
if (change === "delete") app.rigRepo.deleteNode(selected.node.id);
else app.rigRepo.archiveRig(selected.rig.id);
release();
expect(await saving).toMatchObject({ ok: false, code: "seat_not_found" });
expect(db.prepare("SELECT COUNT(*) n FROM events WHERE type='node.codex_profile_changed'").get()).toEqual({ n: 0 });
expect(db.prepare("SELECT COUNT(*) n FROM nodes WHERE id=? AND codex_config_profile IS NOT NULL").get(selected.node.id))
.toEqual({ n: 0 });
});
});