Compare commits

...
Author SHA1 Message Date
review50-r3 104a7bd11b fix(daemon): a seat restore stops for on its runtime previews unknown
When a seat's saved startup context names a different runtime from the
one it has now, restore stops for a decision before launch. The
permission preview now reads unknown with that reason in both plans
(rig up --existing --plan and rig launch --plan), instead of a definite
mode beside the awaiting decision.

The shared seat projection carries restore's own runtime check
(restoreRuntimeCompatibilityError), so both plan producers get it. No new
refusal and no change to what launch does.
2026-11-13 19:52:18 +00:00
review50-r3 d362cdb09a fix(daemon): an unsettled restore path previews unknown, and every runtime's choice is checked
A restore path this read can't settle now previews as unknown with its
reason instead of a definite mode: a seat no saved snapshot names, a seat
outside a pod (restore takes its runtime from the resume token), and a pod
seat without saved startup context (restore starts it fresh and re-reads
its policy then). Only a pod seat with saved startup context is planned,
the path restore's posture is exact for.

The preview first makes the permission decision startup makes for every
runtime, so a Pi seat holding a choice made for another runtime, or an
unreadable one, previews unknown with startup's own reason instead of "no
native permission policy". No new refusal and no precedence change.
2026-11-13 19:18:05 +00:00
review50-r3andClaude Opus 5.5 abe652d7aa fix(daemon): show each seat's restore permission in the plan previews
After a restart a seat can come back in a permission mode the person
didn't expect, decided by the usual precedence. `rig up --existing --plan`
and `rig launch --plan` now show, per seat, the permission restore will
start it with and where it comes from: the seat's own choice, a member or
rig policy, or the team or kernel default.

planPermissionAccount can plan a restore: it starts from the posture
restore re-reads from the seat's saved provenance
(RestoreOrchestrator.resolveRestorePosture), through the same
NativePermissionStore.apply restore's launch calls. planRestorePermission
wraps it for the previews: a fact restore would refuse on reads as unknown
with that reason, and it names the restore paths whose posture this read
can't settle (no saved startup context, a seat outside a pod, a seat no
saved snapshot names). A seat restore leaves attached gets none. No
precedence change; the plans stay read-only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-13 16:23:22 +00:00
12 changed files with 301 additions and 14 deletions

No files matched your search

