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
w4rc0n d6d8fce70f feat(daemon): report type, choices, range, format and write route on config browser entries (#3050)
Each entry of GET /api/config?view=browser now says what kind of setting it is and what it accepts, so a client no longer keeps its own copy of the rules. The new optional fields are read from the rules the daemon already enforces: type (boolean, integer, string or enum), choices for an enum key, range for an integer key (a side the validator doesn't bound is left out), format for the keys that check a shape, writeRoute (whether POST and DELETE /api/config/:key accept the key), and appliesAt (daemon_restart where the entry's own guidance already says so, otherwise unknown). These stay separate facts: writeRoute says nothing about whether a value is shown, and appliesAt doesn't replace the existing application text. Writes, coercion, error messages and dynamic keys are unchanged, and there is no new route.
2026-11-12 15:08:14 -08:00
Mike Schwarz 40da7331ad feat(mcp): hold back only new work under a grant service's fair-use limit (#3048)
A grant service that answers rig mcp serve --http --grant-service can now slow an assistant down without disconnecting it. An active answer may carry an optional throttle. While it does, a rig_queue_create that would open a new item writes nothing and returns an ordinary retryable result (fair_use_retry_later, retryAfterSeconds kept within 1 to 600, the qitemId to retry with, outcome not_written). The text tells the assistant when to retry and that its existing work continues. A retry of a create that already landed is served from the stored item, and claim, update, show, list, rig_team, artifact reads and question answers are served as before. When Core can't tell whether an earlier attempt landed, it still writes nothing and says the earlier attempt is unknown. A message is answered on the same item, in its resolution, and the sender reads it there. The request stays authenticated, with no 401 and no dropped connection. A throttle Core can't read is ignored with one log line, and an answer without it behaves exactly as before. The service is asked once per HTTP request, so it counts requests, not tool calls.
2026-11-12 14:49:06 -08:00
14 changed files with 668 additions and 62 deletions

No files matched your search

+12 -1
View File
@@ -180,7 +180,18 @@ and nothing else is offered:
- **Two assistants together:** a grant with `team`/`list` sees its rigs' seats with `rig_team`, and may address and hear from
another connected assistant's seat. A grant without it never reaches a seat in the service's caller form
(`cus-<id>-<connection>@<rig>`), so an existing connection keeps the reach it had.
- **Messages are items:** a message is an item the receiver reads and closes, and a reply is an item back to its sender.
- **Messages are items:** a message is an item the receiver reads and closes, with any reply in its `resolution`. The
sender reads that reply on the same item. Nothing wakes the sender or posts a new item for it. A separate new message is
new work.
- **A fair-use limit on new work:** the service can add `throttle: { new_work: true, retry_after_seconds: <n> }` to an
active answer.
- While it does, a `rig_queue_create` that would open a new item writes nothing. It returns `fair_use_retry_later` with
`retryAfterSeconds` (1 to 600) and the `qitemId` to retry with.
- A retry of a create that already landed is answered from the stored item, as before.
- Everything else is served: reading, claiming and closing existing items (a reply in `resolution` included), and
answering the team's questions. A separate new message or revision waits like any other new item.
- It's never a credential refusal, and a `throttle` that can't be read is ignored.
- The service is asked once per HTTP request (initialize and tool listing included), not once per tool call.
- **What the sender can tell, from the item it reads:**
- **Queued:** the item exists and is `pending` with no claim.
- **Picked up:** the receiver's claim (`in-progress`).
+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",
+20 -2
View File
@@ -9,7 +9,8 @@
//
// With --grant-service, grants come from an external grant service instead and the token file is never read: each
// request's key is checked with that service (openrig.grant/v1) for this daemon's instance, every answer but active is
// refused, and an external caller reaches only what its grant names (externalScope in mcp-server.ts).
// refused, and an external caller reaches only what its grant names (externalScope in mcp-server.ts). An active answer's
// fair-use throttle holds back only that caller's new work (rig_queue_create), never the request.
//
// The endpoint serves both protocol revisions: 2026-07-28 per request, and 2025's initialize and exchanges statelessly.
import http from "node:http";
@@ -231,6 +232,18 @@ const unavailable = (log: string): Refusal => ({
const strings = (value: unknown): value is string[] => Array.isArray(value) && value.every((item) => typeof item === "string" && item !== "");
/** An active answer's optional fair-use limit on new work (`throttle`): the seconds until new work is accepted again,
* rounded up and kept within 1..600, undefined when there is none, or "unusable" when it can't be read. */
function fairUseThrottle(value: unknown): number | undefined | "unusable" {
if (value === undefined || value === null) return undefined;
if (typeof value !== "object" || Array.isArray(value)) return "unusable";
const { new_work: newWork, retry_after_seconds: seconds } = value as Record<string, unknown>;
if (typeof newWork !== "boolean") return "unusable";
if (!newWork) return undefined;
if (typeof seconds !== "number" || !Number.isFinite(seconds)) return "unusable";
return Math.min(600, Math.max(1, Math.ceil(seconds)));
}
/** One request's caller from the grant service's answer for its key. A refusal never falls back to the token file or
* the env's seat. */
async function grantServiceCaller(service: GrantServiceOptions, url: URL, key: string, instance: string): Promise<{ seat: string; access: McpAccess; principal: McpPrincipal } | Refusal> {
@@ -274,11 +287,16 @@ async function grantServiceCaller(service: GrantServiceOptions, url: URL, key: s
if (person !== undefined && person !== null && !personId) {
console.error(`rig mcp serve --http: grant ${grant.id} names an unusable person.entity_id, so this connection isn't offered the person tools (rig_question_*)`);
}
// The service's fair-use limit, when it sets one, holds back only new work (rig_queue_create). One that can't be read is
// ignored: it never refuses the request or reads as a credential problem.
const throttle = fairUseThrottle(body.throttle);
if (throttle === "unusable") console.error(`rig mcp serve --http: grant ${grant.id} answered an unusable throttle, so it's ignored`);
// The principal the daemon records beside this caller's rows. A version that isn't a finite number is left out (a v1
// answer sends a number).
const version = Number.isFinite(grant.version) ? { version: grant.version as number } : {};
return { seat, access: { principal: principal.id, owner: false, rigs, roots, workspace: false,
external: { objects, grant: { id: grant.id, ...version }, ...(personId ? { person: personId } : {}) } },
external: { objects, grant: { id: grant.id, ...version }, ...(personId ? { person: personId } : {}),
...(typeof throttle === "number" ? { throttle: { retryAfterSeconds: throttle } } : {}) } },
principal: { source: "grant-service", id: principal.id, grant: grant.id, ...version } };
}
+29 -3
View File
@@ -432,6 +432,17 @@ function outOfScope(what: string, hint?: string): TextResult {
return errorResult(JSON.stringify({ ok: false, error: "out_of_scope", message }), "out_of_scope", message);
}
/** A new create the grant service's fair-use limit holds back: nothing was written, and it says when to retry with the same
* id. `unread`: whether an earlier create with this id was saved couldn't be read. */
function fairUseRetryLater(qitemId: string, retryAfterSeconds: number, unread: boolean): TextResult {
const earlier = unread ? ` Whether an earlier attempt with this id was saved is unknown: check rig_queue_show with qitemId ${qitemId} before retrying.` : "";
const message = `OpenRig: this account has reached its fair-use limit for new work just now. Nothing was created by this call.${earlier}`
+ ` Retry after ${retryAfterSeconds} seconds with the same qitemId ${qitemId}. Work you already have continues as usual: read, claim, answer and`
+ " close it, and to reply to an item addressed to you, put the reply in its resolution when you close it.";
return errorResult(message, "fair_use_retry_later", message,
{ retryable: true, retryAfterSeconds, qitemId, outcome: "not_written", ...(unread ? { earlierAttempt: "unknown" } : {}) });
}
/** A file the daemon read through a shared root, as text from offset: at most CONTEXT_TEXT_LIMIT characters, with
* nextOffset when more of what the daemon read remains, and its byte cut when the file was longer than the daemon reads. */
function artifactView(source: string, read: FileRead, offset: number): Record<string, unknown> {
@@ -507,8 +518,10 @@ export type McpAccessClass = "work" | "workspace" | "owner" | "person";
export type McpAccess = {
principal: string; owner: boolean; rigs: readonly string[]; roots: readonly string[]; workspace: boolean;
/** An external caller's grant: the object/action pairs it names (openrig.grant/v1 `objects`), the grant it answered
* with, and `person`, the gateway entity id of the person the grant service vouches owns this connection. */
external?: { objects: Readonly<Record<string, readonly string[]>>; grant?: { id: string; version?: number | string }; person?: string };
* with, `person`, the gateway entity id of the person the grant service vouches owns this connection, and `throttle`,
* the service's fair-use limit on new work for this request: the seconds before a new create is accepted again. */
external?: { objects: Readonly<Record<string, readonly string[]>>; grant?: { id: string; version?: number | string }; person?: string;
throttle?: { retryAfterSeconds: number } };
};
/** The tool each external object/action pair offers. A pair not listed here offers nothing. */
@@ -638,7 +651,7 @@ const EXTERNAL_PERSON_INSTRUCTIONS = [
/** For an external grant that names claim and update: how it works the items addressed to it, messages included. */
const EXTERNAL_INBOX_INSTRUCTIONS = [...SHARED_INSTRUCTIONS,
"Items addressed to your seat are yours: rig_queue_list with owned set lists them. For each, claim it (rig_queue_claim), do it, then close it done with closure reason no-follow-on and the answer in resolution (rig_queue_update); the sender reads that answer on the same item. A message is an item too: read it, close it done, and reply, when it needs one, with a new item to its sender. Nothing promises to wake you, so check your items again.",
"Items addressed to your seat are yours: rig_queue_list with owned set lists them. For each, claim it (rig_queue_claim), do it, then close it done with closure reason no-follow-on and the answer in resolution (rig_queue_update); the sender reads that answer on the same item. A message is an item too: read it and close it done, with your reply in resolution when it needs one; its sender reads that reply on the same item. Nothing promises to wake you, so check your items again.",
].join("\n");
function instructionsFor(access: McpAccess | undefined, frontDoor = false): string {
@@ -1402,6 +1415,19 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
}
}
const id = qitemId ?? `qitem-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}-${randomBytes(8).toString("hex")}`;
// The grant service's fair-use limit holds back new work only. A retry of a create that already landed isn't new
// work: the daemon answers it from the stored row, so it goes through as before.
const throttle = access?.external?.throttle;
if (throttle) {
let landed: boolean | undefined = false;
if (qitemId) {
try {
const existing = await client.get(`/api/queue/${encodeURIComponent(qitemId)}`);
landed = existing.status < 400 ? true : existing.status === 404 ? false : undefined;
} catch { landed = undefined; }
}
if (landed !== true) return fairUseRetryLater(id, throttle.retryAfterSeconds, landed === undefined);
}
// With no seat, the task goes to its destination as from the unknown sender, with the note at the top of its body.
const note = unknownSenderNote("task");
try {
@@ -1045,6 +1045,94 @@ describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
expect((await asExternal("rig_queue_create", { destination: "lead@team-a", body: "far", qitemId: far })).text).not.toContain("other-host");
});
describe("a fair-use throttle on an active answer", () => {
const ALL = { queue: ["create", "show", "list", "claim", "update"] };
const throttled = (throttle: unknown) => (): [number, unknown] => [200, active({ objects: ALL, throttle })];
it("holds back only new creates, which write nothing and say when to retry with the same id; a replay, claim, update, show and list are served", async () => {
answer = () => [200, active({ objects: ALL })];
const { url } = await listen();
const ask = await connect(url);
const landed = { destination: "lead@team-a", body: "First task", qitemId: "qitem-landed" };
expect((await ask("rig_queue_create", landed)).isError).toBe(false);
const theirs = (await create("lead@team-a", CALLER, "A task for the assistant")).qitemId;
answer = throttled({ new_work: true, retry_after_seconds: 30 });
const before = rows();
for (const args of [
{ destination: "lead@team-a", body: "New work" },
{ destination: "lead@team-a", body: "New work", qitemId: "qitem-new" },
{ destination: "lead@team-a", body: "Improve it", revises: landed.qitemId },
] as Array<Record<string, string>>) {
const held = await ask("rig_queue_create", args);
expect(held.isError).toBe(true);
expect(held.structured).toMatchObject({ ok: false, code: "fair_use_retry_later", retryable: true, retryAfterSeconds: 30, outcome: "not_written" });
expect(held.structured.earlierAttempt).toBeUndefined();
expect(held.text).toContain("Nothing was created by this call.");
expect(held.text).toContain(`Retry after 30 seconds with the same qitemId ${held.structured.qitemId}.`);
if (args.qitemId) expect(held.structured.qitemId).toBe(args.qitemId);
}
expect(rows()).toBe(before);
expect(repo.getById("qitem-new")).toBeFalsy();
const replay = await ask("rig_queue_create", landed);
expect(replay.isError).toBe(false);
expect(replay.structured.qitemId).toBe(landed.qitemId);
expect((await ask("rig_queue_claim", { qitemId: theirs })).isError).toBe(false);
expect((await ask("rig_queue_update", { qitemId: theirs, state: "done", closureReason: "no-follow-on", resolution: "Done" })).isError).toBe(false);
expect(repo.getById(theirs)).toMatchObject({ state: "done", resolution: "Done" });
expect((await ask("rig_queue_show", { qitemId: landed.qitemId })).isError).toBe(false);
expect((await ask("rig_queue_list", {})).isError).toBe(false);
expect(rows()).toBe(before);
// Once the service lifts it, the same retry lands.
answer = () => [200, active({ objects: ALL })];
expect((await ask("rig_queue_create", { destination: "lead@team-a", body: "New work", qitemId: "qitem-new" })).isError).toBe(false);
expect(rows()).toBe(before + 1);
});
it.each([
["a string", "yes"],
["a list", [true]],
["new_work as text", { new_work: "true", retry_after_seconds: 30 }],
["a delay as text", { new_work: true, retry_after_seconds: "30" }],
["no delay", { new_work: true }],
])("ignores a throttle it can't read (%s): nothing is refused, and the log says so", async (_label, throttle) => {
const logged = vi.spyOn(console, "error").mockImplementation(() => {});
answer = throttled(throttle);
const { url } = await listen();
const ask = await connect(url);
expect((await ask("rig_queue_create", { destination: "lead@team-a", body: "Served" })).isError).toBe(false);
expect(rows()).toBe(1);
expect(logged.mock.calls.some(([line]) => String(line).includes("answered an unusable throttle, so it's ignored"))).toBe(true);
logged.mockRestore();
});
it("serves everything when new_work is false, and keeps a delay within 1 to 600 seconds, rounded up", async () => {
answer = throttled({ new_work: false, retry_after_seconds: 30 });
const { url } = await listen();
const ask = await connect(url);
expect((await ask("rig_queue_create", { destination: "lead@team-a", body: "Served" })).isError).toBe(false);
for (const [given, kept] of [[0, 1], [-5, 1], [12.2, 13], [9000, 600]]) {
answer = throttled({ new_work: true, retry_after_seconds: given });
expect((await ask("rig_queue_create", { destination: "lead@team-a", body: "Held" })).structured.retryAfterSeconds).toBe(kept);
}
expect(rows()).toBe(1);
});
it.each([
["answers an error", () => new Response(JSON.stringify({ error: "boom" }), { status: 500, headers: { "Content-Type": "application/json" } })],
["answers a body it can't parse", () => new Response("{", { status: 200, headers: { "Content-Type": "application/json" } })],
])("writes nothing, and says an earlier attempt is unknown, when the check of whether the id landed %s", async (_label, failure) => {
answer = throttled({ new_work: true, retry_after_seconds: 5 });
const { url } = await listen();
const ask = await connect(url);
broken = (req) => req.method === "GET" && new URL(req.url).pathname === "/api/queue/qitem-unread" ? failure() : undefined;
const held = await ask("rig_queue_create", { destination: "lead@team-a", body: "Maybe", qitemId: "qitem-unread" });
broken = undefined;
expect(held.structured).toMatchObject({ code: "fair_use_retry_later", outcome: "not_written", earlierAttempt: "unknown", qitemId: "qitem-unread" });
expect(held.text).toContain("Whether an earlier attempt with this id was saved is unknown");
expect(rows()).toBe(0);
});
});
it("a task parked on an internal child reads blocked, with no child id, owner or state anywhere", async () => {
const { asExternal, asLead, mine } = await externalAndLead();
const child = await create("lead@team-a", "qa@team-b", "SECRET child");
@@ -50,6 +50,8 @@ describe("agent network increment A: OAuth-connected assistants work with each o
let serviceUrl: string;
/** What each connection's grant names, by key; a key set to "revoked" is answered revoked. */
let grants: Map<string, Record<string, string[]> | "revoked">;
/** The keys whose active answer carries the grant service's fair-use throttle on new work. */
let throttled: Set<string>;
const closers: Array<() => Promise<void>> = [];
const saved = { home: process.env.OPENRIG_HOME, session: process.env.OPENRIG_SESSION_NAME };
@@ -65,6 +67,7 @@ describe("agent network increment A: OAuth-connected assistants work with each o
daemonUrl = `http://127.0.0.1:${(daemon.address() as AddressInfo).port}`;
setSelfHostId(INSTANCE);
grants = new Map([[A.key, NETWORK], [B.key, NETWORK]]);
throttled = new Set();
service = http.createServer((req, res) => {
req.resume();
req.on("end", () => {
@@ -72,7 +75,8 @@ describe("agent network increment A: OAuth-connected assistants work with each o
const who = key === A.key ? A : key === B.key ? B : null;
const objects = who ? grants.get(who.key) : undefined;
const body = !who || !objects ? { schema: SCHEMA, status: "unknown" }
: objects === "revoked" ? { schema: SCHEMA, status: "revoked" } : active(who, objects);
: objects === "revoked" ? { schema: SCHEMA, status: "revoked" }
: { ...active(who, objects), ...(throttled.has(who.key) ? { throttle: { new_work: true, retry_after_seconds: 20 } } : {}) };
res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(body));
});
});
@@ -113,7 +117,7 @@ describe("agent network increment A: OAuth-connected assistants work with each o
const result = await client.callTool({ name, arguments: args });
return { isError: result.isError === true, structured: result.structuredContent as Record<string, any>, text: (result.content as Array<{ text: string }>)[0]?.text ?? "" };
};
return Object.assign(call, { tools: async () => (await client.listTools()).tools.map((tool) => tool.name).sort() });
return Object.assign(call, { tools: async () => (await client.listTools()).tools.map((tool) => tool.name).sort(), instructions: () => client.getInstructions() });
}
const rows = () => (db.prepare("SELECT COUNT(*) AS n FROM queue_items").get() as { n: number }).n;
@@ -174,6 +178,42 @@ describe("agent network increment A: OAuth-connected assistants work with each o
expect((await a("rig_queue_show", { qitemId: reply })).text).toContain("Agreed; I'll review the fix.");
});
it("under a fair-use throttle, B answers A's message on the same item and A reads that answer through its own grant; B's separate new message waits", async () => {
const served = await listen();
const a = await connect(served.url, A);
const b = await connect(served.url, B);
expect(b.instructions()).toContain("with your reply in resolution when it needs one; its sender reads that reply on the same item");
expect(b.instructions()).not.toContain("with a new item to its sender");
const message = createdId(await a("rig_queue_create", { destination: B.seat, summary: "Which fix first?", body: "Smaller fix or the full one?", tags: ["message"] }));
throttled.add(B.key);
const before = rows();
expect((await b("rig_queue_list", { owned: true })).text).toContain(message);
expect((await b("rig_queue_claim", { qitemId: message })).isError).toBe(false);
expect((await b("rig_queue_update", { qitemId: message, state: "done", closureReason: "no-follow-on", resolution: "The smaller fix first." })).isError).toBe(false);
const separate = await b("rig_queue_create", { destination: A.seat, summary: "Also", body: "One more thing.", tags: ["message"] });
expect(separate).toMatchObject({ isError: true, structured: { code: "fair_use_retry_later", retryAfterSeconds: 20, outcome: "not_written" } });
expect(rows()).toBe(before);
// A, the sender, isn't throttled, and reads B's answer on its own item through its own grant's ordinary show and list.
const answered = await a("rig_queue_show", { qitemId: message });
expect(answered.structured.item).toMatchObject({ state: "done", resolution: "The smaller fix first.", destinationSession: B.seat });
expect((await a("rig_queue_list", { mine: true, all: true })).text).toContain("The smaller fix first.");
});
it("under a fair-use throttle, A answers the lead's message on the same item and the lead reads it on its ordinary read; A's new item to the lead waits", async () => {
const served = await listen();
const a = await connect(served.url, A);
const message = (await repo.create({ sourceSession: LEAD, destinationSession: A.seat, body: "Can you take the review?", nudge: false } as never)).qitemId;
throttled.add(A.key);
const before = rows();
expect((await a("rig_queue_claim", { qitemId: message })).isError).toBe(false);
expect((await a("rig_queue_update", { qitemId: message, state: "done", closureReason: "no-follow-on", resolution: "Yes, after lunch." })).isError).toBe(false);
expect((await a("rig_queue_create", { destination: LEAD, body: "Starting the review." })).structured).toMatchObject({ code: "fair_use_retry_later", outcome: "not_written" });
expect(rows()).toBe(before);
const read = await fetch(`${daemonUrl}/api/queue/${encodeURIComponent(message)}`, { headers: { "X-OpenRig-Session": LEAD } });
expect(read.status).toBe(200);
expect(await read.json()).toMatchObject({ sourceSession: LEAD, state: "done", resolution: "Yes, after lunch." });
});
it("B hands work on to the team and reads the team's result; A's task and B's task are separate items, each with its own result", async () => {
const served = await listen();
const b = await connect(served.url, B);
@@ -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
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { join, isAbsolute } from "node:path";
import { getOpenRigHome } from "../../openrig-compat.js";
import { SETTINGS_VALID_KEYS, type SettingsStore, type ResolvedSetting } from "./settings-store.js";
import { SETTINGS_VALID_KEYS, describeSettingKey, type SettingKeyDescription, type SettingsStore, type ResolvedSetting } from "./settings-store.js";
import { connectionsProjection, readConnectionConfiguration } from "../gateway/connections-projection.js";
import { DEFAULT_CONFIG, INHERIT_INBOUND_DESTINATION, LEGACY_INBOUND_DESTINATION } from "../gateway/slack/config.js";
import { projectionPath } from "../gateway/human-registry.js";
@@ -13,7 +13,13 @@ import { describeDefaultReceiver, type DefaultReceiver } from "../default-receiv
type Value = string | number | boolean | null;
type State = "available" | "missing" | "malformed" | "unavailable";
type Kind = "scalar" | "text" | "path" | "identity" | "url" | "names" | "paths" | "withheld";
export interface BrowserEntry {
/** Each is optional and separate: the rules a value must meet, whether a write route exists for the key, when a change is
* known to take effect, and (above, on the entry) whether this caller is shown the value. None implies another. */
export interface BrowserEntryMetadata extends SettingKeyDescription {
/** "daemon_restart" when the application guidance says so; otherwise "unknown": OpenRig does not guess. */
appliesAt: "daemon_restart" | "unknown";
}
export interface BrowserEntry extends BrowserEntryMetadata {
key: string;
group: "general" | "slack" | "people" | "hosts" | "health";
value: Value;
@@ -90,6 +96,8 @@ function sourceFile(file: string): { state: State; bytes: string | null } {
try { return { state: "available", bytes: readFileSync(file, "utf8") }; }
catch (error) { return { state: (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "unavailable", bytes: null }; }
}
/** The keys whose guidance below says a daemon restart or startup applies them. The browser test ties this to that text. */
const DAEMON_RESTART_KEYS = new Set(["terminal.tmux_server", "retention.enabled"]);
function application(key: string): string {
if (key === "terminal.tmux_server") return "Selected at daemon restart; surviving sessions keep their previous server until deliberately stopped. See terminal status for the effective choice.";
if (key === "ui.terminal.max_live_terminals") return "Legacy web client; unset resolver value, consumer fallback 2. No TUI limit implied.";
@@ -119,7 +127,8 @@ export function settingsBrowser(store: SettingsStore, gateway: Record<string, un
const d = safeValue(defaultValue, kind, redact);
entries.push({ key, group, value: v.value, defaultValue: d.value, defaultKnown: defaultValue !== undefined && !["people", "hosts"].includes(group), source,
visibility: source === "unavailable" ? "unavailable" : v.value === null && v.reason ? "withheld" : "shown",
reason: v.reason ?? d.reason, scope, application: applied });
reason: v.reason ?? d.reason, scope, application: applied,
...describeSettingKey(key), appliesAt: DAEMON_RESTART_KEYS.has(key) ? "daemon_restart" : "unknown" });
}
const file = sourceFile(store.configPath);
let generalState = file.state;
@@ -842,6 +842,20 @@ function coerceValue(key: SettingsValidKey, raw: string, workspaceRoot: string):
// in `set()`. Lockstep with cli/src/config-store.ts KEY_CONSTRAINTS so the
// daemon's HTTP write surface (/api/config POST) rejects the same input
// the CLI rejects.
/** What a validator can say about the values it accepts, kept on the validator itself so it cannot drift from it. */
interface ConstraintMeta { choices?: readonly string[]; format?: string }
type KeyConstraint = ((raw: string, coerced: string | number | boolean) => void) & { meta?: ConstraintMeta };
function described(meta: ConstraintMeta, check: (raw: string, coerced: string | number | boolean) => void): KeyConstraint {
return Object.assign(check, { meta });
}
/** A closed list of accepted strings: the list is both what is enforced and what the browser view reports. */
function choiceConstraint(choices: readonly string[], message: (raw: string) => string, trim = false): KeyConstraint {
return described({ choices }, (raw) => {
if (!choices.includes(trim ? (raw ?? "").trim() : raw)) throw new Error(message(raw));
});
}
const PROVIDER_SELECTED_CHOICES: readonly string[] = ["", "claude", "codex", "claude,codex", "codex,claude"];
function positiveIntegerConstraint(key: string) {
return (raw: string, coerced: string | number | boolean): void => {
if (!/^\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced <= 0) {
@@ -850,12 +864,6 @@ function positiveIntegerConstraint(key: string) {
};
}
function wakeHumanRungConstraint(raw: string): void {
if (raw !== "always" && raw !== "explicit-only") {
throw new Error(`Invalid value for queue.wake_human_rung: must be "always" or "explicit-only", got "${raw}"`);
}
}
function percentageConstraint(key: string) {
return (raw: string, coerced: string | number | boolean): void => {
if (!/^\d+$/.test((raw ?? "").trim())
@@ -868,15 +876,10 @@ function percentageConstraint(key: string) {
};
}
const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, (raw: string, coerced: string | number | boolean) => void>> = {
"terminal.tmux_server": (raw) => {
if (raw !== "inherit" && raw !== "isolated") {
throw new Error(`Invalid value for terminal.tmux_server: must be "inherit" or "isolated", got "${raw}"`);
}
},
"daemon.db_conflict": (raw) => {
if (raw !== "warn" && raw !== "stop") throw new Error("daemon.db_conflict must be warn or stop");
},
const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, KeyConstraint>> = {
"terminal.tmux_server": choiceConstraint(["inherit", "isolated"],
(raw) => `Invalid value for terminal.tmux_server: must be "inherit" or "isolated", got "${raw}"`),
"daemon.db_conflict": choiceConstraint(["warn", "stop"], () => "daemon.db_conflict must be warn or stop"),
"daemon.seat_poll_interval_ms": (raw, value) => {
// Startup-only: the seat pollers read it once when the daemon starts. Only slower than the 1 s default, up to the
// ceiling the structural observation's expiry allows (seat-activity-service.ts MAX_SEAT_POLL_INTERVAL_MS).
@@ -884,9 +887,8 @@ const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, (raw: string, coerced: s
throw new Error("Invalid daemon.seat_poll_interval_ms: must be a whole number of milliseconds in [1000, 2000]");
}
},
"transport.draft_delivery": (raw) => {
if (raw !== "refuse" && raw !== "deliver") throw new Error(`Invalid value for transport.draft_delivery: must be "refuse" or "deliver", got "${raw}"`);
},
"transport.draft_delivery": choiceConstraint(["refuse", "deliver"],
(raw) => `Invalid value for transport.draft_delivery: must be "refuse" or "deliver", got "${raw}"`),
"transcripts.lines": (raw, value) => {
if (!/^\d+$/.test(raw.trim()) || typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > 1_000_000) {
throw new Error("Invalid transcripts.lines: must be an integer in [1, 1000000]");
@@ -928,37 +930,34 @@ const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, (raw: string, coerced: s
throw new Error(`Invalid value for daemon.port: must be an integer in [0, 65535], got "${raw}"`);
}
},
"ui.timezone": (_raw, value) => {
"ui.timezone": described({ format: "iana-timezone" }, (_raw, value) => {
try {
if (typeof value !== "string" || !value || /^[+-]/.test(value)) throw new Error();
new Intl.DateTimeFormat("en-US", { timeZone: value });
} catch { throw new Error("Invalid ui.timezone: use an IANA timezone such as America/Los_Angeles or Europe/London"); }
},
}),
"health.context_pressure.warning_percent": percentageConstraint("health.context_pressure.warning_percent"),
"health.context_pressure.critical_percent": percentageConstraint("health.context_pressure.critical_percent"),
"policies.idle_gate_qitem.scan_interval_seconds": positiveIntegerConstraint("policies.idle_gate_qitem.scan_interval_seconds"),
"policies.idle_gate_qitem.active_wake_interval_seconds": positiveIntegerConstraint("policies.idle_gate_qitem.active_wake_interval_seconds"),
"policies.idle_gate_qitem.auto_register": (raw) => {
const v = (raw ?? "").trim();
if (v !== "off" && v !== "all") {
throw new Error(`Invalid value for policies.idle_gate_qitem.auto_register: must be "off" or "all", got "${raw}"`);
}
},
"policies.idle_gate_qitem.auto_register": choiceConstraint(["off", "all"],
(raw) => `Invalid value for policies.idle_gate_qitem.auto_register: must be "off" or "all", got "${raw}"`, true),
// Policy threshold: integer in [1, 100]. Documented contract from
// slice 27 README. parseInt's permissive coercion ("80abc" → 80;
// "80.5" → 80) is not safe for a key the daemon's compaction trigger
// reads at every poll tick; runtime validator rejects what the
// contract forbids per banked feedback_static_gates_mirror_runtime_validators.
"provider.selected": (_raw, value) => {
if (typeof value !== "string" || !/^(|claude|codex|claude,codex|codex,claude)$/.test(value)) {
// Checks the typed value too: a config.json array would otherwise pass as its String() form.
"provider.selected": described({ choices: PROVIDER_SELECTED_CHOICES }, (_raw, value) => {
if (typeof value !== "string" || !PROVIDER_SELECTED_CHOICES.includes(value)) {
throw new Error("Invalid provider.selected: use claude, codex, claude,codex, or an empty string for no recorded choice");
}
},
"policies.claude_compaction.preparation_root": (_raw, value) => {
}),
"policies.claude_compaction.preparation_root": described({ format: "absolute-path-or-empty" }, (_raw, value) => {
if (typeof value !== "string" || (value !== "" && (!path.isAbsolute(value) || value.includes("\0")))) {
throw new Error("Invalid policies.claude_compaction.preparation_root: use an absolute path or an empty string for the default");
}
},
}),
"policies.claude_compaction.threshold_percent": (raw, coerced) => {
const trimmed = (raw ?? "").trim();
if (!/^-?\d+$/.test(trimmed)) {
@@ -1049,12 +1048,12 @@ const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, (raw: string, coerced: s
}
},
// Empty (the default) keeps no erasure ledger; otherwise an absolute file path, kept outside the backed-up data.
"retention.erasure_ledger": (raw) => {
"retention.erasure_ledger": described({ format: "absolute-path-or-empty" }, (raw) => {
const value = (raw ?? "").trim();
if (value !== "" && !value.startsWith("/")) {
throw new Error(`Invalid value for retention.erasure_ledger: must be empty or an absolute file path, got "${raw}"`);
}
},
}),
"retention.watchdog_keep_per_job": (raw, coerced) => {
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 0) {
throw new Error(`Invalid value for retention.watchdog_keep_per_job: must be an integer >= 0, got "${raw}"`);
@@ -1070,12 +1069,12 @@ const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, (raw: string, coerced: s
throw new Error(`Invalid value for retention.stream_max_items: must be an integer >= 1, got "${raw}"`);
}
},
"stream.intake": (raw) => {
"stream.intake": described({ format: "queue.worker, off, or member@rig" }, (raw) => {
const val = (raw ?? "").trim();
if (val !== "off" && val !== "queue.worker" && !/^[a-zA-Z0-9\-_.]+@[a-zA-Z0-9\-_.]+$/.test(val)) {
throw new Error(`Invalid value for stream.intake: must be "queue.worker", a canonical session (<seat>@<rig>), or "off", got "${raw}"`);
}
},
}),
"retention.batch_size": (raw, coerced) => {
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 1) {
throw new Error(`Invalid value for retention.batch_size: must be an integer >= 1, got "${raw}"`);
@@ -1089,18 +1088,19 @@ const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, (raw: string, coerced: s
"queue.wake_retry_cap": positiveIntegerConstraint("queue.wake_retry_cap"),
"queue.wake_unconfirmed_window_minutes": positiveIntegerConstraint("queue.wake_unconfirmed_window_minutes"),
"queue.wake_swap_grace_seconds": positiveIntegerConstraint("queue.wake_swap_grace_seconds"),
"queue.wake_human_rung": wakeHumanRungConstraint,
"decisions.template_dir": (_raw, value) => {
"queue.wake_human_rung": choiceConstraint(["always", "explicit-only"],
(raw) => `Invalid value for queue.wake_human_rung: must be "always" or "explicit-only", got "${raw}"`),
"decisions.template_dir": described({ format: "absolute-path-or-empty" }, (_raw, value) => {
if (typeof value !== "string" || (value !== "" && (!path.isAbsolute(value) || value.includes("\0")))) {
throw new Error("Invalid decisions.template_dir: use an absolute path, or an empty string for the shipped templates only");
}
},
}),
// rig acp serve's receiver when --to isn't given: a team seat, member@rig, or empty for none.
"acp.to": (_raw, value) => {
"acp.to": described({ format: "member@rig-or-empty" }, (_raw, value) => {
if (typeof value !== "string" || (value !== "" && !/^[^@\s]+@[^@\s]+$/.test(value))) {
throw new Error("Invalid acp.to: use the seat that receives your ACP tasks as member@rig, or an empty string for none");
}
},
}),
};
function validateKeyConstraints(key: SettingsValidKey, raw: string, coerced: string | number | boolean): void {
@@ -1126,6 +1126,58 @@ function validateTypedFileValue(key: SettingsValidKey, value: string | number |
check(raw, value);
}
/** What the browser view reports about a key's values, read from the rules that govern it. */
export interface SettingKeyDescription {
/** Whether POST and DELETE /api/config/:key accept this key. */
writeRoute: boolean;
type?: "boolean" | "integer" | "string" | "enum";
choices?: string[];
range?: { min?: number; max?: number };
format?: string;
}
const ACCEPTED_INTEGER_LIMIT = Number.MAX_SAFE_INTEGER;
/** The interval of whole numbers a key's validator accepts, found by asking the validator itself; a side it does not bound is left out. */
function acceptedIntegerRange(key: SettingsValidKey, start: number): { min?: number; max?: number } | undefined {
const check = KEY_CONSTRAINTS[key];
if (!check) return undefined;
const accepts = (n: number): boolean => {
try { check(String(n), n); return true; } catch { return false; }
};
if (!accepts(start)) return undefined;
const range: { min?: number; max?: number } = {};
if (!accepts(ACCEPTED_INTEGER_LIMIT)) {
let lo = start, hi = ACCEPTED_INTEGER_LIMIT;
while (hi - lo > 1) { const mid = Math.floor(lo + (hi - lo) / 2); if (accepts(mid)) lo = mid; else hi = mid; }
range.max = lo;
}
if (!accepts(-ACCEPTED_INTEGER_LIMIT)) {
let lo = -ACCEPTED_INTEGER_LIMIT, hi = start;
while (hi - lo > 1) { const mid = Math.floor(lo + (hi - lo) / 2); if (accepts(mid)) hi = mid; else lo = mid; }
range.min = hi;
}
return range.min === undefined && range.max === undefined ? undefined : range;
}
/** Derived from coerceValue (the type), KEY_CONSTRAINTS (choices, format, range) and the key grammar (write route); no second list. */
export function describeSettingKey(key: string): SettingKeyDescription {
if (parseFeedHostSubscriptionKey(key)) return { writeRoute: true, type: "boolean" };
if (!isSettingsValidKey(key)) return { writeRoute: false };
const fallback = getDefaultValue(key, "");
const meta = KEY_CONSTRAINTS[key]?.meta;
const out: SettingKeyDescription = { writeRoute: true };
if (typeof fallback === "boolean") out.type = "boolean";
else if (typeof fallback === "number") {
out.type = "integer";
const range = acceptedIntegerRange(key, fallback);
if (range) out.range = range;
} else out.type = meta?.choices ? "enum" : "string";
if (meta?.choices) out.choices = [...meta.choices];
if (meta?.format) out.format = meta.format;
return out;
}
/**
* Slice 27 — projected Claude auto-compaction policy. ContextMonitor
* consumes this snapshot per-poll; the PreCompact hook reads the same
@@ -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.`);
});
});
@@ -0,0 +1,166 @@
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { SettingsStore, SETTINGS_VALID_KEYS, isSettingsValidKey, parseFeedHostSubscriptionKey } from "../src/domain/user-settings/settings-store.js";
import { settingsBrowser } from "../src/domain/user-settings/settings-browser.js";
import { configRoutes } from "../src/routes/config.js";
let home: string;
let n = 0;
const freshStore = () => new SettingsStore(join(home, `config-${n++}.json`));
const browse = () => settingsBrowser(freshStore(), null, home);
/** The write path is the oracle: does the daemon's own set() accept this raw value for this key? */
const accepts = (key: string, raw: string): boolean => {
try { freshStore().set(key, raw); return true; } catch { return false; }
};
/** Each accepts 1 to 100 on its own, but set() also requires warning < critical: a cross-key rule these per-key fields do not describe. */
const PAIRED = new Set(["health.context_pressure.warning_percent", "health.context_pressure.critical_percent"]);
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), "s06-browser-meta-"));
for (const key of Object.keys(process.env).filter((k) => /^(OPENRIG_|RIGGED_|SLACK_)/.test(k))) vi.stubEnv(key, "");
vi.stubEnv("OPENRIG_HOME", home);
vi.stubEnv("OPENRIG_WORKSPACE_ROOT", join(home, "workspace"));
});
afterEach(() => { vi.unstubAllEnvs(); rmSync(home, { recursive: true, force: true }); });
describe("browser entry metadata", () => {
it("adds type, choices, range, format, write route and timing without changing the existing fields", () => {
const entries = browse().entries;
expect(entries.find((e) => e.key === "terminal.history_limit")).toMatchObject({
type: "integer", range: { min: 2000, max: 1_000_000 }, writeRoute: true, appliesAt: "unknown",
value: expect.anything(), defaultKnown: true, application: expect.stringContaining("tmux"),
});
expect(entries.find((e) => e.key === "terminal.tmux_server")).toMatchObject({
type: "enum", choices: ["inherit", "isolated"], writeRoute: true, appliesAt: "daemon_restart",
});
expect(entries.find((e) => e.key === "daemon.port")).toMatchObject({ type: "integer", range: { min: 0, max: 65535 } });
expect(entries.find((e) => e.key === "retention.stream_days")).toMatchObject({ type: "integer", range: { min: 1 } });
expect(entries.find((e) => e.key === "retention.stream_days")!.range).not.toHaveProperty("max");
expect(entries.find((e) => e.key === "ui.timezone")).toMatchObject({ type: "string", format: "iana-timezone" });
expect(entries.find((e) => e.key === "retention.enabled")).toMatchObject({ type: "boolean", appliesAt: "daemon_restart" });
// A key with no validator reports a type and nothing it cannot back up.
const free = entries.find((e) => e.key === "host.name")!;
expect(free).toMatchObject({ type: "string", writeRoute: true });
expect(free).not.toHaveProperty("choices");
expect(free).not.toHaveProperty("range");
expect(free).not.toHaveProperty("format");
});
it("matches the daemon's own validator for every key it can be written under", () => {
const entries = browse().entries.filter((e) => e.group === "general" && isSettingsValidKey(e.key));
expect(entries.map((e) => e.key)).toEqual([...SETTINGS_VALID_KEYS]);
for (const e of entries) {
expect(e.writeRoute, e.key).toBe(true);
expect(e.type, e.key).toBeDefined();
if (e.type === "boolean") {
expect(accepts(e.key, "true"), e.key).toBe(true);
expect(accepts(e.key, "false"), e.key).toBe(true);
expect(accepts(e.key, "not-a-boolean"), e.key).toBe(false);
}
if (e.type === "integer" && !PAIRED.has(e.key)) {
expect(accepts(e.key, "not-a-number"), e.key).toBe(false);
if (e.range?.min !== undefined) {
expect(accepts(e.key, String(e.range.min)), `${e.key} min`).toBe(true);
expect(accepts(e.key, String(e.range.min - 1)), `${e.key} below min`).toBe(false);
}
if (e.range?.max !== undefined) {
expect(accepts(e.key, String(e.range.max)), `${e.key} max`).toBe(true);
expect(accepts(e.key, String(e.range.max + 1)), `${e.key} above max`).toBe(false);
}
// Where a side is left out, the validator must accept a value far on that side.
if (e.range?.max === undefined) expect(accepts(e.key, "1000000000"), `${e.key} no max`).toBe(true);
if (e.range?.min === undefined) expect(accepts(e.key, "0") || accepts(e.key, "1"), `${e.key} no min`).toBe(true);
}
if (e.choices) {
expect(e.type, e.key).toBe("enum");
for (const choice of e.choices) expect(accepts(e.key, choice), `${e.key}=${choice}`).toBe(true);
expect(accepts(e.key, "not-one-of-the-choices"), e.key).toBe(false);
}
}
});
it("states a range only where the validator is an interval, at sample points across the whole line", () => {
const samples = [-1_000_000_000, -100_001, -1000, -61, -60, -2, -1, 0, 1, 2, 59, 60, 100, 101, 599, 600, 601,
999, 1000, 1999, 2000, 2001, 3600, 3601, 65535, 65536, 999_999, 1_000_000, 1_000_001, 1_000_000_000];
const ranged = browse().entries.filter((x) => x.type === "integer" && x.range && !PAIRED.has(x.key));
expect(ranged.length).toBeGreaterThan(25);
for (const e of ranged) {
for (const sample of samples) {
const inside = (e.range!.min === undefined || sample >= e.range!.min) && (e.range!.max === undefined || sample <= e.range!.max);
expect(accepts(e.key, String(sample)), `${e.key} at ${sample}`).toBe(inside);
}
}
});
it("reports the per-key range of the paired percentages and leaves their cross-key rule out", () => {
const entries = browse().entries;
for (const key of PAIRED) expect(entries.find((e) => e.key === key)).toMatchObject({ type: "integer", range: { min: 1, max: 100 } });
// Per key the bounds are the validator's: critical takes 100, nothing takes 0 or 101 ...
expect(accepts("health.context_pressure.critical_percent", "100")).toBe(true);
for (const key of PAIRED) for (const bad of ["0", "101"]) expect(accepts(key, bad), `${key}=${bad}`).toBe(false);
expect(accepts("health.context_pressure.warning_percent", "1")).toBe(true);
// ... while the pair rule (warning below critical) still refuses a value inside that range, and is not a field here.
expect(accepts("health.context_pressure.warning_percent", "100")).toBe(false);
expect(entries.find((e) => e.key === "health.context_pressure.warning_percent")).not.toHaveProperty("pair");
});
it("names a format that agrees with what the validator accepts and refuses", () => {
const cases: Record<string, { format: string; ok: string[]; bad: string[] }> = {
"ui.timezone": { format: "iana-timezone", ok: ["Europe/London", "UTC"], bad: ["Not/AZone", "+01:00"] },
"policies.claude_compaction.preparation_root": { format: "absolute-path-or-empty", ok: ["/var/lib/example", ""], bad: ["relative/path"] },
"decisions.template_dir": { format: "absolute-path-or-empty", ok: ["/var/lib/example", ""], bad: ["relative/path"] },
"retention.erasure_ledger": { format: "absolute-path-or-empty", ok: ["/var/lib/example/ledger", ""], bad: ["relative/ledger"] },
"stream.intake": { format: "queue.worker, off, or member@rig", ok: ["off", "queue.worker", "seat@rig"], bad: ["no at sign"] },
"acp.to": { format: "member@rig-or-empty", ok: ["seat@rig", ""], bad: ["no-at-sign", "a@b@c"] },
};
const entries = browse().entries;
for (const [key, c] of Object.entries(cases)) {
expect(entries.find((e) => e.key === key)!.format, key).toBe(c.format);
for (const ok of c.ok) expect(accepts(key, ok), `${key}=${ok}`).toBe(true);
for (const bad of c.bad) expect(accepts(key, bad), `${key}=${bad}`).toBe(false);
}
// Every key that reports a format is in this table, so a new one cannot ship unchecked.
expect(entries.filter((e) => e.format).map((e) => e.key).sort()).toEqual(Object.keys(cases).sort());
});
it("reports the write route as the daemon's own key rules decide, for every entry", () => {
const store = freshStore();
store.set("feed.subscriptions.fixture-host.enabled", "true");
const entries = settingsBrowser(store, null, home).entries;
for (const e of entries) {
expect(e.writeRoute, e.key).toBe(isSettingsValidKey(e.key) || parseFeedHostSubscriptionKey(e.key) !== null);
}
expect(entries.find((e) => e.key === "feed.subscriptions.fixture-host.enabled")).toMatchObject({ type: "boolean", writeRoute: true });
expect(entries.find((e) => e.key === "slack.enabled")).toMatchObject({ writeRoute: false });
expect(entries.find((e) => e.key === "slack.enabled")).not.toHaveProperty("type");
expect(entries.filter((e) => e.group !== "general" && e.writeRoute)).toEqual([]);
});
it("agrees with the write route itself: the keys it reports writable are accepted, the others are unknown keys", async () => {
const store = freshStore();
const app = new Hono();
app.use("*", async (c, next) => { c.set("settingsStore" as never, store as never); await next(); });
app.route("/api/config", configRoutes({ home }));
const post = (key: string, value: string) => app.request(`/api/config/${key}`, {
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ value }) });
expect((await post("terminal.history_limit", "5000")).status).toBe(200);
expect((await post("terminal.history_limit", "1999")).status).toBe(400);
const refused = await post("slack.enabled", "true");
expect(refused.status).toBe(400);
expect((await refused.json()).error).toContain("Unknown config key");
const browser = await (await app.request("/api/config?view=browser")).json();
expect(browser.entries.find((e: { key: string }) => e.key === "terminal.history_limit")).toMatchObject({ writeRoute: true, range: { min: 2000, max: 1_000_000 } });
expect(browser.entries.find((e: { key: string }) => e.key === "slack.enabled")).toMatchObject({ writeRoute: false });
expect(browser.readOnly).toBe(true);
});
it("marks timing only where the application guidance says a restart applies it, and says unknown otherwise", () => {
for (const e of browse().entries) {
const guidanceSaysRestart = /\bdaemon (?:restart|startup)\b/i.test(e.application);
expect(e.appliesAt, e.key).toBe(guidanceSaysRestart ? "daemon_restart" : "unknown");
}
});
});
@@ -625,6 +625,19 @@ describe("SettingsStore (User Settings v0)", () => {
}
});
it("a non-string provider.selected in config.json falls back to the default with a warning", () => {
writeFileSync(configPath, JSON.stringify({ provider: { selected: ["claude"] } }));
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
try {
const store = new SettingsStore(configPath);
expect(store.resolveOne("provider.selected")).toMatchObject({ value: "", source: "default" });
const warns = stderrSpy.mock.calls.map((c) => String(c[0]));
expect(warns.some((w) => w.includes("file value for provider.selected rejected"))).toBe(true);
} finally {
stderrSpy.mockRestore();
}
});
it("a quoted in-range port in config.json keeps its port, and the CLI target and daemon bind agree", () => {
writeFileSync(configPath, JSON.stringify({ daemon: { port: "7434" } }));
const store = new SettingsStore(configPath);