mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-12 16:23:00 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80f923a2a0 |
No files matched your search
@@ -423,7 +423,7 @@ Each startup file and action specifies when it applies:
|
||||
- a launch replaces it, as launches always did, and names it (`guidance_block_refreshed_unrecorded`);
|
||||
- an exact resume or a handover leaves it, and names it as possibly stale (`guidance_block_may_be_stale`).
|
||||
|
||||
**A file keeps one copy of each distinct block text, and nothing you wrote is removed.**
|
||||
**A file keeps one copy of each distinct block text, and tidying the copies never removes a text you wrote.**
|
||||
- **Touched markers:** a marker an editor or formatter rewrapped, with spaces or line breaks inside the comment, still reads as its block, and the next write puts it back in OpenRig's form.
|
||||
- **Collected copies:** a copy that repeats an earlier one exactly is removed. Beside a copy you edited, OpenRig's own unedited copies are removed too, and every different edited copy stays (`guidance_block_copies_removed`).
|
||||
- **A marker that lost its pair:** OpenRig can't tell where that block starts or ends, so it adds no copy beside it and names it (`guidance_block_markers_damaged`). Put back the missing marker or remove the partial block, and OpenRig's next write treats that block like any other. `rig package install` leaves such a file as it is, names it in its result (`notes`), and installs the rest. A marker without its pair beside a complete block is named too (`guidance_block_marker_unpaired`), and the block is written.
|
||||
|
||||
@@ -180,18 +180,7 @@ 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, 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.
|
||||
- **Messages are items:** a message is an item the receiver reads and closes, and a reply is an item back to its sender.
|
||||
- **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`).
|
||||
|
||||
@@ -143,7 +143,8 @@ export function downCommand(depsOverride?: DownDeps): Command {
|
||||
const cmd = new Command("down").description("Tear down a rig")
|
||||
.addHelpText("after", `
|
||||
rig down stops every agent OpenRig started in the rig: their sessions end, and any work in progress stops
|
||||
with them. A session OpenRig didn't start (adopted, bound or discovered) is left running, and rig down names it.
|
||||
with them. For a community harness only its pane stops: its own processes may keep running, and rig down
|
||||
names it. A session OpenRig didn't start (adopted, bound or discovered) is left running, and rig down names it.
|
||||
Before running it, check who is working (rig ps --nodes --rig <rig>) and that the person asked for it;
|
||||
don't stop a team you didn't start unless asked. OpenRig snapshots the rig first (best-effort), and
|
||||
rig up <rig> --existing brings it back, resuming each seat's conversation where its harness allows. With
|
||||
|
||||
@@ -72,8 +72,6 @@ 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
|
||||
@@ -233,7 +231,6 @@ const ALLOWED_NODE_FIELDS = new Set([
|
||||
"lifecycleState",
|
||||
"tmuxAttachCommand",
|
||||
"resumeCommand",
|
||||
"resumeCommandNote",
|
||||
"latestError",
|
||||
"terminalActive",
|
||||
"hasAssignedWork",
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
//
|
||||
// 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). An active answer's
|
||||
// fair-use throttle holds back only that caller's new work (rig_queue_create), never the request.
|
||||
// refused, and an external caller reaches only what its grant names (externalScope in mcp-server.ts).
|
||||
//
|
||||
// The endpoint serves both protocol revisions: 2026-07-28 per request, and 2025's initialize and exchanges statelessly.
|
||||
import http from "node:http";
|
||||
@@ -232,18 +231,6 @@ 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> {
|
||||
@@ -287,16 +274,11 @@ 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 } : {}),
|
||||
...(typeof throttle === "number" ? { throttle: { retryAfterSeconds: throttle } } : {}) } },
|
||||
external: { objects, grant: { id: grant.id, ...version }, ...(personId ? { person: personId } : {}) } },
|
||||
principal: { source: "grant-service", id: principal.id, grant: grant.id, ...version } };
|
||||
}
|
||||
|
||||
|
||||
@@ -432,17 +432,6 @@ 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> {
|
||||
@@ -518,10 +507,8 @@ 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, `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 } };
|
||||
* 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 };
|
||||
};
|
||||
|
||||
/** The tool each external object/action pair offers. A pair not listed here offers nothing. */
|
||||
@@ -651,7 +638,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 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.",
|
||||
"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.",
|
||||
].join("\n");
|
||||
|
||||
function instructionsFor(access: McpAccess | undefined, frontDoor = false): string {
|
||||
@@ -1415,19 +1402,6 @@ 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,94 +1045,6 @@ 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,8 +50,6 @@ 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 };
|
||||
|
||||
@@ -67,7 +65,6 @@ 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", () => {
|
||||
@@ -75,8 +72,7 @@ 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), ...(throttled.has(who.key) ? { throttle: { new_work: true, retry_after_seconds: 20 } } : {}) };
|
||||
: objects === "revoked" ? { schema: SCHEMA, status: "revoked" } : active(who, objects);
|
||||
res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(body));
|
||||
});
|
||||
});
|
||||
@@ -117,7 +113,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(), instructions: () => client.getInstructions() });
|
||||
return Object.assign(call, { tools: async () => (await client.listTools()).tools.map((tool) => tool.name).sort() });
|
||||
}
|
||||
|
||||
const rows = () => (db.prepare("SELECT COUNT(*) AS n FROM queue_items").get() as { n: number }).n;
|
||||
@@ -178,42 +174,6 @@ 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,8 +2,6 @@ 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
|
||||
@@ -54,8 +52,6 @@ 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") {
|
||||
@@ -64,19 +60,11 @@ export function buildNativeResumeCommand(
|
||||
return `claude ${claudePostureFlag(process.env)} --resume ${shellQuote(resumeToken)}${nameSuffix}${mcpSuffix}`;
|
||||
}
|
||||
if (runtime === "codex") {
|
||||
return buildCodexResumeCore(resumeToken, codexConfigProfile, undefined, undefined, undefined, undefined,
|
||||
codexSeatPostureArg(codexConfigProfile, codexPermissions), codexDaemonOptOut === true);
|
||||
return buildCodexResumeCore(resumeToken, codexConfigProfile, undefined, undefined, undefined, undefined, undefined, 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. */
|
||||
|
||||
@@ -24,8 +24,7 @@ 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, codexSeatPostureArg } from "./native-resume-probe.js";
|
||||
import { validateCodexMemberPermissions, type CodexMemberPermissions } from "./codex-member-permissions.js";
|
||||
import { buildNativeResumeCommand, buildCodexResumeCore } from "./native-resume-probe.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";
|
||||
@@ -115,38 +114,13 @@ interface BindingRow {
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -156,10 +130,8 @@ 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, codexChoice } = input;
|
||||
const { runtime, resumeToken, cwd, sessionName, codexConfigProfile, codexDaemonOptOut, claudeMcp } = input;
|
||||
|
||||
if (runtime === "claude-code") {
|
||||
const commands: string[] = [];
|
||||
@@ -193,20 +165,14 @@ 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, seatPosture, daemonOptOut));
|
||||
commands.push(buildCodexResumeCore(resumeToken, codexConfigProfile, undefined, undefined, undefined, undefined, undefined, daemonOptOut));
|
||||
}
|
||||
if (cwd) {
|
||||
commands.push(`cd ${cwd}`);
|
||||
}
|
||||
if (!resumeToken) {
|
||||
commands.push(lastCommand);
|
||||
}
|
||||
if (codexChoice && "unreadable" in codexChoice) {
|
||||
notes.push(CODEX_PERMISSIONS_NOT_INCLUDED);
|
||||
commands.push(buildCodexResumeCore("", codexConfigProfile, true, undefined, undefined, undefined, undefined, daemonOptOut));
|
||||
}
|
||||
|
||||
notes.push("Use workspace and recent prompt text to identify the right conversation.");
|
||||
@@ -220,11 +186,8 @@ 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: seatPosture
|
||||
? (resumeToken ? `Try native Codex resume first; ${ownChoice}` : `No stored Codex resume token. Try ${lastCommand}; ${ownChoice}`)
|
||||
: resumeToken
|
||||
summary: 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,
|
||||
@@ -649,12 +612,11 @@ 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, codexSeatChoices }),
|
||||
buildInventoryEntry(db, row, { usableSnapshot, workspaceSpec, identityVerdicts, restoreOutcomes, orienteds, codexDaemonOptOuts }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -769,8 +731,6 @@ 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>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -783,8 +743,7 @@ function buildInventoryEntry(
|
||||
row: InventoryRow,
|
||||
ctx: InventoryBuildContext,
|
||||
): NodeInventoryEntry {
|
||||
const { usableSnapshot, workspaceSpec, identityVerdicts, restoreOutcomes, orienteds, codexDaemonOptOuts, codexSeatChoices } = ctx;
|
||||
const codexChoice = row.runtime === "codex" ? codexSeatChoices.get(row.node_id) : undefined;
|
||||
const { usableSnapshot, workspaceSpec, identityVerdicts, restoreOutcomes, orienteds, codexDaemonOptOuts } = ctx;
|
||||
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`);
|
||||
@@ -864,8 +823,7 @@ 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, codexChoice),
|
||||
...(row.resume_token && codexChoice && "unreadable" in codexChoice ? { resumeCommandNote: CODEX_PERMISSIONS_NOT_INCLUDED } : {}),
|
||||
resumeCommand: computeResumeCommand(row.runtime, row.resume_token, row.codex_config_profile, codexDaemonOptOuts.get(row.node_id), row.claude_mcp),
|
||||
// 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
|
||||
@@ -963,7 +921,6 @@ 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, {
|
||||
@@ -973,7 +930,6 @@ 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); }
|
||||
@@ -1181,7 +1137,6 @@ 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,
|
||||
|
||||
@@ -814,9 +814,6 @@ 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, describeSettingKey, type SettingKeyDescription, type SettingsStore, type ResolvedSetting } from "./settings-store.js";
|
||||
import { SETTINGS_VALID_KEYS, 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,13 +13,7 @@ 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";
|
||||
/** 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 {
|
||||
export interface BrowserEntry {
|
||||
key: string;
|
||||
group: "general" | "slack" | "people" | "hosts" | "health";
|
||||
value: Value;
|
||||
@@ -96,8 +90,6 @@ 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.";
|
||||
@@ -127,8 +119,7 @@ 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,
|
||||
...describeSettingKey(key), appliesAt: DAEMON_RESTART_KEYS.has(key) ? "daemon_restart" : "unknown" });
|
||||
reason: v.reason ?? d.reason, scope, application: applied });
|
||||
}
|
||||
const file = sourceFile(store.configPath);
|
||||
let generalState = file.state;
|
||||
|
||||
@@ -842,20 +842,6 @@ 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) {
|
||||
@@ -864,6 +850,12 @@ 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())
|
||||
@@ -876,10 +868,15 @@ function percentageConstraint(key: string) {
|
||||
};
|
||||
}
|
||||
|
||||
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"),
|
||||
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");
|
||||
},
|
||||
"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).
|
||||
@@ -887,8 +884,9 @@ const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, KeyConstraint>> = {
|
||||
throw new Error("Invalid daemon.seat_poll_interval_ms: must be a whole number of milliseconds in [1000, 2000]");
|
||||
}
|
||||
},
|
||||
"transport.draft_delivery": choiceConstraint(["refuse", "deliver"],
|
||||
(raw) => `Invalid value for transport.draft_delivery: must be "refuse" or "deliver", got "${raw}"`),
|
||||
"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}"`);
|
||||
},
|
||||
"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]");
|
||||
@@ -930,34 +928,37 @@ const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, KeyConstraint>> = {
|
||||
throw new Error(`Invalid value for daemon.port: must be an integer in [0, 65535], got "${raw}"`);
|
||||
}
|
||||
},
|
||||
"ui.timezone": described({ format: "iana-timezone" }, (_raw, value) => {
|
||||
"ui.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": choiceConstraint(["off", "all"],
|
||||
(raw) => `Invalid value for policies.idle_gate_qitem.auto_register: must be "off" or "all", got "${raw}"`, true),
|
||||
"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}"`);
|
||||
}
|
||||
},
|
||||
// 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.
|
||||
// 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)) {
|
||||
"provider.selected": (_raw, value) => {
|
||||
if (typeof value !== "string" || !/^(|claude|codex|claude,codex|codex,claude)$/.test(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": described({ format: "absolute-path-or-empty" }, (_raw, value) => {
|
||||
},
|
||||
"policies.claude_compaction.preparation_root": (_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)) {
|
||||
@@ -1048,12 +1049,12 @@ const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, KeyConstraint>> = {
|
||||
}
|
||||
},
|
||||
// Empty (the default) keeps no erasure ledger; otherwise an absolute file path, kept outside the backed-up data.
|
||||
"retention.erasure_ledger": described({ format: "absolute-path-or-empty" }, (raw) => {
|
||||
"retention.erasure_ledger": (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}"`);
|
||||
@@ -1069,12 +1070,12 @@ const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, KeyConstraint>> = {
|
||||
throw new Error(`Invalid value for retention.stream_max_items: must be an integer >= 1, got "${raw}"`);
|
||||
}
|
||||
},
|
||||
"stream.intake": described({ format: "queue.worker, off, or member@rig" }, (raw) => {
|
||||
"stream.intake": (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}"`);
|
||||
@@ -1088,19 +1089,18 @@ const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, KeyConstraint>> = {
|
||||
"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": 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) => {
|
||||
"queue.wake_human_rung": wakeHumanRungConstraint,
|
||||
"decisions.template_dir": (_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": described({ format: "member@rig-or-empty" }, (_raw, value) => {
|
||||
"acp.to": (_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,58 +1126,6 @@ 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
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
// 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.`);
|
||||
});
|
||||
});
|
||||
@@ -1,166 +0,0 @@
|
||||
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,19 +625,6 @@ 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);
|
||||
|
||||
Reference in new issue
Block a user