Compare commits

...
Author SHA1 Message Date
dev-driver 384413e830 fix(daemon): the Codex resume command a person is shown carries the seat's permission choice
The resume command in rig ps and the node's recovery guidance printed
the default posture for a Codex seat with its own saved permission
choice, so resuming by hand from it started the seat without that
choice. It now carries the choice through the same arguments the
seat's managed resume emits. A choice that can't be read still shows
the command, with a note that it doesn't include it.
2026-11-13 00:28:12 +00:00
5 changed files with 194 additions and 11 deletions

No files matched your search

+3
View File
@@ -72,6 +72,8 @@ export interface NodeEntry {
lifecycleState?: "running" | "detached" | "recoverable" | "attention_required";
tmuxAttachCommand: string | null;
resumeCommand: string | null;
/** What the resume command couldn't include, when something the seat chose couldn't be read. */
resumeCommandNote?: string;
latestError: string | null;
/** Slice 15 — `terminal-active` primitive. true=producing output,
* false=silent past threshold, null=no signal. NEVER derived from
@@ -231,6 +233,7 @@ const ALLOWED_NODE_FIELDS = new Set([
"lifecycleState",
"tmuxAttachCommand",
"resumeCommand",
"resumeCommandNote",
"latestError",
"terminalActive",
"hasAssignedWork",
@@ -2,6 +2,8 @@ import { currentClaudeMcpMenu } from "./claude-mcp-menu.js";
import { shellQuote } from "../adapters/shell-quote.js";
import { claudePostureFlag, codexPostureArg } from "../adapters/yolo-mode.js";
import { claudeMcpArgs, type ClaudeMcp } from "./claude-mcp.js";
import { codexMemberEnvelope, type CodexMemberPermissions } from "./codex-member-permissions.js";
import { codexEnvelopeArgs } from "./codex-rebuild-permissions.js";
// L3 adds `attention_required` for the Claude resume-selection prompt proxy.
// Distinct from `inconclusive` (we don't know yet) and `failed` (terminal
@@ -52,6 +54,8 @@ export function buildNativeResumeCommand(
/** Codex: the seat's recorded daemon choice; only a positive true adds --no-daemon (absent and false print as before). */
codexDaemonOptOut?: boolean,
claudeMcp?: ClaudeMcp | null,
/** Codex: the seat's own saved permission choice; it replaces the posture decision, as on the seat's managed resume. */
codexPermissions?: CodexMemberPermissions,
): string | null {
if (!resumeToken) return null;
if (runtime === "claude-code") {
@@ -60,11 +64,19 @@ export function buildNativeResumeCommand(
return `claude ${claudePostureFlag(process.env)} --resume ${shellQuote(resumeToken)}${nameSuffix}${mcpSuffix}`;
}
if (runtime === "codex") {
return buildCodexResumeCore(resumeToken, codexConfigProfile, undefined, undefined, undefined, undefined, undefined, codexDaemonOptOut === true);
return buildCodexResumeCore(resumeToken, codexConfigProfile, undefined, undefined, undefined, undefined,
codexSeatPostureArg(codexConfigProfile, codexPermissions), codexDaemonOptOut === true);
}
return null;
}
/** A Codex seat's own permission choice as the managed resume emits it: the profile, then the choice's flags, in place
* of the posture decision. Undefined without a choice, so the posture decision stands. */
export function codexSeatPostureArg(codexConfigProfile: string | null | undefined, permissions: CodexMemberPermissions | undefined): string | undefined {
if (!permissions) return undefined;
return `${codexConfigProfile ? ` -p ${shellQuote(codexConfigProfile)}` : ""}${codexEnvelopeArgs(codexMemberEnvelope(permissions))}`;
}
/** Managed Codex seats run tool commands in a non-login shell, so they keep the seat's own environment, including the
* OpenRig command folder at the front of PATH. Codex otherwise gives each command the user's login-shell environment
* and puts back only its own directories, so a login file that sets PATH outright drops that folder. */
+55 -10
View File
@@ -24,7 +24,8 @@ import { RigRepository, findLatestUsableSnapshot, findLatestUsableSnapshotsForAl
import { hasCompleteGuidanceBlock, seatGuidanceFile } from "./restore-guidance.js";
import { resolveNodeWorkspace } from "./workspace/workspace-resolver.js";
import { deriveCanonicalSessionName } from "./session-name.js";
import { buildNativeResumeCommand, buildCodexResumeCore } from "./native-resume-probe.js";
import { buildNativeResumeCommand, buildCodexResumeCore, codexSeatPostureArg } from "./native-resume-probe.js";
import { validateCodexMemberPermissions, type CodexMemberPermissions } from "./codex-member-permissions.js";
import { AppliedLaunchObservationStore } from "./applied-launch-observation-store.js";
import { readReadyFreshLaunchFences } from "./fresh-occupant-relation.js";
import { daemonTmuxServerArgs, daemonTmuxServerInfo, tmuxAttachCommand } from "../adapters/tmux-server.js";
@@ -114,13 +115,38 @@ interface BindingRow {
// -- Helpers --
function computeResumeCommand(runtime: string | null, resumeToken: string | null, codexConfigProfile?: string | null, codexDaemonOptOut?: boolean, claudeMcp?: ClaudeMcp | null): string | null {
return buildNativeResumeCommand(runtime, resumeToken, null, codexConfigProfile, codexDaemonOptOut, claudeMcp);
function computeResumeCommand(runtime: string | null, resumeToken: string | null, codexConfigProfile?: string | null, codexDaemonOptOut?: boolean, claudeMcp?: ClaudeMcp | null, codexChoice?: CodexSeatChoice): string | null {
return buildNativeResumeCommand(runtime, resumeToken, null, codexConfigProfile, codexDaemonOptOut, claudeMcp,
codexChoice && "permissions" in codexChoice ? codexChoice.permissions : undefined);
}
const CODEX_DAEMON_CHOICE_NOT_RECORDED = "Not recorded whether this seat's last managed launch used --no-daemon. If `codex --help` lists --no-daemon, "
+ "add it right after `codex`, as the managed launch does.";
/** Shown beside a Codex resume command when the seat's saved permission choice couldn't be read: the command still shows. */
const CODEX_PERMISSIONS_NOT_INCLUDED = "This seat's saved Codex permission choice couldn't be read, so the resume command doesn't include it and "
+ "carries the default posture instead. Repair it with rig seat set-permissions <seat> --codex-permissions <file> --reason <reason>.";
/** A Codex seat's saved permission choice: the choice, or unreadable. A seat with none has no entry. */
type CodexSeatChoice = { permissions: CodexMemberPermissions } | { unreadable: true };
/** Every seat's saved Codex permission choice, in one read. A schema without the column (a minimal fixture) has none. */
function readCodexSeatChoices(db: Database.Database): Map<string, CodexSeatChoice> {
const choices = new Map<string, CodexSeatChoice>();
let rows: Array<{ id: string; codex_permissions_json: string }>;
try {
rows = db.prepare("SELECT id, codex_permissions_json FROM nodes WHERE codex_permissions_json IS NOT NULL").all() as typeof rows;
} catch (error) {
if ((error as { code?: string }).code === "SQLITE_ERROR" && /no such column: codex_permissions_json\b/.test((error as Error).message)) return choices;
throw error;
}
for (const row of rows) {
try { choices.set(row.id, { permissions: validateCodexMemberPermissions(JSON.parse(row.codex_permissions_json)) }); }
catch { choices.set(row.id, { unreadable: true }); }
}
return choices;
}
function computeRecoveryGuidance(input: {
runtime: string | null;
resumeToken: string | null;
@@ -130,8 +156,10 @@ function computeRecoveryGuidance(input: {
/** Codex: the current generation's recorded daemon choice; undefined when none was recorded. */
codexDaemonOptOut?: boolean;
claudeMcp?: ClaudeMcp;
/** Codex: the seat's saved permission choice; undefined when it has none. */
codexChoice?: CodexSeatChoice;
}): NodeRecoveryGuidance | null {
const { runtime, resumeToken, cwd, sessionName, codexConfigProfile, codexDaemonOptOut, claudeMcp } = input;
const { runtime, resumeToken, cwd, sessionName, codexConfigProfile, codexDaemonOptOut, claudeMcp, codexChoice } = input;
if (runtime === "claude-code") {
const commands: string[] = [];
@@ -165,14 +193,20 @@ function computeRecoveryGuidance(input: {
const notes: string[] = [];
const daemonOptOut = codexDaemonOptOut === true;
// The seat's own permission choice replaces the posture decision, as on its managed resume.
const seatPosture = codexSeatPostureArg(codexConfigProfile, codexChoice && "permissions" in codexChoice ? codexChoice.permissions : undefined);
const lastCommand = buildCodexResumeCore("", codexConfigProfile, true, undefined, undefined, undefined, seatPosture, daemonOptOut);
if (resumeToken) {
commands.push(buildCodexResumeCore(resumeToken, codexConfigProfile, undefined, undefined, undefined, undefined, undefined, daemonOptOut));
commands.push(buildCodexResumeCore(resumeToken, codexConfigProfile, undefined, undefined, undefined, undefined, seatPosture, daemonOptOut));
}
if (cwd) {
commands.push(`cd ${cwd}`);
}
if (!resumeToken) {
commands.push(buildCodexResumeCore("", codexConfigProfile, true, undefined, undefined, undefined, undefined, daemonOptOut));
commands.push(lastCommand);
}
if (codexChoice && "unreadable" in codexChoice) {
notes.push(CODEX_PERMISSIONS_NOT_INCLUDED);
}
notes.push("Use workspace and recent prompt text to identify the right conversation.");
@@ -186,8 +220,11 @@ function computeRecoveryGuidance(input: {
notes.push(CODEX_DAEMON_CHOICE_NOT_RECORDED);
}
const ownChoice = "it carries this seat's own permission choice, as its managed launch does.";
return {
summary: resumeToken
summary: seatPosture
? (resumeToken ? `Try native Codex resume first; ${ownChoice}` : `No stored Codex resume token. Try ${lastCommand}; ${ownChoice}`)
: resumeToken
? "Try native Codex resume first; the managed launch sets the explicit -s workspace-write floor flag unless a named config profile is present."
: `No stored Codex resume token. Try codex${daemonOptOut ? " --no-daemon" : ""} -s workspace-write resume --last; the managed launch sets the explicit -s workspace-write floor flag unless a named config profile is present.`,
commands,
@@ -612,11 +649,12 @@ export function getNodeInventory(db: Database.Database, rigId: string): NodeInve
const restoreOutcomes = buildRestoreOutcomeMap(db, rigId);
const orienteds = buildOrientedMap(db, rigId);
const codexDaemonOptOuts = new AppliedLaunchObservationStore(db).readCurrentDaemonOptOuts();
const codexSeatChoices = readCodexSeatChoices(db);
const rows = queryInventoryRows(db, rigId);
return rows.map((row) =>
buildInventoryEntry(db, row, { usableSnapshot, workspaceSpec, identityVerdicts, restoreOutcomes, orienteds, codexDaemonOptOuts }),
buildInventoryEntry(db, row, { usableSnapshot, workspaceSpec, identityVerdicts, restoreOutcomes, orienteds, codexDaemonOptOuts, codexSeatChoices }),
);
}
@@ -731,6 +769,8 @@ interface InventoryBuildContext {
orienteds: Map<string, NodeOriented>;
/** Each node's current recorded Codex daemon choice (one read per projection); absent = not recorded. */
codexDaemonOptOuts: Map<string, boolean>;
/** Each seat's saved Codex permission choice (one read per projection); absent = none. */
codexSeatChoices: Map<string, CodexSeatChoice>;
}
/**
@@ -743,7 +783,8 @@ function buildInventoryEntry(
row: InventoryRow,
ctx: InventoryBuildContext,
): NodeInventoryEntry {
const { usableSnapshot, workspaceSpec, identityVerdicts, restoreOutcomes, orienteds, codexDaemonOptOuts } = ctx;
const { usableSnapshot, workspaceSpec, identityVerdicts, restoreOutcomes, orienteds, codexDaemonOptOuts, codexSeatChoices } = ctx;
const codexChoice = row.runtime === "codex" ? codexSeatChoices.get(row.node_id) : undefined;
const launchWrapper = parseStoredLaunchWrapper(row.launch_wrapper, `Seat ${row.node_id} launch_wrapper`);
if (launchWrapper && !isLaunchWrapperRuntime(row.runtime)) {
throw new Error(`Seat ${row.node_id} launch_wrapper: only valid when runtime is "pi", "codex", "claude-code" or a supported community harness`);
@@ -823,7 +864,8 @@ function buildInventoryEntry(
? { tmuxServerArgs: [...daemonTmuxServerArgs()] } : {}),
...(row.binding_attachment_type === "tmux" && row.session_name && daemonTmuxServerInfo()
? { tmuxServerMode: daemonTmuxServerInfo()!.mode } : {}),
resumeCommand: computeResumeCommand(row.runtime, row.resume_token, row.codex_config_profile, codexDaemonOptOuts.get(row.node_id), row.claude_mcp),
resumeCommand: computeResumeCommand(row.runtime, row.resume_token, row.codex_config_profile, codexDaemonOptOuts.get(row.node_id), row.claude_mcp, codexChoice),
...(row.resume_token && codexChoice && "unreadable" in codexChoice ? { resumeCommandNote: CODEX_PERMISSIONS_NOT_INCLUDED } : {}),
// OPR.0.4.0.26: recoveryGuidance is NOT inlined per node in the LIST
// payload. It duplicated ~47KB of templated prose across all nodes and
// no node-list consumer reads it. The full guidance is recomputed on
@@ -921,6 +963,7 @@ function buildInventoryMapFromRows(db: Database.Database, rows: InventoryRow[]):
const restoreOutcomes = buildRestoreOutcomeMap(db); // one fleet restore-outcome scan
const orienteds = buildOrientedMap(db); // one fleet startup-orientation scan
const codexDaemonOptOuts = new AppliedLaunchObservationStore(db).readCurrentDaemonOptOuts(); // one launch-fact read
const codexSeatChoices = readCodexSeatChoices(db); // one permission-choice read
const out = new Map<string, NodeInventoryEntry[]>();
for (const row of rows) {
const entry = buildInventoryEntry(db, row, {
@@ -930,6 +973,7 @@ function buildInventoryMapFromRows(db: Database.Database, rows: InventoryRow[]):
restoreOutcomes,
orienteds,
codexDaemonOptOuts,
codexSeatChoices,
});
let list = out.get(row.rig_id);
if (!list) { list = []; out.set(row.rig_id, list); }
@@ -1137,6 +1181,7 @@ export function getNodeDetail(
codexConfigProfile: entry.codexConfigProfile,
codexDaemonOptOut: entry.runtime === "codex" ? new AppliedLaunchObservationStore(db).readCurrent(nodeId)?.daemonOptOut : undefined,
claudeMcp: entry.runtime === "claude-code" ? readSeatClaudeMcp(db, nodeId) : undefined,
codexChoice: entry.runtime === "codex" ? readCodexSeatChoices(db).get(nodeId) : undefined,
}),
binding,
startupFiles,
+3
View File
@@ -814,6 +814,9 @@ export interface NodeInventoryEntry {
/** Effective mode; inherited non-default sockets also have server arguments. */
tmuxServerMode?: "inherit" | "isolated";
resumeCommand: string | null;
/** Present when the resume command couldn't include something the seat chose (its saved Codex permission choice
* couldn't be read): the command still shows, and this says what it lacks. */
resumeCommandNote?: string;
recoveryGuidance: NodeRecoveryGuidance | null;
latestError: string | null;
// Extended fields
@@ -0,0 +1,120 @@
// The Codex resume command OpenRig shows a person for a seat (`rig ps --nodes` resumeCommand and the node detail's
// recovery guidance) carries the seat's own saved permission choice, as its managed resume does. A choice that can't be
// read still shows the command, with a note that it doesn't include it.
import { mockShellCommand } from "./helpers/shell-command-mock.js";
import type Database from "better-sqlite3";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createFullTestDb } from "./helpers/test-app.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { AppliedLaunchObservationStore } from "../src/domain/applied-launch-observation-store.js";
import { observeCodexSandbox, type AppliedLaunchObservation } from "../src/domain/permission-drift.js";
import { getNodeDetail, getNodeInventory } from "../src/domain/node-inventory.js";
import { CodexResumeAdapter } from "../src/adapters/codex-resume.js";
import type { CodexMemberPermissions } from "../src/domain/codex-member-permissions.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
// No real Codex, shell or tmux runs here.
const processMocks = vi.hoisted(() => ({
execFile: vi.fn(),
execSync: vi.fn<() => string>(() => { throw new Error("Unexpected synchronous process execution"); }),
}));
vi.mock("node:child_process", async (importOriginal) => ({
...(await importOriginal<typeof import("node:child_process")>()), execFile: processMocks.execFile, execSync: processMocks.execSync,
}));
const workspace: CodexMemberPermissions = { sandbox: "workspace-write", approval: "on-request", network_access: true, writable_roots: ["/data"] };
const WORKSPACE_ARGS = " -s workspace-write -a on-request -c 'sandbox_workspace_write.writable_roots=[\"/data\"]'"
+ " -c 'sandbox_workspace_write.network_access=true' -c 'sandbox_workspace_write.exclude_tmpdir_env_var=false'"
+ " -c 'sandbox_workspace_write.exclude_slash_tmp=false'";
const NOT_INCLUDED = "This seat's saved Codex permission choice couldn't be read, so the resume command doesn't include it and "
+ "carries the default posture instead. Repair it with rig seat set-permissions <seat> --codex-permissions <file> --reason <reason>.";
describe("the Codex resume command a person is shown carries the seat's own permission choice", () => {
let db: Database.Database;
let registry: SessionRegistry;
let sessionId: string;
beforeEach(() => {
db = createFullTestDb();
db.prepare("INSERT INTO rigs (id, name) VALUES (?, ?)").run("rig-2", "test-rig");
db.prepare("INSERT INTO pods (id, rig_id, namespace, label) VALUES (?, ?, ?, ?)").run("pod-2", "rig-2", "dev", "Dev");
db.prepare(
"INSERT INTO nodes (id, rig_id, logical_id, runtime, cwd, pod_id, agent_ref, profile, resolved_spec_name, resolved_spec_version, resolved_spec_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
).run("node-codex", "rig-2", "dev.qa", "codex", "/workspace/app", "pod-2", "local:agents/qa", "default", "qa", "1.0.0", "hash");
registry = new SessionRegistry(db);
sessionId = registry.registerSession("node-codex", "dev-qa@test-rig").id;
});
afterEach(() => db.close());
const choose = (stored: string) => db.prepare("UPDATE nodes SET codex_permissions_json = ? WHERE id = ?").run(stored, "node-codex");
const shown = () => {
const entry = getNodeInventory(db, "rig-2").find((e) => e.logicalId === "dev.qa")!;
const detail = getNodeDetail(db, "rig-2", "dev.qa")!;
return { entry, guidance: detail.recoveryGuidance! };
};
describe("with a stored conversation", () => {
beforeEach(() => registry.updateResumeToken(sessionId, "codex_id", "sess-456", "hook"));
it("a workspace-write choice replaces the default posture, in the command and the guidance", () => {
choose(JSON.stringify(workspace));
const { entry, guidance } = shown();
expect(entry.resumeCommand).toBe(`codex${WORKSPACE_ARGS} resume 'sess-456'`);
expect(entry).not.toHaveProperty("resumeCommandNote");
expect(guidance.commands).toEqual([`codex${WORKSPACE_ARGS} resume 'sess-456'`, "cd /workspace/app"]);
expect(guidance.summary).toBe("Try native Codex resume first; it carries this seat's own permission choice, as its managed launch does.");
expect(guidance.notes).not.toContain(NOT_INCLUDED);
});
it("a read-only choice keeps the seat's profile and a recorded --no-daemon", () => {
choose(JSON.stringify({ sandbox: "read-only", approval: "never" }));
db.prepare("UPDATE nodes SET codex_config_profile = ? WHERE id = ?").run("team", "node-codex");
const generation = registry.currentOccupantTenure("node-codex")!.generationUuid;
new AppliedLaunchObservationStore(db).recordGeneration(generation, { ...observeCodexSandbox("-s workspace-write"), daemonOptOut: true } as AppliedLaunchObservation);
expect(shown().entry.resumeCommand).toBe("codex --no-daemon -p 'team' -s read-only -a never resume 'sess-456'");
});
it("prints the same permission arguments the seat's managed resume sends", async () => {
choose(JSON.stringify(workspace));
const sendText = vi.fn(async () => ({ ok: true as const }));
const tmux = mockShellCommand({
sendText, hasSession: vi.fn(async () => true), getPaneCommand: vi.fn(async () => "codex"),
capturePaneContent: vi.fn(async () => "OpenAI Codex (v0.0.0)\n› Ask Codex to do anything"),
sendKeys: vi.fn(async () => ({ ok: true as const })), getPanePid: vi.fn(async () => null),
listSessions: vi.fn(async () => []), listWindows: vi.fn(async () => []), listPanes: vi.fn(async () => []),
} as unknown as TmuxAdapter);
const adapter = new CodexResumeAdapter(tmux, { sleep: async () => {}, maxWaitMs: 0 });
const result = await adapter.resume("dev-qa@test-rig", "codex_id", "sess-456", "/workspace/app", null, undefined,
undefined, undefined, undefined, undefined, undefined, undefined, workspace);
expect(result.ok).toBe(true);
const managed = (sendText.mock.calls as unknown[][]).map((call) => String(call[1])).find((text) => text.includes(" resume "))!;
expect(managed).toContain(`codex${WORKSPACE_ARGS}`);
expect(shown().entry.resumeCommand).toBe(`codex${WORKSPACE_ARGS} resume 'sess-456'`);
});
it("a choice that can't be read still shows the command, with a note that it doesn't include it", () => {
choose(JSON.stringify({ sandbox: "danger-full-access", approval: "never" }));
const { entry, guidance } = shown();
expect(entry.resumeCommand).toBe("codex -s workspace-write resume 'sess-456'");
expect(entry.resumeCommandNote).toBe(NOT_INCLUDED);
expect(guidance.commands).toEqual(["codex -s workspace-write resume 'sess-456'", "cd /workspace/app"]);
expect(guidance.notes).toContain(NOT_INCLUDED);
});
it("a seat with no choice shows the command as before, with no note", () => {
const { entry, guidance } = shown();
expect(entry.resumeCommand).toBe("codex -s workspace-write resume 'sess-456'");
expect(entry).not.toHaveProperty("resumeCommandNote");
expect(guidance.notes).not.toContain(NOT_INCLUDED);
});
});
it("without a stored conversation, the picker command and the summary carry the choice", () => {
choose(JSON.stringify(workspace));
const { entry, guidance } = shown();
expect(entry.resumeCommand).toBeNull();
expect(entry).not.toHaveProperty("resumeCommandNote");
expect(guidance.commands).toEqual(["cd /workspace/app", `codex${WORKSPACE_ARGS} resume --last`]);
expect(guidance.summary).toBe(`No stored Codex resume token. Try codex${WORKSPACE_ARGS} resume --last; it carries this seat's own permission choice, as its managed launch does.`);
});
});