diff --git a/docs/reference/seat-node-targeting.md b/docs/reference/seat-node-targeting.md new file mode 100644 index 000000000..b02a3c9ec --- /dev/null +++ b/docs/reference/seat-node-targeting.md @@ -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/` 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. diff --git a/packages/daemon/src/domain/seat-lifecycle-service.ts b/packages/daemon/src/domain/seat-lifecycle-service.ts index 8da4b19f9..2538d9dba 100644 --- a/packages/daemon/src/domain/seat-lifecycle-service.ts +++ b/packages/daemon/src/domain/seat-lifecycle-service.ts @@ -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 { + async setModel(input: SeatSettingTarget & { model: string; reason: string; operator?: string | null }): Promise { 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 { + async setEffort(input: SeatSettingTarget & { effort: string | null; reason: string; operator?: string | null }): Promise { 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 | 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 { + async setPolicy(input: SeatSettingTarget & { ref: string; reason: string; actor: string }): Promise { 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: 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; diff --git a/packages/daemon/src/domain/seat-status-service.ts b/packages/daemon/src/domain/seat-status-service.ts index 4901dd436..90a08f851 100644 --- a/packages/daemon/src/domain/seat-status-service.ts +++ b/packages/daemon/src/domain/seat-status-service.ts @@ -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); diff --git a/packages/daemon/src/routes/seat.ts b/packages/daemon/src/routes/seat.ts index b966f1b9c..7108e4d11 100644 --- a/packages/daemon/src/routes/seat.ts +++ b/packages/daemon/src/routes/seat.ts @@ -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[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>().catch(() => ({} as Record)); const actor = transportSenderSession(c) ?? (typeof body?.operator === "string" ? body.operator.trim() : undefined); if (!actor) return c.json({ error: "Sender identity is required; pass --operator
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 --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>().catch(() => ({} as Record)); const actor = transportSenderSession(c) ?? (typeof body?.operator === "string" ? body.operator.trim() : undefined); if (!actor) return c.json({ error: "Sender identity is required; pass --operator
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 = 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 = 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) => { diff --git a/packages/daemon/test/s03-bound-launch.test.ts b/packages/daemon/test/s03-bound-launch.test.ts index 18097da9d..0df16d7a2 100644 --- a/packages/daemon/test/s03-bound-launch.test.ts +++ b/packages/daemon/test/s03-bound-launch.test.ts @@ -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(), 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 = { 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) => { diff --git a/packages/daemon/test/seat-node-targeting.test.ts b/packages/daemon/test/seat-node-targeting.test.ts new file mode 100644 index 000000000..abb411f57 --- /dev/null +++ b/packages/daemon/test/seat-node-targeting.test.ts @@ -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; + + 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) { + 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(resolve => { enter = resolve; }); + const pending = new Promise(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(resolve => { enter = resolve; }); + const pending = new Promise(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 }); + }); +});