+6 -1
View File
@@ -22,7 +22,7 @@ type LaunchResponse = {
held?: Array<{ nodeId: string; logicalId: string; reason: string }>;
alreadyRunning?: Array<{ nodeId: string; logicalId: string }>;
failedTargets?: Array<{ nodeId: string; logicalId: string; reason: string }>;
targetNodes?: Array<{ nodeId: string; logicalId: string }>;
targetNodes?: Array<{ nodeId: string; logicalId: string; permission?: { summary?: string } }>;
snapshotSelection?: { snapshotId: string; kind: string; createdAt: string; ageMs: number; mode: "explicit" | "automatic"; rationale: string; newerUsableAlternative: unknown };
nonTargetEffects?: { mode: "unchanged" | "detach_and_hold"; reason: string | null; affected: unknown[] };
planOnly?: boolean;
@@ -317,6 +317,11 @@ export function launchCommand(depsOverride?: StatusDeps): Command {
if (opts.plan && res.data.planOnly) {
console.log("Plan only; no changes made.");
printSnapshotSelection(res.data.snapshotSelection);
// Each target and the permission this launch will start it with (--json has the whole account).
if (res.data.targetNodes?.length) console.log("Targets:");
for (const target of res.data.targetNodes ?? []) {
console.log(` ${target.logicalId}${target.permission?.summary ? `: ${target.permission.summary}` : ""}`);
}
console.log(`Non-target effect: ${res.data.nonTargetEffects?.mode ?? "unavailable"}`);
for (const node of res.data.nonTargetEffects?.affected ?? []) {
const affected = node as { logicalId?: string; reason?: string };
+3 -1
View File
@@ -685,7 +685,7 @@ as no rig by that name or a failed boot).
} else if (res.data["wouldCaptureCurrentState"] === true) {
console.log(`Snapshot: none usable — apply would first capture current DB state as an auto-rehydrate snapshot.`);
}
const planNodes = (res.data["nodes"] as Array<{ logicalId: string; intendedAction: string; reason?: string; tokenState?: string; provenance?: string | null; lastVerified?: string | null; freshRequired?: boolean; runtimePrompt?: string }>) ?? [];
const planNodes = (res.data["nodes"] as Array<{ logicalId: string; intendedAction: string; reason?: string; tokenState?: string; provenance?: string | null; lastVerified?: string | null; freshRequired?: boolean; runtimePrompt?: string; permission?: { summary?: string } }>) ?? [];
for (const n of planNodes) {
console.log(` ${n.logicalId}: ${n.intendedAction}${n.reason ? ` — ${n.reason}` : ""}`);
// OPR.0.4.3.20 FR-6 — per-seat token truth: present/missing/stale/unverified
@@ -698,6 +698,8 @@ as no rig by that name or a failed boot).
const prompt = n.runtimePrompt ? `; ${n.runtimePrompt}` : "";
console.log(` token: ${n.tokenState}${reverify}${prov}${verified}${fresh}${prompt}`);
}
// The permission restore will start the seat with, and where it comes from (--json has the whole account).
if (n.permission?.summary) console.log(` permission: ${n.permission.summary}`);
}
console.log("No changes made.");
return;
+23
View File
@@ -114,6 +114,25 @@ describe("rig launch --seats", () => {
expect(logs.join("\n")).not.toContain("Launched");
});
it.each([
["no saved snapshot names this seat yet, so which launch path restore takes for it isn't known."],
["restore takes this seat's runtime from its resume token, which this read doesn't know."],
["this seat has no saved startup context, so restore starts it fresh and re-reads its policy then."],
])("--plan prints an unsettled target's permission as unknown with the reason: %s", async (reason) => {
const deps = makeDeps({
"launch-subset": {
status: 200,
data: { ok: true, planOnly: true, snapshotSelection: { snapshotId: "snap-1", mode: "automatic" },
targetNodes: [{ nodeId: "n-1", logicalId: "dev.driver", permission: { basis: "unknown", runtime: "claude-code", summary: `Unknown: ${reason}` } }],
nonTargetEffects: { mode: "detach_and_hold", reason: "operator hold", affected: [{ logicalId: "dev.guard", reason: "operator hold" }] } },
},
});
deps._client.get.mockResolvedValue({ status: 200, data: { version: "0.6.8" } });
await launchCommand(deps).parseAsync(["node", "rig", "rig-1", "--seats", "dev.driver", "--hold-reason", "operator hold", "--plan"]);
expect(logs.join("\n")).toContain(`Targets:\n dev.driver: Unknown: ${reason}`);
expect(logs.join("\n")).not.toContain("On restore");
});
it("passes --plan through and renders non-target effects without claiming launch", async () => {
const deps = makeDeps({
"launch-subset": {
@@ -122,6 +141,8 @@ describe("rig launch --seats", () => {
ok: true,
planOnly: true,
snapshotSelection: { snapshotId: "snap-1", mode: "automatic" },
targetNodes: [{ nodeId: "n-1", logicalId: "dev.driver",
permission: { basis: "planned", summary: "On restore: --permission-mode acceptEdits, from OpenRig's team default (no policy or seat choice is recorded)." } }],
nonTargetEffects: {
mode: "detach_and_hold",
reason: "operator hold",
@@ -144,6 +165,8 @@ describe("rig launch --seats", () => {
undefined,
);
expect(logs.join("\n")).toContain("Plan only; no changes made.");
// Each target and the permission this launch will start it with.
expect(logs.join("\n")).toContain("Targets:\n dev.driver: On restore: --permission-mode acceptEdits, from OpenRig's team default");
expect(logs.join("\n")).toContain("dev.guard: operator hold");
expect(process.exitCode).toBeUndefined();
});
+21 -1
View File
@@ -241,6 +241,22 @@ describe("rig up --existing — resume-original default (OPR.0.3.4.2)", () => {
expect(exitCode).toBe(1);
});
it.each([
["no saved snapshot names this seat yet, so which launch path restore takes for it isn't known."],
["restore takes this seat's runtime from its resume token, which this read doesn't know."],
["this seat has no saved startup context, so restore starts it fresh and re-reads its policy then."],
])("--plan prints an unsettled seat's permission as unknown with the reason: %s", async (reason) => {
respond = () => ({ status: "plan", mode: "restore", rigId: "rig-1", rigName: "myrig", snapshot: null, wouldCaptureCurrentState: true,
nodes: [{ logicalId: "dev.impl", intendedAction: "fresh-primed", permission: { basis: "unknown", runtime: "claude-code", summary: `Unknown: ${reason}` } }],
mutated: false });
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "myrig", "--existing", "--plan"]);
});
const output = logs.join("\n");
expect(output).toContain(` permission: Unknown: ${reason}`);
expect(output).not.toContain("On restore");
});
// OPR.0.3.4.4 — plan-restore preview render + honest-async timeout.
it("--plan restore preview renders per-seat intended actions and 'No changes made.'", async () => {
respond = () => ({
@@ -251,7 +267,8 @@ describe("rig up --existing — resume-original default (OPR.0.3.4.2)", () => {
snapshot: { id: "snap-9", kind: "auto-pre-down", createdAt: "2026-06-11T00:00:00Z" },
wouldCaptureCurrentState: false,
nodes: [
{ logicalId: "dev.impl", intendedAction: "resume-original" },
{ logicalId: "dev.impl", intendedAction: "resume-original",
permission: { basis: "planned", summary: "On restore: --permission-mode auto, from this seat's own choice (person@host: after the reboot)." } },
{ logicalId: "dev.qa", intendedAction: "awaiting-decision", reason: "resume source 'claude_name' recorded but no token available — apply would stop and ask (zero session)" },
],
mutated: false,
@@ -261,6 +278,9 @@ describe("rig up --existing — resume-original default (OPR.0.3.4.2)", () => {
await makeCmd().parseAsync(["node", "rig", "up", "myrig", "--existing", "--plan"]);
});
const output = logs.join("\n");
// Each seat's permission on restore, and where it comes from, before anything launches.
expect(output).toContain(" permission: On restore: --permission-mode auto, from this seat's own choice (person@host: after the reboot).");
expect(output.match(/permission: /g)).toHaveLength(1);
expect(output).toContain('Plan: restore rig "myrig"');
expect(output).toContain("snap-9");
expect(output).toContain("dev.impl: resume-original");
@@ -51,8 +51,10 @@ function policyRefs(db: Database.Database, nodeId: string): { member: string | n
return { ...refs, nonInterruptive };
}
/** `restore`: plan the seat's restore instead of its next fresh launch. Restore starts from the policy posture it re-reads
* from the seat's saved provenance (RestoreOrchestrator.resolveRestorePosture), not the cached one a fresh launch uses. */
export function planPermissionAccount(db: Database.Database, nodeId: string, seat: string, runtime: string | null,
env: NodeJS.ProcessEnv = process.env): PermissionAccount {
env: NodeJS.ProcessEnv = process.env, restore?: { launchPosture: NonNullable<NodeBinding["launchPosture"]> }): PermissionAccount {
const controls = [
`rig seat set-permissions ${seat} --mode <mode> --reason "<why>"`,
`rig seat set-permissions ${seat} --mode inherit --reason "<why>" (clears the seat's choice)`,
@@ -79,7 +81,7 @@ export function planPermissionAccount(db: Database.Database, nodeId: string, sea
// choice and the team or kernel default through NativePermissionStore.apply.
const repo = new RigRepository(db);
const rigId = (db.prepare("SELECT rig_id FROM nodes WHERE id = ?").get(nodeId) as { rig_id: string } | undefined)?.rig_id;
const bindingPosture = repo.getNodePolicyProvenance(nodeId)?.launchPosture
const bindingPosture = restore?.launchPosture ?? repo.getNodePolicyProvenance(nodeId)?.launchPosture
?? (rigId ? repo.getRigPolicyProvenance(rigId)?.launchPosture : undefined) ?? "floor";
const planned = store.apply({ nodeId, launchPosture: bindingPosture } as NodeBinding, runtime);
const posture = planned.launchPosture;
@@ -98,7 +100,9 @@ export function planPermissionAccount(db: Database.Database, nodeId: string, sea
: codexPostureArg(profile ? ` -p ${profile}` : "", env, posture).trim();
const openRigAdds: PermissionAccount["openRigAdds"] = { claudeAllow: [], claudeAsk: [], notes: [] };
const unknown = [NOT_READ, UNVERIFIED,
runtime === "codex" && envelope
restore
? "This is the plan for restoring this seat, from the policy posture restore re-reads from its saved provenance, then the seat's own choice and the team or kernel default."
: runtime === "codex" && envelope
? "This is the saved plan for a fresh launch. Resumes, restores and handovers emit the same envelope."
: runtime === "codex"
? "This is the saved plan for a fresh launch. A restore re-reads a custom policy file, and a rebuild handover can carry the previous occupant's observed Codex sandbox and approvals instead, without the extra roots or network setting."
@@ -178,6 +182,36 @@ export function planPermissionAccount(db: Database.Database, nodeId: string, sea
return {
basis: "planned", runtime, source, nativeArgument, overrides, openRigAdds, unknown, controls, advisories: planNote ? [planNote] : [],
...(explicit ? { explicit: { mode: explicit.mode, actor: explicit.actor, reason: explicit.reason, updatedAt: explicit.updatedAt } } : {}),
summary: `Next launch: ${nativeArgument}, from ${where[source]}${notes}${adds}.`,
summary: `${restore ? "On restore" : "Next launch"}: ${nativeArgument}, from ${where[source]}${notes}${adds}.`,
};
}
/** What a restore preview shows for one seat: its account as restore will decide it, or why it can't be read. */
export type RestorePermissionPreview = PermissionAccount | { basis: "unknown"; runtime: string | null; summary: string };
/** One seat's permission as a restore will start it, for the plan previews (`rig up --existing --plan`, `rig launch
* --plan`): restore's own posture through the same NativePermissionStore.apply its launch calls. It's planned only for
* the path that posture is exact for, a pod seat with saved startup context; `podAware` and `startupContext` come from
* the snapshot restore reads (undefined when no saved node names the seat). Every other path, a runtime restore stops
* for before launch, and a permission fact restore would refuse on read unknown with the reason, never a definite mode. */
export function planRestorePermission(db: Database.Database, input: {
nodeId: string; seat: string; runtime: string | null;
restorePosture: () => NonNullable<NodeBinding["launchPosture"]>;
podAware?: boolean; startupContext?: boolean; runtimeError?: string;
}, env: NodeJS.ProcessEnv = process.env): RestorePermissionPreview {
const unknown = (reason: string): RestorePermissionPreview => ({ basis: "unknown", runtime: input.runtime, summary: `Unknown: ${reason}` });
if (input.runtimeError) return unknown(`restore stops for a runtime decision before it starts this seat. ${input.runtimeError}`);
if (input.podAware === undefined) return unknown("no saved snapshot names this seat yet, so which launch path restore takes for it isn't known.");
if (!input.podAware) return unknown("restore takes this seat's runtime from its resume token, which this read doesn't know.");
if (input.startupContext === false) return unknown("this seat has no saved startup context, so restore starts it fresh and re-reads its policy then.");
if (input.runtime === null) return unknown("this seat's runtime isn't recorded.");
try {
const launchPosture = input.restorePosture();
// Startup makes this decision for every runtime (StartupOrchestrator's apply) and fails the seat on its error, so a
// choice it would refuse, even on a runtime OpenRig sets no native argument for, reads unknown here too.
new NativePermissionStore(db).apply({ nodeId: input.nodeId, launchPosture } as NodeBinding, input.runtime);
return planPermissionAccount(db, input.nodeId, input.seat, input.runtime, env, { launchPosture });
} catch (error) {
return unknown((error as Error).message);
}
}
@@ -2,7 +2,8 @@ import { captureClaudeContinuation, type ClaudeContinuationLaunch } from "./clau
import { observeSolePane } from "./pane-binding-observation.js";
import { automaticRestoreOverrides, type AutomaticSeatReplacement } from "./existing-rig-restore.js";
import { restoreRuntimeCompatibilityError } from "./restore-runtime-compatibility.js";
import { leftAttachedReason } from "./restore-plan-preview.js";
import { leftAttachedReason, restorePermissionSeat } from "./restore-plan-preview.js";
import { planRestorePermission, type RestorePermissionPreview } from "./permission-account.js";
import { assessSnapshotCurrentOccupants, stoppedSnapshotReason } from "./rehydrate-eligibility.js";
import { nonInterruptiveNotice, nonInterruptiveSummary } from "../adapters/non-interruptive.js";
import { planModelAliasNotice } from "../adapters/yolo-mode.js";
@@ -215,7 +216,8 @@ export interface NarrowLaunchResult {
held?: Array<{ nodeId: string; logicalId: string; reason: string }>;
alreadyRunning?: Array<{ nodeId: string; logicalId: string }>;
failedTargets?: Array<{ nodeId: string; logicalId: string; reason: string }>;
targetNodes?: Array<{ nodeId: string; logicalId: string }>;
/** In a plan, each target's `permission`: what this launch will start it with (absent for a seat it leaves attached). */
targetNodes?: Array<{ nodeId: string; logicalId: string; permission?: RestorePermissionPreview }>;
unmatchedIds?: string[];
warnings?: string[];
snapshotSelection?: RestoreSnapshotSelection;
@@ -623,7 +625,17 @@ export class RestoreOrchestrator {
snapshotSelection: selected.selection,
targetNodes: intendedNodes
.filter((node) => targetIds.has(node.logicalId))
.map((node) => ({ nodeId: node.id, logicalId: node.logicalId })),
.map((node) => {
// The permission this launch will start the seat with: restore's own posture rule through the launch's resolver.
const live = rig.nodes.find((candidate) => candidate.logicalId === node.logicalId);
if (live && seatProcessOwner(resolveSeatKind(live.binding?.attachmentType)) === "external") {
return { nodeId: node.id, logicalId: node.logicalId };
}
const seat = restorePermissionSeat(rig.rig.name, live ?? { id: node.id, logicalId: node.logicalId, runtime: node.runtime ?? null },
selected.snapshot, node.id);
return { nodeId: node.id, logicalId: node.logicalId,
permission: planRestorePermission(this.db, { ...seat, restorePosture: () => this.resolveRestorePosture(seat.nodeId, rigId) }) };
}),
unmatchedIds: logicalIds.filter((id) => !targetIds.has(id) && !targetNodeIds.has(id)),
nonTargetEffects,
};
@@ -14,6 +14,7 @@ import { restoreRuntimeCompatibilityError } from "./restore-runtime-compatibilit
import type Database from "better-sqlite3";
import { resolveActiveSnapshotSession, deriveRehydrateOccupantsByNode, deriveRehydrateSessionIdByNode, activeOccupantAmbiguityError, absentSeatResumeHistory, absentSeatResumeHistoryError, type ActiveOccupantResolution } from "./active-occupant.js";
import type { RigWithRelations, Snapshot } from "./types.js";
import type { RestorePermissionPreview } from "./permission-account.js";
import { readFreshOccupantRelations } from "./fresh-occupant-relation.js";
import { parseSqliteUtcMs } from "./sqlite-time.js";
import { communityLaunchOnlyRestoreReason, effectiveRestorePolicy, isOrdinaryTerminalSeat, type RestoreSeatFacts } from "./restore-policy.js";
@@ -56,6 +57,9 @@ export interface RestorePlanPreviewNode {
/** A runtime prompt the operator should expect at restore (Claude session
* picker, Codex auth/update) — a forecast, not an incidental surprise. */
runtimePrompt?: string;
/** The permission restore will start this seat with, and where it comes from, when the caller can read it (planned,
* not observed). Absent for a seat restore leaves attached. */
permission?: RestorePermissionPreview;
}
export interface RestorePlanPreview {
@@ -283,6 +287,35 @@ export function capturedPreviewSessionRows(snapshot: Snapshot): PreviewSessionRo
* Forecast per-seat restore actions from current inventory and saved occupancy.
* Report token state and guidance without launching or changing any sessions.
*/
/** One seat's facts for a restore permission plan, from the snapshot restore reads: whether restore takes its pod-aware
* path (the saved node's pod) and finds saved startup context (keyed by the saved node, as restore keys it), and the
* runtime that context names. Undefined when there's no saved node to read them from. */
export interface RestorePermissionSeat {
nodeId: string;
logicalId: string;
seat: string;
runtime: string | null;
podAware?: boolean;
startupContext?: boolean;
/** Restore's own runtime check (restoreRuntimeCompatibilityError): set, restore stops for a decision before launch. */
runtimeError?: string;
}
export function restorePermissionSeat(rigName: string, node: { id: string; logicalId: string; runtime: string | null; binding?: { tmuxSession: string | null } | null },
snapshot: Snapshot | null, savedNodeId: string): RestorePermissionSeat {
const saved = snapshot?.data.nodes?.find((entry) => entry.logicalId === node.logicalId);
const startup = saved ? snapshot?.data.nodeStartupContext?.[savedNodeId] : undefined;
const runtimeError = restoreRuntimeCompatibilityError(node.runtime, startup?.runtime);
return {
nodeId: node.id,
logicalId: node.logicalId,
seat: node.binding?.tmuxSession ?? `${node.logicalId.replace(".", "-")}@${rigName}`,
runtime: startup?.runtime ?? node.runtime,
...(saved ? { podAware: !!saved.podId, startupContext: !!startup } : {}),
...(runtimeError ? { runtimeError } : {}),
};
}
export function buildRestorePlanPreview(
rig: RigWithRelations,
snapshot: Snapshot | null,
@@ -291,6 +324,8 @@ export function buildRestorePlanPreview(
nowMs: number = Date.now(),
recorded: Record<string, string | null> = {},
selectedHistory?: { nodeId: string; historySessionId: string },
/** Plans a seat's restore permission (planRestorePermission); omitted, the preview carries none. */
permissionOf?: (seat: RestorePermissionSeat) => RestorePermissionPreview,
): RestorePlanPreview {
// OPR.0.5.7.1 — the relation the resolution consumes: the snapshot's own
// when previewing a snapshot; for the live no-snapshot case, the SAME
@@ -350,6 +385,7 @@ export function buildRestorePlanPreview(
// A terminal has no token to find and relaunches fresh on its own.
freshRequired: attached || stoppedCurrent || runtimeError || resolution.kind === "ambiguous" ? false : tokenState === "missing" && !isOrdinaryTerminalSeat(seat),
...(runtimePrompt ? { runtimePrompt } : {}),
...(permissionOf && !attached ? { permission: permissionOf(restorePermissionSeat(rig.rig.name, node, snapshot, savedNodeId)) } : {}),
};
});
return {
+7 -2
View File
@@ -11,7 +11,8 @@ import type { SnapshotRepository } from "../domain/snapshot-repository.js";
import type { SnapshotCapture } from "../domain/snapshot-capture.js";
import type { RestoreOrchestrator } from "../domain/restore-orchestrator.js";
import { chooseRestoreSnapshot, joinAutomaticRestore, JOINED_AUTOMATIC_RESTORE_WARNING, runExistingRigRestore, type ExistingRigRestoreOutcome } from "../domain/existing-rig-restore.js";
import { buildRestorePlanPreview, collectPreviewSessionRows } from "../domain/restore-plan-preview.js";
import { buildRestorePlanPreview, collectPreviewSessionRows, type RestorePermissionSeat } from "../domain/restore-plan-preview.js";
import { planRestorePermission } from "../domain/permission-account.js";
import { readFreshOccupantRelations } from "../domain/fresh-occupant-relation.js";
import { loadTopologyManifest } from "../domain/topology/topology-manifest.js";
import { MultiRigLauncher } from "../domain/topology/multi-rig-launcher.js";
@@ -120,7 +121,11 @@ async function restoreByRigId(rigId: string, rigName: string | null, deps: Retur
// snapshot capture (itself a mutation) is reported as would-happen, never
// performed.
if (plan) {
return c.json(buildRestorePlanPreview(rig, choice.snapshot ?? null, collectPreviewSessionRows(snapshotRepo.db, rig, choice.snapshot ?? null, true), freshLogicalIds, Date.now(), readFreshOccupantRelations(snapshotRepo.db, rig.rig.id)), 200);
// Each seat's permission as this restore will start it: restore's own posture rule through the launch's resolver.
const restorer = deps.restoreOrchestrator;
const permissionOf = restorer ? (seat: RestorePermissionSeat) =>
planRestorePermission(snapshotRepo.db, { ...seat, restorePosture: () => restorer.resolveRestorePosture(seat.nodeId, rig.rig.id) }) : undefined;
return c.json(buildRestorePlanPreview(rig, choice.snapshot ?? null, collectPreviewSessionRows(snapshotRepo.db, rig, choice.snapshot ?? null, true), freshLogicalIds, Date.now(), readFreshOccupantRelations(snapshotRepo.db, rig.rig.id), undefined, permissionOf), 200);
}
const fs = await import("node:fs");
@@ -145,6 +145,20 @@ describe("RestoreOrchestrator.launchNodeSubset", () => {
expect(db.prepare("SELECT * FROM sessions ORDER BY id").all()).toEqual(before);
});
it.each([
["codex", "claude-code"],
["claude-code", "codex"],
])("plans saved startup %s on live %s as unknown with restore's reason, never a definite mode", (saved, live) => {
const { rigId, nodeIds } = seedPodAwareRig();
const snapshotId = seedSnapshot(rigId, nodeIds, { [nodeIds[0]!]: emptyStartupContext(saved) });
db.prepare("UPDATE nodes SET runtime = ? WHERE id = ?").run(live, nodeIds[0]);
const result = orchestrator.planNodeSubset(rigId, ["dev.driver"], { snapshotId });
const permission = result.targetNodes?.[0]?.permission;
expect(permission).toMatchObject({ basis: "unknown" });
expect(permission?.summary).toContain("restore stops for a runtime decision before it starts this seat. No session was started for this seat.");
expect(permission?.summary).not.toContain("On restore");
});
it("returns rig_not_found for unknown rig", async () => {
const result = await orchestrator.launchNodeSubset("nonexistent", ["dev.driver"]);
expect(result.ok).toBe(false);
@@ -309,6 +323,8 @@ describe("RestoreOrchestrator.launchNodeSubset", () => {
});
expect(db.prepare("SELECT COUNT(*) AS n FROM sessions").get()).toEqual(before.sessions);
expect(db.prepare("SELECT COUNT(*) AS n FROM events").get()).toEqual(before.events);
// Each target carries the permission this launch will start it with, from restore's own posture rule.
expect(result.targetNodes?.[0]?.permission?.summary).toEqual(expect.any(String));
});
it("plans single-node subset by node id with nonTargetMode unchanged (#887)", () => {
@@ -2,7 +2,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { createFullTestDb } from "./helpers/test-app.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { NativePermissionStore } from "../src/domain/native-permission-store.js";
import { planPermissionAccount } from "../src/domain/permission-account.js";
import { planPermissionAccount, planRestorePermission } from "../src/domain/permission-account.js";
import { claudePostureFlag } from "../src/adapters/yolo-mode.js";
import { SeatStatusService } from "../src/domain/seat-status-service.js";
import { KERNEL_CLAUDE_ALLOW, TEAM_CLAUDE_ALLOW, TEAM_CLAUDE_ASK } from "../src/adapters/kernel-authority.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
@@ -149,3 +150,76 @@ describe("the planned permission account", () => {
expect(result.status.permissions).toMatchObject({ selectionState: "inherit", nativeEffect: "unverified", account: { basis: "planned", runtime: "claude-code" } });
});
});
// After a reboot a seat came back in a mode the person didn't expect. The plan previews now show, per seat, the permission
// restore will start it with: restore's own posture through the launch's resolver, with no precedence change.
describe("a seat's planned permission on restore", () => {
const inputs = (nodeId: string, restorePosture: () => "floor" | "full_bypass" | "auto") =>
({ nodeId, seat: "dev-builder@workshop", runtime: "claude-code", restorePosture, podAware: true, startupContext: true });
it("starts from the posture restore re-reads, not the cached one a fresh launch uses", () => {
const { db, rigRepo, node } = seat("claude-code");
attach(rigRepo, db, node.id, "builtin:yolo", "full_bypass");
const restored = planRestorePermission(db, inputs(node.id, () => "floor"), env);
const fresh = planPermissionAccount(db, node.id, "dev-builder@workshop", "claude-code", env);
expect(fresh.nativeArgument).toBe(claudePostureFlag(env, "full_bypass", undefined));
expect(restored).toMatchObject({ basis: "planned", source: "member_policy", nativeArgument: claudePostureFlag(env, "floor", undefined) });
expect(restored.summary.startsWith("On restore: ")).toBe(true);
expect(fresh.summary.startsWith("Next launch: ")).toBe(true);
});
it("keeps the seat's own choice ahead of restore's posture, as the launch does", () => {
const { db, rigRepo, node } = seat("claude-code");
attach(rigRepo, db, node.id, "builtin:yolo", "full_bypass");
new NativePermissionStore(db).write(node.id, { runtime: "claude-code", mode: "auto" }, "person@host", "after the reboot");
const restored = planRestorePermission(db, inputs(node.id, () => "full_bypass"), env);
expect(restored).toMatchObject({ basis: "planned", source: "explicit", nativeArgument: "--permission-mode auto",
explicit: { mode: "auto", actor: "person@host", reason: "after the reboot" }, overrides: ["member policy builtin:yolo"] });
});
it("reads unknown, with restore's own reason, when restore would refuse the seat's permission facts", () => {
const { db, node } = seat("claude-code");
new NativePermissionStore(db).write(node.id, { runtime: "claude-code", mode: "plan" }, "person@host", "review");
const restored = planRestorePermission(db, { ...inputs(node.id, () => "floor"), runtime: "codex" }, env);
expect(restored.basis).toBe("unknown");
expect(restored.summary).toContain("Seat runtime changed since permission selection");
});
it.each([
[{ podAware: false }, "runtime from its resume token"],
[{ podAware: true, startupContext: false }, "no saved startup context"],
[{ podAware: undefined, startupContext: undefined }, "no saved snapshot names this seat"],
[{ podAware: true, startupContext: true, runtimeError: "No session was started for this seat." }, "restore stops for a runtime decision"],
])("reads a restore path it can't settle as unknown with the reason, never a definite mode (%o)", (path, words) => {
const { db, rigRepo, node } = seat("claude-code");
attach(rigRepo, db, node.id, "builtin:yolo", "full_bypass");
const restored = planRestorePermission(db, { ...inputs(node.id, () => "floor"), ...path }, env);
expect(restored).toEqual({ basis: "unknown", runtime: "claude-code", summary: expect.stringContaining(words) });
expect(restored.summary).not.toContain("On restore");
});
it("plans only the path restore's posture is exact for: a pod seat with saved startup context", () => {
const { db, node } = seat("claude-code");
const restored = planRestorePermission(db, inputs(node.id, () => "floor"), env);
expect(restored.basis).toBe("planned");
expect(restored.summary.startsWith("On restore: ")).toBe(true);
expect(restored.basis === "planned" ? restored.unknown.join(" ") : "").toContain("This is the plan for restoring this seat");
});
// Startup makes the permission decision for every runtime, Pi included, and fails the seat on its error.
it("reads unknown when startup would refuse a Pi seat's choice, made for another runtime or unreadable", () => {
const { db, node } = seat("pi");
expect(planRestorePermission(db, { ...inputs(node.id, () => "floor"), runtime: "pi" }, env))
.toMatchObject({ basis: "planned", summary: "Pi has no native permission policy; its resource trust is set at launch." });
new NativePermissionStore(db).write(node.id, { runtime: "claude-code", mode: "plan" }, "person@host", "before the switch");
const changed = planRestorePermission(db, { ...inputs(node.id, () => "floor"), runtime: "pi" }, env);
expect(changed).toMatchObject({ basis: "unknown", runtime: "pi" });
expect(changed.summary).toContain("Seat runtime changed since permission selection");
// A stored choice is only ever Claude's or Codex's (the table checks it); an unreadable mode fails startup's read first.
db.prepare("UPDATE node_permission_selections SET mode = 'not-a-mode' WHERE node_id = ?").run(node.id);
const unreadable = planRestorePermission(db, { ...inputs(node.id, () => "floor"), runtime: "pi" }, env);
expect(unreadable).toMatchObject({ basis: "unknown", runtime: "pi" });
expect(unreadable.summary).toContain("Invalid persisted native permission selection");
expect(unreadable.summary).not.toContain("Pi has no native permission policy");
});
});
@@ -1,8 +1,9 @@
import { describe, it, expect } from "vitest";
import { buildRestorePlanPreview, collectPreviewSessionRows, type PreviewSessionRow } from "../src/domain/restore-plan-preview.js";
import { buildRestorePlanPreview, collectPreviewSessionRows, type PreviewSessionRow, type RestorePermissionSeat } from "../src/domain/restore-plan-preview.js";
import type { RigWithRelations, Snapshot } from "../src/domain/types.js";
import { createFullTestDb } from "./helpers/test-app.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { planRestorePermission } from "../src/domain/permission-account.js";
// OPR.0.4.3.20 FR-6 — restore-plan per-seat token state (read-only forecast).
@@ -254,3 +255,60 @@ describe("FR-6 live path — DB → collectPreviewSessionRows → plan state", (
db.close();
});
});
describe("restore plan permission per seat", () => {
const snapshotOf = (nodes: Array<{ id: string; logicalId: string; runtime: string; podId: string | null }>, startup: Record<string, { runtime: string }>) =>
({ id: "snap", kind: "manual", createdAt: "now", data: {
nodes, sessions: [row("n1")], activeSessionIdByNode: { n1: "s-n1" }, nodeStartupContext: startup,
} } as unknown as Snapshot);
it("asks for each seat restore launches, with the facts restore reads from the snapshot, and none for an attached seat", () => {
const rig = rigWith([{ id: "n1", logicalId: "dev.impl", runtime: "claude-code" }, { id: "n2", logicalId: "dev.qa", runtime: "codex" },
{ id: "n3", logicalId: "dev.old", runtime: "claude-code" }]);
(rig.nodes[1] as unknown as { binding: unknown }).binding = { attachmentType: "external_cli", externalSessionName: "qa-cli", tmuxSession: null };
const snapshot = snapshotOf([{ id: "n1", logicalId: "dev.impl", runtime: "claude-code", podId: "pod-1" },
{ id: "n2", logicalId: "dev.qa", runtime: "codex", podId: "pod-1" }, { id: "n3", logicalId: "dev.old", runtime: "claude-code", podId: null }],
{ n1: { runtime: "codex" } });
const seen: RestorePermissionSeat[] = [];
const preview = buildRestorePlanPreview(rig, snapshot, [row("n1")], undefined, NOW, {}, undefined, (seat) => {
seen.push(seat);
return { basis: "unknown", runtime: seat.runtime, summary: `planned ${seat.logicalId}` };
});
// n1's saved startup context names Codex while it runs Claude now: restore stops for that decision before launch
// (restoreRuntimeCompatibilityError), so its seat carries restore's own reason, and the plan awaits the decision.
expect(preview.nodes[0]).toMatchObject({ intendedAction: "awaiting-decision" });
expect(seen).toEqual([
{ nodeId: "n1", logicalId: "dev.impl", seat: "dev-impl@test-rig", runtime: "codex", podAware: true, startupContext: true,
runtimeError: "No session was started for this seat. Restore a snapshot whose saved startup runtime matches the seat's current runtime." },
{ nodeId: "n3", logicalId: "dev.old", seat: "dev-old@test-rig", runtime: "claude-code", podAware: false, startupContext: false },
]);
expect(preview.nodes.map((n) => n.permission?.summary)).toEqual(["planned dev.impl", undefined, "planned dev.old"]);
});
it("plans a live Claude seat whose saved startup runtime is Codex as unknown with restore's reason, beside its awaiting decision", () => {
const rig = rigWith([{ id: "n1", logicalId: "dev.impl", runtime: "claude-code" }]);
const snapshot = snapshotOf([{ id: "n1", logicalId: "dev.impl", runtime: "codex", podId: "pod-1" }], { n1: { runtime: "codex" } });
const db = createFullTestDb();
try {
// The same planner `rig up --existing --plan` passes; no explicit choice is stored for the seat.
const preview = buildRestorePlanPreview(rig, snapshot, [row("n1")], undefined, NOW, {}, undefined,
(seat) => planRestorePermission(db, { ...seat, restorePosture: () => "floor" }));
expect(preview.nodes[0]).toMatchObject({ intendedAction: "awaiting-decision", permission: { basis: "unknown" } });
expect(preview.nodes[0]!.permission!.summary).toContain("restore stops for a runtime decision before it starts this seat.");
expect(preview.nodes[0]!.permission!.summary).not.toContain("On restore");
} finally { db.close(); }
});
it("carries no permission when the caller doesn't plan one, so the other plan callers are unchanged", () => {
const rig = rigWith([{ id: "n1", logicalId: "dev.impl", runtime: "claude-code" }]);
const preview = buildRestorePlanPreview(rig, snapshotOf([{ id: "n1", logicalId: "dev.impl", runtime: "claude-code", podId: "pod-1" }], {}), [row("n1")], undefined, NOW);
expect(preview.nodes[0]).not.toHaveProperty("permission");
});
it("leaves the path facts unset for a seat no saved snapshot names", () => {
const rig = rigWith([{ id: "n1", logicalId: "dev.impl", runtime: "claude-code" }]);
const seen: RestorePermissionSeat[] = [];
buildRestorePlanPreview(rig, null, [row("n1")], undefined, NOW, {}, undefined, (seat) => { seen.push(seat); return { basis: "unknown", runtime: null, summary: "x" }; });
expect(seen).toEqual([{ nodeId: "n1", logicalId: "dev.impl", seat: "dev-impl@test-rig", runtime: "claude-code" }]);
});
});
@@ -245,6 +245,8 @@ describe("up --existing keeps proved current stops", () => {
expect(preview.status).toBe(200); expect(applied.status).toBe(200);
expect(plan.nodes[0].intendedAction).toBe(fresh ? "fresh-primed" : "awaiting-decision");
expect(result.nodes[0].status).toBe(plan.nodes[0].intendedAction);
// The plan shows the permission restore will start the seat with, and stays read-only (total_changes above).
expect(plan.nodes[0].permission?.summary).toEqual(expect.any(String));
if (!fresh) {
const command = "rig seat launch 'dev.worker@stopped-review' --fresh --reason";
expect(plan.nodes[0].reason).toContain(command);