Compare commits

..
Author SHA1 Message Date
Edward Bowie 27d5d6d206 fix(scripts): read npm pack --json in both npm 10 and npm 12 shapes (#2953) (#2975)
Under npm 12, `npm pack --json` prints an object keyed by package name, so six scripts and tests that read `[0]` failed, including the release pack and the substance gate. A shared reader, `scripts/npm-pack-json.mjs`, accepts both npm 10/11's array and npm 12's object, and all six readers use it; npm 10 and 11 output reads as before. The substance gate binds one package's receipt, so it now refuses pack output that lists more than one package, naming --package-root and any npm workspaces setting, instead of certifying the first entry only. For #2953.
2026-11-11 19:19:48 -08:00
Mike Schwarz aa71304a7d fix(tui): show a seat the daemon reads as unknown as unknown, not idle (#3012)
When the daemon couldn't tell what a running seat was doing, rig ps said unknown, but the TUI's list, explorer and agent-detail status rows said idle unless the seat was a community harness, so a seat thinking quietly could look free to take more work. Those rows now show unknown when the daemon serves an explicit unknown. The daemon's idle and working words keep their mapping, and screen motion still shows active. When no activityState is served, the existing screen fallback remains; this change covers an explicit unknown display. The topology graph view is unchanged.
2026-11-11 19:11:45 -08:00
Coder8124 492170b1b5 fix(daemon): give an unreadable Claude statusline cache its own reason (#2880)
`rig provider status` gave a Claude statusline cache that couldn't be parsed the same unknown reason as a valid cache with no rate-limit windows, so a usage check couldn't tell a broken cache from one with nothing reported yet. An unreadable cache now reports `claude_statusline_cache_unreadable`, and a readable cache with no windows keeps `claude_statusline_cache_had_no_windows`; both stay unknown rows with no percentage. Discovery also keeps a seat whose only cache file is malformed, naming it from the file name, so that seat reads unreadable instead of no cache yet. For #2875.
2026-11-11 18:51:26 -08:00
28 changed files with 327 additions and 1277 deletions

No files matched your search

+3 -1
View File
@@ -13,7 +13,9 @@ provider usage stop updating, and Claude resume-token capture at adoption or
handover loses its current session evidence. An earlier sidecar can remain and
contain an older session ID; without one, context and capture report
`missing_sidecar`, and provider usage reports `no_statusline_cache_yet` unless an
earlier cache remains.
earlier cache remains. A cache that exists but cannot be read reports
`claude_statusline_cache_unreadable`; a readable cache that carried no rate
limits reports `claude_statusline_cache_had_no_windows`.
## Feed both commands the same input
+5 -70
View File
@@ -8,67 +8,7 @@ with one grant per participant. The network connector puts one selected local se
The connector opens no port: your daemon makes every connection outward, and the hub never connects to your machine. Work the hub
sends that participant runs on your seat, under your accounts, and the result goes back to the hub item.
## Enroll in an account, then connect by name
When your hub is an account's network, enroll this daemon once with the one-time code its page shows, then connect
seats by name:
```bash
rig network enroll <code> --site <site URL>
rig network connect <seat> --as <name>
```
- `enroll` trades the code, with this daemon's host id, for an installation key. The key is stored like a participant's
token, under `<OpenRig home>/network-secrets/<installation>/key.json`, and is never returned or shown. A code is
single-use and short-lived; a refused one says why, so get a new one from the page. If the account doesn't answer,
whether it took the code is unknown: try again with the same code, and get a new one if that's refused.
- Enrolling trusts the site with where your connected seats' work goes: for each name, the account answers the front
and rig the binding reaches.
- The installation key goes only to the site you enrolled at: a redirect from it isn't followed. Each `enroll`,
`connect` or `disconnect` spends at most 30 seconds with the account in all, and the command waits 45 seconds for the
daemon, so you see the account's own answer or error.
- The account keeps one list of the installation's names, and every change sends the whole list. Changes to it run one
at a time on this daemon, each reading the names to keep in its turn, so two connects started together both stay
connected.
- `connect --as <name>` lists the name with the account, which answers that participant's bearer, its MCP front and its
rig. The binding then reaches that front as `<name>@<rig>`, the same as a direct connect. A name is lowercase letters,
digits and inner dashes, up to 32. One that another connection to the account uses is refused.
- If the answer to a connect is lost, the next connect drops the name and lists it again, which gets a fresh bearer and
ends the old one.
- If the account listed the name but the binding can't be made here, OpenRig asks the account to drop the name again.
That's best effort: if it fails too, the error says the account may still list the name.
- `disconnect` ends the binding here, then has the account stop listing the name, which revokes it there. Once the
account's answer no longer lists the name, `disconnect` says the account stopped listing it. If the account can't be
reached or its answer is lost, the binding is still ended, the error says OpenRig couldn't confirm the account dropped
the name and names the `rig network disconnect <binding id>` to run again, and `rig network status` keeps showing it
as unconfirmed. A later answer from the same account installation that no longer lists the name marks it dropped.
- `rig network status` shows the enrollment above the bindings.
### Enroll again after a lost key or a removed installation
When the installation key is missing, or the account no longer recognizes the installation, the error says to enroll
again with a new code from the account page:
```bash
rig network enroll <code> --site <site URL> --replace
```
Without `--replace`, a second enroll at the same site is refused and the code isn't spent. With it, the replace first
waits for any other change to this enrollment to finish, within the same 30 seconds; one that runs out of time while
waiting says so and doesn't send the code. The account makes a new installation, with a new key, for the new code. Here,
that key takes the old one's place and the bindings made through the old installation end, in one step:
- If that step fails after the account accepted the code, nothing here changes, and the error says so. The old
enrollment keeps working only while the account keeps the old installation, and the account now has a new
installation whose key nothing here holds: remove it on the account page, then enroll again with a new code.
- Once the step is done, `enroll` names the bindings it ended. The old installation may still list their names, and the
new key can't drop them, so remove them on the account page. `rig network status` marks each of them as possibly still
listed under the old installation, as it does a binding that ended earlier without the account confirming the drop:
only the installation a name was listed under can confirm it.
- If cleanup after the step doesn't finish (removing the old key's backup, or an ended binding's credential), `enroll`
reports it, and the replace still stands.
## Connect with a hub URL and a token
## Connect
```bash
rig network connect <seat> --hub <url> --as <participant> --token-file <path>
@@ -129,9 +69,8 @@ rig queue update <local item> --state done --closure-reason no-follow-on --resol
rig network status [--json]
```
Each binding shows its seat, participant, hub, state, and its last contact or last error. An ended binding made through
the account also says when the account hasn't confirmed it dropped the name, or when the name may still be listed under
a replaced installation. `--json` adds the connector's view: each binding's next pass, backoff and last pass.
Each binding shows its seat, participant, hub, state, and its last contact or last error. `--json` adds the
connector's view: each binding's next pass, backoff and last pass.
## When things go wrong
@@ -149,8 +88,7 @@ rig network disconnect <binding-or-seat> [--reason "<why>"]
```
The binding ends here, so no new work comes in through it, and its credential is removed. Local items and results
stay. A binding made through the account is then dropped there too (see above). For a binding made with a hub URL and
a token, the hub still lists the participant until it's removed there too.
stay. The hub still lists the participant until it's removed there too.
If the credential can't be removed, the binding still ends, and the error names the
`rig network disconnect <binding id>` to run again. The daemon also removes credentials whose binding isn't active
@@ -166,14 +104,11 @@ each time it starts.
An atomic close-if-open needs the hub to offer a conditional close.
- After a disconnect or a revocation, work already taken keeps running here, but its pickup and result no longer reach
the hub item, which stays at its last reported stage.
- If the daemon stops between storing a new installation key and recording the enrollment, that key's folder under
`network-secrets/` stays with no enrollment, and nothing removes it. It's private to you and unused, so remove it by
hand.
- **Plain http needs your choice.** `rig network connect` refuses an `http:` hub URL until you add `--allow-http`, because
the participant's token and its work then cross the network unencrypted, where they can be read. Use the hub's
`https:` URL where it has one. The choice is recorded on that binding, so the connector doesn't ask again, and
`rig network status` (and its `--json`) marks the binding unencrypted. OpenRig doesn't judge the address: a private or
tailnet address doesn't prove the connection is protected. `rig network enroll` asks the same for a plain-http site.
tailnet address doesn't prove the connection is protected.
- **Redirects:** the connector follows a hub's redirect only within its own origin, or to its `https:` form on the same
host. Any other redirect stops the connection with its target named, and the token isn't sent there. Connect the
binding to the hub's own URL.
+15 -74
View File
@@ -15,25 +15,8 @@ interface Binding {
lastContactAt: string | null; lastError: string | null; unencrypted?: boolean;
/** Hub items someone else closed before this binding's result reached them. */
closedElsewhere?: Array<{ hubItem: string; localItem: string }>;
participantName?: string | null;
/** For an ended binding made through the account: whether the account confirmed it stopped listing the name. */
accountRemoval?: "confirmed" | "unconfirmed" | "old-installation" | null;
}
/** The network writes can wait on the account, which the daemon bounds at 30 s in all; the CLI waits past that so the
* account's own answer is shown rather than a daemon timeout. */
const ACCOUNT_WRITE_TIMEOUT_MS = 45_000;
const accountNote = (b: Binding): string => {
if (b.accountRemoval === "unconfirmed") return `\n couldn't confirm the account dropped ${b.participantName}: run rig network disconnect ${b.id} again`;
if (b.accountRemoval === "old-installation") return `\n ${b.participantName} may still be listed under the old installation: remove it on the account page`;
return "";
};
interface Installation { id: string; site: string; installationId: string; name: string | null }
const enrolled = (i: Installation) => `Enrolled at ${i.site} as installation ${i.name ?? i.installationId} (${i.id})`;
/** The participant's token, read from the file the account gave the person. A file others can read is used, with a note. */
function defaultReadTokenFile(path: string): string {
const stat = lstatSync(path);
@@ -47,7 +30,7 @@ function line(b: Binding): string {
const state = b.state === "active" ? contact : `${b.state}${b.stateReason ? ` (${b.stateReason})` : ""}`;
const plain = b.unencrypted ? " UNENCRYPTED (http, allowed for this binding)" : "";
const conflicts = (b.closedElsewhere ?? []).map((c) => `\n hub item ${c.hubItem} was closed elsewhere before its result arrived; the result stays on ${c.localItem}`).join("");
return `${b.id} ${b.localSession} -> ${b.hubSeat} at ${b.hubUrl}${plain} ${state}${b.lastError && b.state === "active" ? ` last error: ${b.lastError}` : ""}${conflicts}${accountNote(b)}`;
return `${b.id} ${b.localSession} -> ${b.hubSeat} at ${b.hubUrl}${plain} ${state}${b.lastError && b.state === "active" ? ` last error: ${b.lastError}` : ""}${conflicts}`;
}
/** `rig network`: connect a selected local seat to a participant of a remote hub (an account's network rig, or
@@ -73,60 +56,25 @@ export function networkCommand(depsOverride?: NetworkDeps): Command {
console.log(json ? JSON.stringify(data, null, 2) : ok(data as Record<string, unknown>));
};
cmd.command("enroll")
.description("Enroll this daemon in an account with the one-time code its page shows, so seats connect by name")
.argument("<code>", "The one-time code from the account page")
.requiredOption("--site <url>", "The account's site")
.option("--allow-http", "Accept a plain-http site: the installation key and tokens cross the network unencrypted")
.option("--replace", "Replace this daemon's enrollment at the site, once the account accepts the new code (after a lost key or a removed installation)")
.option("--json", "Print JSON")
.action(async (code: string, opts: { site: string; allowHttp?: boolean; replace?: boolean; json?: boolean }) => {
const daemon = await client();
if (!daemon) return;
const res = await daemon.post<Record<string, unknown>>("/api/network/installations", { code, site: opts.site, allowHttp: opts.allowHttp === true, replace: opts.replace === true },
{ headers: operatorAuthHeaders(), timeoutMs: ACCOUNT_WRITE_TIMEOUT_MS });
finish(res.status, res.data, opts.json, (i) => {
const replaced = (i as { replaced?: { endedBindings?: Array<{ id: string; localSession: string; participantName: string | null }>; problems?: string[] } }).replaced;
const ended = replaced?.endedBindings ?? [];
const lines = [enrolled(i as unknown as Installation)];
if (ended.length) {
lines.push(`Ended ${ended.length} binding${ended.length === 1 ? "" : "s"} made through the old installation; their names may still be listed under it, so remove them on the account page:`);
for (const b of ended) lines.push(` ${b.id} ${b.localSession} as ${b.participantName}`);
}
for (const problem of replaced?.problems ?? []) lines.push(`Replaced, with cleanup left over: ${problem}`);
lines.push("Connect a seat with rig network connect <seat> --as <name>.");
return lines.join("\n");
});
});
cmd.command("connect")
.description("Connect a local seat to a hub participant: by name through the enrolled account, or with a hub URL and token file")
.description("Connect a local seat to a hub participant, using that participant's token")
.argument("<seat>", "The local seat to connect (its address, member@rig)")
.requiredOption("--as <participant>", "Through the account: the name to connect as. With --hub: the participant's address in the hub (member@rig)")
.option("--hub <url>", "The hub's MCP URL, to connect without the account (with --token-file)")
.option("--token-file <path>", "A file holding the participant's token (with --hub)")
.requiredOption("--hub <url>", "The hub's MCP URL")
.requiredOption("--as <participant>", "The participant's address in the hub (member@rig)")
.requiredOption("--token-file <path>", "A file holding the participant's token")
.option("--allow-http", "Accept a plain-http hub for this binding: its token and work cross the network unencrypted")
.option("--json", "Print JSON")
.action(async (seat: string, opts: { hub?: string; as: string; tokenFile?: string; allowHttp?: boolean; json?: boolean }) => {
if (!opts.hub !== !opts.tokenFile) {
console.error("Give both --hub and --token-file, or neither to connect through the enrolled account.");
.action(async (seat: string, opts: { hub: string; as: string; tokenFile: string; allowHttp?: boolean; json?: boolean }) => {
let token: string;
try { token = (getDeps().readTokenFile ?? defaultReadTokenFile)(opts.tokenFile); } catch (error) {
console.error(`Can't read the token file: ${(error as Error).message}`);
process.exitCode = 1;
return;
}
let body: Record<string, unknown> = { session: seat, participantName: opts.as };
if (opts.hub && opts.tokenFile) {
let token: string;
try { token = (getDeps().readTokenFile ?? defaultReadTokenFile)(opts.tokenFile); } catch (error) {
console.error(`Can't read the token file: ${(error as Error).message}`);
process.exitCode = 1;
return;
}
body = { session: seat, hubUrl: opts.hub, hubSeat: opts.as, token, allowHttp: opts.allowHttp === true };
}
const daemon = await client();
if (!daemon) return;
const res = await daemon.post<Record<string, unknown>>("/api/network/bindings", body, { headers: operatorAuthHeaders(), timeoutMs: ACCOUNT_WRITE_TIMEOUT_MS });
finish(res.status, res.data, opts.json, (b) => `Connected ${line(b as unknown as Binding)}\nWork for ${(b as unknown as Binding).hubSeat} reaches ${seat} once the connector runs; rig network status shows its last contact.`);
const res = await daemon.post<Record<string, unknown>>("/api/network/bindings", { session: seat, hubUrl: opts.hub, hubSeat: opts.as, token, allowHttp: opts.allowHttp === true }, { headers: operatorAuthHeaders() });
finish(res.status, res.data, opts.json, (b) => `Connected ${line(b as unknown as Binding)}\nWork for ${opts.as} reaches ${seat} once the connector runs; rig network status shows its last contact.`);
});
cmd.command("status")
@@ -135,11 +83,10 @@ export function networkCommand(depsOverride?: NetworkDeps): Command {
.action(async (opts: { json?: boolean }) => {
const daemon = await client();
if (!daemon) return;
const res = await daemon.get<{ bindings: Binding[]; installations?: Installation[] }>("/api/network/bindings");
const res = await daemon.get<{ bindings: Binding[] }>("/api/network/bindings");
finish(res.status, res.data, opts.json, (d) => {
const bindings = (d.bindings ?? []) as Binding[];
const installations = (d.installations ?? []) as Installation[];
return [...installations.map(enrolled), ...(bindings.length ? bindings.map(line) : ["No seats are connected to a hub."])].join("\n");
return bindings.length ? bindings.map(line).join("\n") : "No seats are connected to a hub.";
});
});
@@ -151,14 +98,8 @@ export function networkCommand(depsOverride?: NetworkDeps): Command {
.action(async (ref: string, opts: { reason?: string; json?: boolean }) => {
const daemon = await client();
if (!daemon) return;
const res = await daemon.post<Record<string, unknown>>(`/api/network/bindings/${encodeURIComponent(ref)}/disconnect`, { reason: opts.reason },
{ headers: operatorAuthHeaders(), timeoutMs: ACCOUNT_WRITE_TIMEOUT_MS });
finish(res.status, res.data, opts.json, (raw) => {
const b = raw as unknown as Binding;
if (b.accountRemoval === "confirmed") return `Disconnected ${line(b)}\nThe account stopped listing ${b.participantName}, which revokes it there.`;
if (b.accountRemoval) return `Disconnected ${line(b)}`;
return `Disconnected ${line(b)}\nRemove the participant on the hub too, so the account stops listing it.`;
});
const res = await daemon.post<Record<string, unknown>>(`/api/network/bindings/${encodeURIComponent(ref)}/disconnect`, { reason: opts.reason }, { headers: operatorAuthHeaders() });
finish(res.status, res.data, opts.json, (b) => `Disconnected ${line(b as unknown as Binding)}\nRemove the participant on the hub too, so the account stops listing it.`);
});
return cmd;
+3 -52
View File
@@ -17,22 +17,10 @@ let db: Database.Database;
let home: string;
let app: Hono;
let rigs: RigRepository;
/** Each write the CLI sent, with how long it would wait for the daemon. */
const posts: Array<{ url: string; timeoutMs?: number }> = [];
/** An account that answers the connector routes: a key for the one-time code, a bearer for each newly listed name. */
const listed = new Set<string>();
const accountFetch = async (url: string, init: { method: string; body: string }) => {
const path = new URL(url).pathname;
const body = JSON.parse(init.body) as { code?: string; participants?: string[] };
const reply = (status: number, json: unknown) => ({ status, json: async () => json });
if (path === "/api/connector/enroll") return body.code === "ec1.page" ? reply(200, { installation: { id: "inst-1", name: "lab" }, key: "oik1_c_inst-1_key" }) : reply(400, { error: "code_not_current" });
const participants = (body.participants ?? []).map((name) => (listed.has(name) ? { name, state: "connected" } : (listed.add(name), { name, state: "connected", bearer: `bear-${name}` })));
return reply(200, { participants, mcp: "https://account.example/mcp", rig: "acct" });
};
const routesWith = (bearerToken: string | null) => {
const next = new Hono();
next.use("*", async (c, n) => { c.set("rigRepo" as never, rigs as never); await n(); });
next.route("/api/network", networkRoutes({ home, bearerToken, accountFetch, selfHostId: () => "host-a" }));
next.route("/api/network", networkRoutes({ home, bearerToken }));
return next;
};
beforeEach(() => {
@@ -43,7 +31,6 @@ beforeEach(() => {
db.prepare("INSERT INTO sessions (id, node_id, session_name, status) VALUES ('s1', ?, 'dev-impl@lab', 'running')").run(node.id);
home = mkdtempSync(join(tmpdir(), "openrig-network-cli-"));
app = routesWith(null);
posts.length = 0;
});
afterEach(() => { db.close(); rmSync(home, { recursive: true, force: true }); vi.restoreAllMocks(); vi.unstubAllEnvs(); process.exitCode = undefined; });
@@ -59,10 +46,7 @@ function cli(tokenFile: Record<string, string> = {}) {
};
const deps: NetworkDeps = {
lifecycleDeps,
clientFactory: () => ({ get: (url: string) => call("GET", url), post: (url: string, body: unknown, options?: { headers?: Record<string, string>; timeoutMs?: number }) => {
posts.push({ url, timeoutMs: options?.timeoutMs });
return call("POST", url, body, options?.headers);
} }) as never,
clientFactory: () => ({ get: (url: string) => call("GET", url), post: (url: string, body: unknown, options?: { headers?: Record<string, string> }) => call("POST", url, body, options?.headers) }) as never,
readTokenFile: (path) => { if (!(path in tokenFile)) throw new Error("no such file"); return tokenFile[path]!; },
};
return new Command().exitOverride().addCommand(networkCommand(deps));
@@ -79,7 +63,7 @@ it("connects a seat with its token file, shows it in status without the token, a
expect(status).not.toContain("tok-abc");
await cli().parseAsync(["node", "rig", "network", "disconnect", "dev-impl@lab", "--reason", "done"]);
expect(String(out.mock.calls.at(-1)?.[0])).toMatch(/^Disconnected netb-[0-9a-f]{20} dev-impl@lab -> alex@team .*disconnected \(done\)\nRemove the participant on the hub too/);
expect(String(out.mock.calls.at(-1)?.[0])).toMatch(/^Disconnected netb-[0-9a-f]{20} dev-impl@lab -> alex@team .*disconnected \(done\)/);
expect(process.exitCode).toBeUndefined();
});
@@ -127,39 +111,6 @@ it("status shows a hub item closed elsewhere under its binding, in text and JSON
expect(JSON.parse(String(out.mock.calls.at(-1)?.[0])).bindings[0].closedElsewhere).toEqual([{ hubItem: "hub-9", localItem: "qitem-net-0001", note: "closed elsewhere (done, canceled)" }]);
});
it("enrolls with the account page's code, connects a seat by name through it, and shows both in status", async () => {
listed.clear();
const out = vi.spyOn(console, "log").mockImplementation(() => {});
const err = vi.spyOn(console, "error").mockImplementation(() => {});
await cli().parseAsync(["node", "rig", "network", "enroll", "ec1.page", "--site", "https://account.example"]);
expect(process.exitCode).toBeUndefined();
expect(String(out.mock.calls.at(-1)?.[0])).toMatch(/^Enrolled at https:\/\/account\.example\/ as installation lab \(neti-[0-9a-f]{20}\)\nConnect a seat with rig network connect <seat> --as <name>\.$/);
await cli().parseAsync(["node", "rig", "network", "connect", "dev-impl@lab", "--hub", "https://hub.example/mcp", "--as", "alex@team"]);
expect(process.exitCode).toBe(1);
expect(String(err.mock.calls.at(-1)?.[0])).toBe("Give both --hub and --token-file, or neither to connect through the enrolled account.");
process.exitCode = undefined;
await cli().parseAsync(["node", "rig", "network", "connect", "dev-impl@lab", "--as", "alpha"]);
expect(process.exitCode).toBeUndefined();
expect(String(out.mock.calls.at(-1)?.[0])).toMatch(/^Connected netb-[0-9a-f]{20} dev-impl@lab -> alpha@acct at https:\/\/account\.example\/mcp no contact yet\nWork for alpha@acct reaches dev-impl@lab/);
await cli().parseAsync(["node", "rig", "network", "status"]);
const status = String(out.mock.calls.at(-1)?.[0]).split("\n");
expect(status[0]).toMatch(/^Enrolled at https:\/\/account\.example\/ as installation lab \(neti-/);
expect(status[1]).toMatch(/^netb-[0-9a-f]{20} dev-impl@lab -> alpha@acct at https:\/\/account\.example\/mcp/);
await cli().parseAsync(["node", "rig", "network", "status", "--json"]);
expect(String(out.mock.calls.at(-1)?.[0])).not.toMatch(/oik1_|bear-alpha/);
await cli().parseAsync(["node", "rig", "network", "disconnect", "dev-impl@lab"]);
expect(process.exitCode).toBeUndefined();
const disconnected = String(out.mock.calls.at(-1)?.[0]);
expect(disconnected).toMatch(/\nThe account stopped listing alpha, which revokes it there\.$/);
expect(disconnected).not.toContain("Remove the participant on the hub");
// Every network write waits past the daemon's 30 s account deadline, so the account's own answer is what's shown.
expect(posts.map((p) => p.url)).toEqual(["/api/network/installations", "/api/network/bindings", expect.stringMatching(/^\/api\/network\/bindings\/.+\/disconnect$/)]);
for (const p of posts) expect(p.timeoutMs).toBeGreaterThanOrEqual(45_000);
});
it("sends the operator bearer on its writes, so a daemon with one configured takes them", async () => {
app = routesWith("op-secret");
const out = vi.spyOn(console, "log").mockImplementation(() => {});
@@ -157,46 +157,3 @@ it("takes the participant's item over the wire, carries pickup and the result ba
expect(await pass()).toMatchObject({ error: "revoked" });
expect(bindings.get(binding.id)!.state).toBe("revoked");
});
it("a lead's aggregate over its own local child work comes back once on the original hub request, and an open parent publishes nothing early", async () => {
const token = await addGrant(tokenFile, { name: "alex", seat: "alex@team", rigs: ["team"], roots: [], workspace: false, owner: false });
const task = await hubRepo.create({ sourceSession: "lead@team", destinationSession: "alex@team", summary: "Ship the fix", body: "Fix the bug and have it reviewed.", nudge: false } as never);
const localRepo = queue(localDb);
const bindings = new NetworkBindingStore(localDb, localHome);
const binding = bindings.create({ session: "dev-impl@lab", hubUrl, hubSeat: "alex@team", token, allowHttp: true });
const pass = () => runNetworkBindingPass({ db: localDb, queueRepo: localRepo, bindings, home: localHome }, bindings.get(binding.id)!);
const hubItem = () => hubRepo.getById(task.qitemId)!;
expect(await pass()).toMatchObject({ taken: 1 });
const [parent] = localRepo.list({ limit: 50 });
localRepo.claim({ qitemId: parent!.qitemId, destinationSession: "dev-impl@lab" });
// The lead delegates through ordinary local rows and waits on each in turn.
const build = await localRepo.create({ sourceSession: "dev-impl@lab", destinationSession: "builder@lab", summary: "Build the fix", body: "Fix the bug.", nudge: false } as never);
const review = await localRepo.create({ sourceSession: "dev-impl@lab", destinationSession: "reviewer@lab", summary: "Review the fix", body: "Review the build.", nudge: false } as never);
await localRepo.update({ qitemId: parent!.qitemId, actorSession: "dev-impl@lab", state: "blocked", blockedOn: build.qitemId, transitionNote: "Waiting on the build." });
expect(await pass()).toMatchObject({ pickupsSent: 1, resultsSent: 0 });
expect(hubItem()).toMatchObject({ state: "in-progress", resolution: null });
localRepo.claim({ qitemId: build.qitemId, destinationSession: "builder@lab" });
await localRepo.update({ qitemId: build.qitemId, actorSession: "builder@lab", state: "done", closureReason: "no-follow-on", resolution: "Built: the fix is in commit abc123." });
await localRepo.update({ qitemId: parent!.qitemId, actorSession: "dev-impl@lab", state: "blocked", blockedOn: review.qitemId, transitionNote: "Waiting on the review." });
// A finished child isn't a result: the parent is still open, so nothing final goes to the hub.
expect(await pass()).toMatchObject({ resultsSent: 0 });
expect(hubItem()).toMatchObject({ state: "in-progress", resolution: null });
localRepo.claim({ qitemId: review.qitemId, destinationSession: "reviewer@lab" });
await localRepo.update({ qitemId: review.qitemId, actorSession: "reviewer@lab", state: "done", closureReason: "no-follow-on", resolution: "Reviewed: approve." });
const children = [localRepo.getById(build.qitemId)!, localRepo.getById(review.qitemId)!];
const aggregate = children.map((child) => child.resolution).join(" ");
await localRepo.update({ qitemId: parent!.qitemId, actorSession: "dev-impl@lab", state: "done", closureReason: "no-follow-on", resolution: aggregate });
expect(await pass()).toMatchObject({ resultsSent: 1 });
expect(hubItem()).toMatchObject({ state: "done", closureReason: "no-follow-on", resolution: "Built: the fix is in commit abc123. Reviewed: approve." });
// Once: a further pass writes nothing, and the children's own words never reached the hub as notes.
const transitions = hubRepo.listTransitions(task.qitemId);
await pass();
expect(hubRepo.listTransitions(task.qitemId)).toHaveLength(transitions.length);
expect(transitions.filter((t) => t.state === "done")).toHaveLength(1);
expect(transitions.map((t) => t.transitionNote ?? "").join("\n")).not.toMatch(/Waiting on|Built:.*\n.*Reviewed:/);
});
-2
View File
@@ -10,7 +10,6 @@ import { queueItemsHandedOffFromIndexSchema } from "./migrations/159_queue_items
import { networkBindingsSchema } from "./migrations/160_network_bindings.js";
import { networkReceiptsSchema } from "./migrations/161_network_receipts.js";
import { sessionAddressReadIndexesSchema } from "./migrations/162_session_address_read_indexes.js";
import { networkInstallationsSchema } from "./migrations/163_network_installations.js";
import { activityGrantsSchema } from "./migrations/143_activity_grants.js";
import { environmentBindingsSchema } from "./migrations/144_environment_bindings.js";
import { nodePermissionsToSchema } from "./migrations/145_node_permissions_to.js";
@@ -328,5 +327,4 @@ export const ALL_MIGRATIONS: Migration[] = [
networkBindingsSchema,
networkReceiptsSchema,
sessionAddressReadIndexesSchema,
networkInstallationsSchema,
];
@@ -1,24 +0,0 @@
import type { Migration } from "../migrate.js";
/** This daemon enrolled in an account: the account's site, its installation id, and whether the person chose plain http
* for it. The installation key lives in a private file under the OpenRig home, never in this table. A binding made
* through the account names its installation, its participant and the account's installation id the name was listed under,
* and records when the account stopped listing it. */
export const networkInstallationsSchema: Migration = {
name: "163_network_installations.sql",
sql: `
CREATE TABLE IF NOT EXISTS network_installations (
id TEXT PRIMARY KEY,
site TEXT NOT NULL,
installation_id TEXT NOT NULL,
name TEXT,
allow_http INTEGER NOT NULL DEFAULT 0 CHECK (allow_http IN (0, 1)),
created_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_network_installations_site ON network_installations(site);
ALTER TABLE network_bindings ADD COLUMN installation_id TEXT;
ALTER TABLE network_bindings ADD COLUMN participant_name TEXT;
ALTER TABLE network_bindings ADD COLUMN account_removed_at TEXT;
ALTER TABLE network_bindings ADD COLUMN account_installation_id TEXT;
`,
};
@@ -28,18 +28,10 @@ export interface NetworkBinding {
updatedAt: string;
lastContactAt: string | null;
lastError: string | null;
/** Set when the binding was made through an enrolled account: its installation (`neti-…`) and participant name. */
installationId: string | null;
participantName: string | null;
/** The account's own id for the installation the name was listed under. A replaced enrollment keeps the local id and
* moves to a new account installation, so only a list from this one can confirm the name was dropped. */
accountInstallationId: string | null;
/** When the account stopped listing this participant, after the binding ended here. */
accountRemovedAt: string | null;
}
export class NetworkBindingError extends Error {
constructor(readonly code: string, message: string, readonly status: 400 | 401 | 404 | 409 | 500 | 502 | 503 = 400) {
constructor(readonly code: string, message: string, readonly status: 400 | 404 | 409 | 500 = 400) {
super(message);
}
}
@@ -47,15 +39,12 @@ export class NetworkBindingError extends Error {
interface Row {
id: string; node_id: string; local_session: string; hub_url: string; hub_seat: string; allow_http: number; state: NetworkBindingState;
state_reason: string | null; created_at: string; updated_at: string; last_contact_at: string | null; last_error: string | null;
installation_id: string | null; participant_name: string | null; account_removed_at: string | null; account_installation_id: string | null;
}
const project = (row: Row): NetworkBinding => ({
id: row.id, nodeId: row.node_id, localSession: row.local_session, hubUrl: row.hub_url, hubSeat: row.hub_seat,
allowHttp: row.allow_http === 1, unencrypted: row.hub_url.startsWith("http:"), state: row.state, stateReason: row.state_reason, createdAt: row.created_at, updatedAt: row.updated_at,
lastContactAt: row.last_contact_at, lastError: row.last_error,
installationId: row.installation_id, participantName: row.participant_name, accountRemovedAt: row.account_removed_at,
accountInstallationId: row.account_installation_id,
});
const MAX_CREDENTIAL_BYTES = 16384;
@@ -69,15 +58,10 @@ export function networkCredentialFile(home: string, bindingId: string): string {
/** The participant's bearer, read from its private file. A file anyone else can read, a link or an oversized file is
* refused, with the path named and the value never shown. */
export function readNetworkCredential(home: string, bindingId: string): string {
return readPrivateToken(networkCredentialFile(home, bindingId),
(file) => `The credential for ${bindingId} isn't at ${file}. Connect the seat again with its token file.`);
}
/** A token from one of the network's private files (`{"version":1,"token":…}`), checked the same way for every kind. */
export function readPrivateToken(file: string, missing: (file: string) => string): string {
const file = networkCredentialFile(home, bindingId);
let stat;
try { stat = lstatSync(file); } catch {
throw new NetworkBindingError("credential_missing", missing(file), 404);
throw new NetworkBindingError("credential_missing", `The credential for ${bindingId} isn't at ${file}. Connect the seat again with its token file.`, 404);
}
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_CREDENTIAL_BYTES || (stat.mode & 0o077) !== 0) {
throw new NetworkBindingError("credential_unsafe", `${file} must be a regular file readable only by this user.`);
@@ -93,29 +77,7 @@ export function readPrivateToken(file: string, missing: (file: string) => string
return value.token;
}
/** Write a token to its own private file: a 0700 folder and a 0600 file, opened exclusively. On failure, only what
* this call made is removed: the file it opened, and the folder if it made it (`mkdirSync` returns the first folder it
* made, so any value means this one is new). Returns the cleanup for a later step that fails. */
export function writePrivateToken(file: string, token: string): () => void {
let created = false;
let madeFolder: string | undefined;
const cleanup = () => {
if (created) rmSync(file, { force: true });
if (madeFolder !== undefined) rmSync(dirname(file), { recursive: true, force: true });
};
try {
madeFolder = mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
const fd = openSync(file, "wx", 0o600);
created = true;
try { writeFileSync(fd, `${JSON.stringify({ version: 1, token })}\n`); } finally { closeSync(fd); }
} catch (error) {
cleanup();
throw error;
}
return cleanup;
}
export function validHubUrl(raw: unknown, allowHttp: boolean): string {
function validHubUrl(raw: unknown, allowHttp: boolean): string {
if (typeof raw !== "string" || !raw.trim()) throw new NetworkBindingError("invalid_hub_url", "A hub URL is required.");
let url: URL;
try { url = new URL(raw.trim()); } catch { throw new NetworkBindingError("invalid_hub_url", `'${raw}' isn't a URL.`); }
@@ -178,39 +140,37 @@ export class NetworkBindingStore {
/** Bind a local seat to a hub participant with that participant's token. The credential is written first, exclusively
* and private, then the row. If either step fails, the credential this call created is removed, so a refused bind
* leaves none behind; a file that was already at that path is never touched. */
/** The seat to bind, by its node: it must exist and have no active binding. Checked before anything is sent anywhere. */
freeSeat(rawSession: unknown): { session: string; nodeId: string } {
const session = typeof rawSession === "string" ? rawSession.trim() : "";
create(input: { session: unknown; hubUrl: unknown; hubSeat: unknown; token: unknown; allowHttp?: unknown }): NetworkBinding {
const session = typeof input.session === "string" ? input.session.trim() : "";
if (!session) throw new NetworkBindingError("invalid_seat", "Name the local seat to connect.");
const nodeId = resolveSessionNodeId(this.db, session);
if (!nodeId) throw new NetworkBindingError("seat_not_found", `No local seat is named '${session}'.`, 404);
const bound = this.db.prepare("SELECT id FROM network_bindings WHERE node_id = ? AND state = 'active'").get(nodeId) as { id: string } | undefined;
if (bound) throw new NetworkBindingError("seat_already_connected", `${session} is already connected (${bound.id}). Disconnect it first.`, 409);
return { session, nodeId };
}
create(input: { session: unknown; hubUrl: unknown; hubSeat: unknown; token: unknown; allowHttp?: unknown;
installation?: { id: string; name: string; account: string } }): NetworkBinding {
const { session, nodeId } = this.freeSeat(input.session);
const allowHttp = input.allowHttp === true;
const hubUrl = validHubUrl(input.hubUrl, allowHttp);
const hubSeat = validHubSeat(input.hubSeat);
const token = validToken(input.token);
const bound = this.db.prepare("SELECT id FROM network_bindings WHERE node_id = ? AND state = 'active'").get(nodeId) as { id: string } | undefined;
if (bound) throw new NetworkBindingError("seat_already_connected", `${session} is already connected (${bound.id}). Disconnect it first.`, 409);
const taken = this.db.prepare("SELECT id FROM network_bindings WHERE hub_url = ? AND hub_seat = ? AND state = 'active'").get(hubUrl, hubSeat) as { id: string } | undefined;
if (taken) throw new NetworkBindingError("participant_already_connected", `${hubSeat} at ${hubUrl} is already connected (${taken.id}).`, 409);
const id = this.newId();
// The credential first, then the row; a failed row takes the credential with it. A file that was already at the path
// is never touched, and a folder that was already there keeps its other files.
const cleanup = writePrivateToken(networkCredentialFile(this.home, id), token);
const file = networkCredentialFile(this.home, id);
let created = false;
let madeFolder: string | undefined;
try {
madeFolder = mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
const fd = openSync(file, "wx", 0o600);
created = true;
try { writeFileSync(fd, `${JSON.stringify({ version: 1, token })}\n`); } finally { closeSync(fd); }
const at = this.now().toISOString();
this.db.prepare(`INSERT INTO network_bindings (id, node_id, local_session, hub_url, hub_seat, allow_http, state, created_at, updated_at,
installation_id, participant_name, account_installation_id) VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)`)
.run(id, nodeId, session, hubUrl, hubSeat, allowHttp ? 1 : 0, at, at, input.installation?.id ?? null, input.installation?.name ?? null,
input.installation?.account ?? null);
this.db.prepare(`INSERT INTO network_bindings (id, node_id, local_session, hub_url, hub_seat, allow_http, state, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?)`).run(id, nodeId, session, hubUrl, hubSeat, allowHttp ? 1 : 0, at, at);
} catch (error) {
cleanup();
// Only what this call made: the file it opened, and this binding's folder if it made it (`mkdirSync` returns the
// first folder it made, so any value means this one is new). A folder that was already there keeps its other files.
if (created) rmSync(file, { force: true });
if (madeFolder !== undefined) rmSync(dirname(file), { recursive: true, force: true });
throw error;
}
return this.get(id)!;
@@ -227,18 +187,6 @@ export class NetworkBindingStore {
return this.end(id, "revoked", reason);
}
/** Ends these active bindings' state only, so a caller can do it in one transaction with its own change. Their
* credentials stay until the caller ends each one again with `disconnect`, which removes them. */
markDisconnected(ids: string[], reason: string): void {
const end = this.db.prepare("UPDATE network_bindings SET state = 'disconnected', state_reason = ?, updated_at = ? WHERE id = ? AND state = 'active'");
for (const id of ids) end.run(reason, this.now().toISOString(), id);
}
/** The account stopped listing this ended binding's participant. */
markAccountRemoved(id: string): void {
this.db.prepare("UPDATE network_bindings SET account_removed_at = ?, updated_at = ? WHERE id = ?").run(this.now().toISOString(), this.now().toISOString(), id);
}
/** The connector reached the hub as this binding. */
recordContact(id: string, at: Date = this.now()): void {
this.db.prepare("UPDATE network_bindings SET last_contact_at = ?, last_error = NULL, updated_at = ? WHERE id = ?")
@@ -1,378 +0,0 @@
// This daemon enrolled in an account, so the person connects a seat by a name instead of a hub URL and a token file.
// Enrolling trades a one-time code from the account page for this installation's key. Connecting a seat lists the
// installation's participants with the account, which answers a bearer for a newly listed name, and the account's MCP
// front and rig, where the binding then reaches. A name the installation stops listing is revoked there.
//
// The account replaces the whole list on every change, so changes to one installation's list, and a replaced enrollment,
// run one at a time, each reading what it needs inside its turn. One enroll, connect or disconnect spends at most one
// deadline with the account, waiting for its turn included.
import type Database from "better-sqlite3";
import { randomBytes } from "node:crypto";
import { existsSync, renameSync, rmSync } from "node:fs";
import { join } from "node:path";
import { NetworkBindingError, readPrivateToken, writePrivateToken, type NetworkBinding, type NetworkBindingStore } from "./network-bindings.js";
export interface NetworkInstallation {
id: string;
site: string;
/** The account's id for this installation. */
installationId: string;
name: string | null;
allowHttp: boolean;
createdAt: string;
}
/** An enroll's answer. A replaced enrollment names the bindings it ended, whose names the old installation may still list,
* and any cleanup after the switch that didn't finish. */
export interface EnrollResult extends NetworkInstallation {
replaced?: { endedBindings: Array<{ id: string; localSession: string; participantName: string | null }>; problems?: string[] };
}
interface Row { id: string; site: string; installation_id: string; name: string | null; allow_http: number; created_at: string }
const project = (row: Row): NetworkInstallation => ({
id: row.id, site: row.site, installationId: row.installation_id, name: row.name, allowHttp: row.allow_http === 1, createdAt: row.created_at,
});
/** Where the installation key lives: `<home>/network-secrets/<installation>/key.json`, private to this user. */
export function networkInstallationKeyFile(home: string, id: string): string {
if (!/^neti-[0-9a-f]{20}$/.test(id)) throw new NetworkBindingError("invalid_installation_id", `'${id}' isn't a network installation id.`);
return join(home, "network-secrets", id, "key.json");
}
const NAME = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
/** The state reason of a binding a replaced enrollment ended: its name belongs to the old installation. */
export const REPLACED_REASON = "replaced by a new enrollment";
/** What's known about the account's side of an ended binding made through it: confirmed dropped, unconfirmed (a removal
* that failed or whose answer was lost), or left with an earlier account installation after the enrollment was replaced,
* which this daemon's key can neither drop nor confirm. `accountInstallationNow` is the account's id for the binding's
* enrollment as it is now. */
export function accountRemoval(binding: NetworkBinding, accountInstallationNow: string | null | undefined): "confirmed" | "unconfirmed" | "old-installation" | null {
if (!binding.installationId || binding.state === "active") return null;
if (binding.accountRemovedAt) return "confirmed";
// The hub refused its credential, so nothing live is left there.
if (binding.state === "revoked") return null;
if (binding.accountInstallationId !== accountInstallationNow) return "old-installation";
return "unconfirmed";
}
/** The command's time with the account ran out before a call was sent. */
class NotSent extends NetworkBindingError {}
/** What the account answers for the installation's participant list. */
interface Identities { participants: Array<{ name: string; state?: string; bearer?: string }>; mcp: string; rig: string }
export type AccountFetch = (url: string, init: { method: string; headers: Record<string, string>; body: string; redirect: "error"; signal: AbortSignal })
=> Promise<{ status: number; json(): Promise<unknown> }>;
/** How long one enroll, connect or disconnect may spend with the account in all, waiting for its turn included. */
export const ACCOUNT_DEADLINE_MS = 30_000;
/** One change at a time to an installation's participant list, across the stores each request makes. */
const listTurns = new Map<string, Promise<void>>();
async function inListTurn<T>(installationId: string, run: () => Promise<T>): Promise<T> {
const before = listTurns.get(installationId) ?? Promise.resolve();
let release!: () => void;
const mine = new Promise<void>((resolve) => { release = resolve; });
const chain = before.then(() => mine);
listTurns.set(installationId, chain);
try {
await before;
return await run();
} finally {
release();
if (listTurns.get(installationId) === chain) listTurns.delete(installationId);
}
}
export class NetworkInstallationStore {
private readonly fetch: AccountFetch;
constructor(
private readonly db: Database.Database,
private readonly home: string,
private readonly opts: { fetch?: AccountFetch; selfHostId: () => string | null; now?: () => Date; newId?: () => string; timeoutMs?: number },
) {
this.fetch = opts.fetch ?? ((url, init) => fetch(url, init));
}
list(): NetworkInstallation[] {
return (this.db.prepare("SELECT * FROM network_installations ORDER BY created_at, id").all() as Row[]).map(project);
}
/** Trade the account page's one-time code for this installation's key. The code is checked by the account only, so a
* refused enroll here (a URL, a second enroll at the same site without `replace`) never spends it. With `replace`, the
* installation's turn comes first, so its wait counts against the same deadline: a wait that runs out sends no code,
* and the new key takes the old one's place only after the account accepted the new code. */
async enroll(bindings: NetworkBindingStore, input: { code: unknown; site: unknown; allowHttp?: unknown; replace?: unknown }): Promise<EnrollResult> {
const code = typeof input.code === "string" ? input.code.trim() : "";
if (!code || /\s/.test(code)) throw new NetworkBindingError("invalid_code", "Give the one-time code the account page shows.");
const site = validSite(input.site, input.allowHttp === true);
const existing = this.db.prepare("SELECT * FROM network_installations WHERE site = ?").get(site) as Row | undefined;
if (existing && input.replace !== true) {
throw new NetworkBindingError("already_enrolled", `This daemon is already enrolled at ${site} (${existing.id}). To enroll again with a new code, add --replace.`, 409);
}
const instance = this.instance();
const until = this.deadline();
const allowHttp = input.allowHttp === true ? 1 : 0;
if (existing) {
return inListTurn(existing.id, async () => {
const next = await this.exchange(site, code, instance, until);
return this.replace(bindings, this.get(existing.id), next.installation, next.key, allowHttp);
});
}
const { installation, key } = await this.exchange(site, code, instance, until);
const id = this.opts.newId?.() ?? `neti-${randomBytes(10).toString("hex")}`;
const cleanup = writePrivateToken(networkInstallationKeyFile(this.home, id), key);
try {
this.db.prepare("INSERT INTO network_installations (id, site, installation_id, name, allow_http, created_at) VALUES (?, ?, ?, ?, ?, ?)")
.run(id, site, installation.id, installation.name, allowHttp, (this.opts.now?.() ?? new Date()).toISOString());
} catch (error) {
cleanup();
throw error;
}
return this.get(id);
}
/** The code, with this daemon's host id, for an account installation and its key. */
private async exchange(site: string, code: string, instance: string, until: number): Promise<{ installation: { id: string; name: string | null }; key: string }> {
let answer;
try {
answer = await this.call(site, "POST", "/api/connector/enroll", null, { code, instance }, until);
} catch (error) {
if (error instanceof NotSent) throw new NetworkBindingError(error.code, `${error.message} The code wasn't sent, so it's still good while it's current: try again.`, 503);
if (error instanceof NetworkBindingError && error.code === "account_unavailable") {
throw new NetworkBindingError("account_unavailable", `${error.message} Whether the account took the code is unknown: try again with the same code, and if it's refused, get a new one.`, 503);
}
throw error;
}
if (answer.status !== 200) {
const error = errorCode(answer.body);
if (answer.status === 400 || answer.status === 409) {
throw new NetworkBindingError(error ?? "enroll_refused", `The account refused the code (${error ?? answer.status}). Get a new one from the account page; each is single-use and short-lived.`, answer.status);
}
throw new NetworkBindingError("account_unavailable", `The account couldn't answer (${answer.status}). If the code is still current, try again with it; otherwise get a new one.`, 503);
}
const body = (answer.body ?? {}) as { installation?: { id?: unknown; name?: unknown }; key?: unknown };
if (typeof body.installation?.id !== "string" || typeof body.key !== "string" || !body.key || /\s/.test(body.key)) {
throw new NetworkBindingError("account_unexpected", "The account's answer to the enroll had no installation or key.", 502);
}
return { installation: { id: body.installation.id, name: typeof body.installation.name === "string" ? body.installation.name : null }, key: body.key };
}
/** The new key goes in beside the old one and takes its place, then the row and the old installation's active bindings
* change in one transaction. A failure before that commits puts the old key back (with no old key, it removes the new
* one), so nothing here changes, though the account has already made the new installation and nothing here holds its
* key. After the commit the replace has happened: the ended bindings' names are returned, since the old installation
* may still list them and this key can't drop them, with any cleanup that didn't finish. */
private replace(bindings: NetworkBindingStore, old: NetworkInstallation, next: { id: string; name: string | null }, key: string, allowHttp: number): EnrollResult {
const file = networkInstallationKeyFile(this.home, old.id);
const staged = `${file}.next`;
const kept = `${file}.prev`;
const ending = bindings.active().filter((b) => b.installationId === old.id);
rmSync(staged, { force: true });
const cleanup = writePrivateToken(staged, key);
const hadKey = existsSync(file);
try {
if (hadKey) renameSync(file, kept);
renameSync(staged, file);
this.db.transaction(() => {
this.db.prepare("UPDATE network_installations SET installation_id = ?, name = ?, allow_http = ? WHERE id = ?").run(next.id, next.name, allowHttp, old.id);
bindings.markDisconnected(ending.map((b) => b.id), REPLACED_REASON);
})();
} catch (error) {
if (!hadKey) rmSync(file, { force: true });
else if (existsSync(kept)) renameSync(kept, file);
cleanup();
throw new NetworkBindingError("replace_failed",
`The account accepted the new code, but this daemon couldn't switch to it (${(error as Error).message.slice(0, 200)}), so nothing here changed. The account now has a new installation whose key nothing here holds: remove it on the account page, then enroll again with a new code.`, 500);
}
const problems: string[] = [];
try {
rmSync(kept, { force: true });
} catch (error) {
problems.push(`The earlier key's backup at ${kept} couldn't be removed (${(error as Error).message.slice(0, 200)}). It's no longer used; remove it by hand.`);
}
for (const binding of ending) {
// Already ended in the transaction; this removes its credential, and a failure says how to retry.
try { bindings.disconnect(binding.id, REPLACED_REASON); } catch (error) { problems.push((error as Error).message); }
}
return { ...this.get(old.id), replaced: { endedBindings: ending.map((b) => ({ id: b.id, localSession: b.localSession, participantName: b.participantName })),
...(problems.length ? { problems } : {}) } };
}
/** The one installation a name-only connect goes through. */
only(): NetworkInstallation {
const all = this.list();
if (all.length === 0) throw new NetworkBindingError("not_enrolled", "This daemon isn't enrolled in an account. Run rig network enroll <code> --site <site> first, or connect with --hub and --token-file.", 404);
if (all.length > 1) throw new NetworkBindingError("installation_ambiguous", "This daemon is enrolled at more than one site; connect with --hub and --token-file.", 409);
return all[0]!;
}
/** Connect a seat as a participant name: list it with the account, then bind to the account's MCP front with the new
* bearer. The seat is checked first, so a refusal here sends nothing. A name the account keeps without a bearer had
* its answer lost earlier: it's dropped and listed again for a fresh one. */
async connect(bindings: NetworkBindingStore, input: { session: unknown; name: unknown }): Promise<NetworkBinding> {
const installation = this.only();
bindings.freeSeat(input.session);
const name = typeof input.name === "string" ? input.name.trim() : "";
if (!NAME.test(name)) throw new NetworkBindingError("invalid_name", `'${name}' isn't a participant name: lowercase letters, digits and inner dashes, up to 32.`);
const until = this.deadline();
return inListTurn(installation.id, async () => {
// In this installation's turn: checked again, with the enrollment as it is now (a replace may have run while this
// waited), and every list sent reads the names to keep afresh.
const current = this.get(installation.id);
bindings.freeSeat(input.session);
if (this.listedNames(bindings, current).includes(name)) throw new NetworkBindingError("participant_already_connected", `${name} is already connected here.`, 409);
const others = () => this.listedNames(bindings, current).filter((n) => n !== name);
let answer = await this.setParticipants(bindings, current, () => [...others(), name], until);
let bearer = answer.participants.find((p) => p.name === name)?.bearer;
if (!bearer) {
await this.setParticipants(bindings, current, others, until);
answer = await this.setParticipants(bindings, current, () => [...others(), name], until);
bearer = answer.participants.find((p) => p.name === name)?.bearer;
if (!bearer) throw new NetworkBindingError("account_unexpected", `The account listed ${name} without a bearer, twice.`, 502);
}
try {
return bindings.create({ session: input.session, hubUrl: answer.mcp, hubSeat: `${name}@${answer.rig}`, token: bearer,
allowHttp: current.allowHttp, installation: { id: current.id, name, account: current.installationId } });
} catch (error) {
// Best effort: nothing here holds that bearer, so the account is asked to stop listing the name. If that fails,
// the error says so, whatever failed here; the account may still list it.
let unconfirmed = "";
try {
await this.setParticipants(bindings, current, others, until);
} catch (drop) {
unconfirmed = ` OpenRig couldn't confirm the account dropped ${name} again (${(drop as Error).message}); it may still list it.`;
}
if (!unconfirmed) throw error;
if (error instanceof NetworkBindingError) throw new NetworkBindingError(error.code, `${error.message}${unconfirmed}`, error.status);
throw new NetworkBindingError("binding_write_failed", `The binding couldn't be saved here (${(error as Error).message.slice(0, 200)}).${unconfirmed}`, 500);
}
});
}
/** After a binding made through the account ended here, the account stops listing its name, which revokes it there.
* Only an answered list that no longer has the name marks it removed; a failed or lost one leaves it unconfirmed. */
async removeAtAccount(bindings: NetworkBindingStore, binding: NetworkBinding): Promise<NetworkBinding> {
const installation = this.list().find((i) => i.id === binding.installationId);
if (!installation || accountRemoval(binding, installation.installationId) !== "unconfirmed") return binding;
const until = this.deadline();
return inListTurn(installation.id, async () => {
// A replace while this waited moved the enrollment to another account installation, which can't drop this name.
const current = this.get(installation.id);
if (accountRemoval(binding, current.installationId) !== "unconfirmed") return bindings.get(binding.id)!;
try {
await this.setParticipants(bindings, current, () => this.listedNames(bindings, current), until);
} catch (error) {
throw new NetworkBindingError("account_update_unconfirmed",
`${binding.id} is ${binding.state} here, but OpenRig couldn't confirm that the account dropped ${binding.participantName} (${(error as Error).message}). Run \`rig network disconnect ${binding.id}\` again.`, 502);
}
return bindings.get(binding.id)!;
});
}
/** `accountRemoval` against this binding's enrollment as it is now. */
removalOf(binding: NetworkBinding): ReturnType<typeof accountRemoval> {
const now = binding.installationId ? this.list().find((i) => i.id === binding.installationId) : undefined;
return accountRemoval(binding, now?.installationId);
}
private get(id: string): NetworkInstallation {
return project(this.db.prepare("SELECT * FROM network_installations WHERE id = ?").get(id) as Row);
}
private listedNames(bindings: NetworkBindingStore, installation: NetworkInstallation): string[] {
return bindings.active().filter((b) => b.installationId === installation.id && b.participantName).map((b) => b.participantName!);
}
private instance(): string {
const instance = this.opts.selfHostId();
if (!instance) throw new NetworkBindingError("instance_unknown", "This daemon hasn't resolved its host id yet. Try again once it's fully up.", 503);
return instance;
}
private deadline(): number {
return Date.now() + (this.opts.timeoutMs ?? ACCOUNT_DEADLINE_MS);
}
private async setParticipants(bindings: NetworkBindingStore, installation: NetworkInstallation, names: () => string[], until: number): Promise<Identities> {
const instance = this.instance();
const repair = `Get a new code from the account page and run rig network enroll <code> --site ${installation.site} --replace.`;
const key = readPrivateToken(networkInstallationKeyFile(this.home, installation.id),
(file) => `The installation key for ${installation.id} isn't at ${file}. ${repair}`);
const put = () => this.call(installation.site, "PUT", "/api/connector/identities", key, { instance, participants: names() }, until);
let answer = await put();
// The account couldn't apply a drop while something else changed this installation: the list is read and sent once more.
if (answer.status === 409 && errorCode(answer.body) === "drop_failed") answer = await put();
if (answer.status !== 200) {
const error = errorCode(answer.body);
if (answer.status === 409 && error === "drop_failed") {
throw new NetworkBindingError("account_busy", "The account was changing this installation's participants at the same time. Try again.", 503);
}
if (answer.status === 409) throw new NetworkBindingError(error ?? "name_taken", "Another connection to the account already uses that name. Choose another.", 409);
if (answer.status === 401) throw new NetworkBindingError(error ?? "not_enrolled", `The account doesn't recognize this installation any more. ${repair}`, 401);
if (answer.status === 400) throw new NetworkBindingError(error ?? "bad_names", "The account refused the participant names.", 400);
throw new NetworkBindingError("account_unavailable", `The account couldn't answer (${answer.status}). Try again.`, 503);
}
const body = (answer.body ?? {}) as Partial<Identities>;
if (!Array.isArray(body.participants) || typeof body.mcp !== "string" || typeof body.rig !== "string") {
throw new NetworkBindingError("account_unexpected", "The account's answer had no participants, MCP front or rig.", 502);
}
// The answer is what this account installation now lists: a binding that ended while listed under it, whose name
// isn't in the answer, is confirmed dropped. A name listed under an earlier installation is never confirmed here.
const listed = new Set(body.participants.map((p) => p.name));
for (const ended of bindings.list()) {
if (ended.installationId === installation.id && ended.participantName && !listed.has(ended.participantName)
&& accountRemoval(ended, installation.installationId) === "unconfirmed") {
bindings.markAccountRemoved(ended.id);
}
}
return body as Identities;
}
private async call(site: string, method: string, path: string, key: string | null, payload: unknown, until: number): Promise<{ status: number; body: unknown }> {
const left = until - Date.now();
if (left <= 0) throw new NotSent("account_unavailable", `The account at ${site} wasn't reached in time: this command's time with the account ran out.`, 503);
let res;
try {
// The key goes only to the site the person enrolled at: a redirect isn't followed with it, and a stalled call ends.
res = await this.fetch(new URL(path, site).toString(), {
method,
headers: { "Content-Type": "application/json", ...(key ? { Authorization: `Bearer ${key}` } : {}) },
body: JSON.stringify(payload),
redirect: "error",
signal: AbortSignal.timeout(left),
});
} catch (error) {
const cause = (error as { cause?: { message?: unknown } }).cause?.message;
if (typeof cause === "string" && /redirect/i.test(cause)) {
throw new NetworkBindingError("account_redirected", `The account at ${site} answered with a redirect, which isn't followed with the installation key. Use the site's own URL.`, 502);
}
throw new NetworkBindingError("account_unavailable", `The account at ${site} couldn't be reached: ${(error as Error).message.slice(0, 200)}.`, 503);
}
let body: unknown = null;
try { body = await res.json(); } catch { /* no JSON body */ }
return { status: res.status, body };
}
}
function errorCode(body: unknown): string | null {
const error = (body as { error?: unknown } | null)?.error;
return typeof error === "string" && /^[a-z][a-z0-9_]*$/.test(error) ? error : null;
}
/** The account's site: https, or plain http on the person's choice, with no credential in it. */
function validSite(raw: unknown, allowHttp: boolean): string {
if (typeof raw !== "string" || !raw.trim()) throw new NetworkBindingError("invalid_site", "Give the account's site URL with --site.");
let url: URL;
try { url = new URL(raw.trim()); } catch { throw new NetworkBindingError("invalid_site", `'${raw}' isn't a URL.`); }
if (url.protocol !== "https:" && url.protocol !== "http:") throw new NetworkBindingError("invalid_site", "The site must use https (or http).");
if (url.username || url.password) throw new NetworkBindingError("invalid_site", "The site URL can't hold a credential.");
if (url.protocol === "http:" && !allowHttp) {
throw new NetworkBindingError("http_not_allowed", `${url.origin} is plain http, so the installation key and every participant's token would cross the network unencrypted, where they can be read. Use the site's https URL, or enroll again with --allow-http to accept that.`);
}
return url.origin + "/";
}
@@ -61,15 +61,16 @@ export interface ClaudeUsageReaderDeps {
/**
* Read each Claude seat's provider_usage cache → provider_statusline rows via
* claudeStatuslineSignals. Explicit unknown rows for absent cache (no_statusline_cache_yet) and
* cache-present-but-no/-malformed windows (empty_reading).
* Never throws — a malformed cache is treated as present-with-no-windows, not a crash.
* claudeStatuslineSignals. Explicit unknown rows for absent cache (no_statusline_cache_yet),
* an unreadable cache (unreadable_cache), and a valid cache with no windows (empty_reading).
* Never throws — a malformed cache becomes an unknown row, not a crash.
*/
export function collectClaudeStatuslineSignals(deps: ClaudeUsageReaderDeps): ProviderSignal[] {
const now = deps.now();
const out: ProviderSignal[] = [];
for (const seat of deps.listClaudeSeats()) {
let cachePresent = false;
let cacheUnreadable = false;
let reading: ClaudeStatuslineReading | undefined;
let staleAfter: string | undefined;
let capturedAsOf: string | undefined;
@@ -84,7 +85,8 @@ export function collectClaudeStatuslineSignals(deps: ClaudeUsageReaderDeps): Pro
staleAfter = parsed.staleAfter;
capturedAsOf = typeof parsed.asOf === "string" ? parsed.asOf : undefined;
} catch {
// Malformed cache → present-with-no-windows (empty_reading unknown), never a throw.
// Malformed cache → present-but-unreadable (its own unknown), never a throw.
cacheUnreadable = true;
reading = undefined;
}
}
@@ -92,6 +94,7 @@ export function collectClaudeStatuslineSignals(deps: ClaudeUsageReaderDeps): Pro
...claudeStatuslineSignals({
seatSession: seat.seatSession,
cachePresent,
cacheUnreadable,
reading,
// Signal asOf = when the reading was captured (the cache's asOf) when present+valid; else now.
asOf: cachePresent && capturedAsOf ? capturedAsOf : now,
@@ -109,6 +112,7 @@ export function collectClaudeSignalsFromProviderUsageDirectory(
legacyDirectory?: string,
): ProviderSignal[] {
const seats = new Map<string, ClaudeSeatRef>();
const unreadableFiles = new Set<string>();
for (const candidateDirectory of [directory, legacyDirectory]) {
if (!candidateDirectory) continue;
try {
@@ -119,10 +123,16 @@ export function collectClaudeSignalsFromProviderUsageDirectory(
if (typeof parsed.seatSession === "string") {
seats.set(parsed.seatSession, { seatSession: parsed.seatSession });
}
} catch { /* malformed cache cannot create a usable cache signal */ }
} catch {
// A cache file that exists but cannot be read or parsed still names its seat in the
// filename (telemetrySidecarFilename sanitizes the same seat name). Keep it in the
// seat list so the reader reports an unreadable cache instead of the file vanishing.
unreadableFiles.add(file.slice(0, -".json".length));
}
}
} catch { /* absent cache directory */ }
}
for (const seatSession of unreadableFiles) seats.set(seatSession, { seatSession });
const readFrom = (candidateDirectory: string, seatSession: string): string | null => {
try {
@@ -131,6 +131,8 @@ export interface ClaudeSignalInput {
seatSession: string;
/** false = the statusline cache has not been written yet (before the first API response). */
cachePresent: boolean;
/** True when a cache file exists but cannot be read or parsed. Distinct from an empty reading. */
cacheUnreadable?: boolean;
/** Present only when the cache exists and carried a rate_limits object. */
reading?: ClaudeStatuslineReading;
asOf: string;
@@ -140,6 +142,7 @@ export interface ClaudeSignalInput {
export const CLAUDE_UNKNOWN_REASON = {
no_statusline_cache_yet: "claude_no_statusline_cache_yet",
empty_reading: "claude_statusline_cache_had_no_windows",
unreadable_cache: "claude_statusline_cache_unreadable",
} as const;
function claudeUnknownSignal(
@@ -165,17 +168,22 @@ function claudeUnknownSignal(
/**
* Normalize a Claude statusline `rate_limits` reading into `signals[]` rows.
* - seat before first response (no cache) → explicit unknown row.
* - a cache that exists but cannot be read/parsed → its own unknown reason (broken, not empty).
* - seat with subscription windows → one `provider_statusline` row per window (five_hour, and
* seven_day normalized to "weekly"); a genuine 0 survives as 0.
* - subscription with cache but no windows → explicit unknown row (never fabricated zeros).
*/
export function claudeStatuslineSignals(input: ClaudeSignalInput): ProviderSignal[] {
const { seatSession, cachePresent, reading, asOf, staleAfter } = input;
const { seatSession, cachePresent, cacheUnreadable, reading, asOf, staleAfter } = input;
if (!cachePresent) {
return [claudeUnknownSignal(seatSession, asOf, CLAUDE_UNKNOWN_REASON.no_statusline_cache_yet, staleAfter)];
}
if (cacheUnreadable) {
return [claudeUnknownSignal(seatSession, asOf, CLAUDE_UNKNOWN_REASON.unreadable_cache, staleAfter)];
}
const windows: Array<["five_hour" | "weekly", ClaudeStatuslineWindow | undefined]> = [
["five_hour", reading?.five_hour],
["weekly", reading?.seven_day],
+14 -42
View File
@@ -2,75 +2,47 @@ import { Hono, type Context } from "hono";
import type { RigRepository } from "../domain/rig-repository.js";
import { NetworkBindingError, NetworkBindingStore } from "../domain/network/network-bindings.js";
import { networkClosedElsewhere } from "../domain/network/network-connector.js";
import { NetworkInstallationStore, type AccountFetch } from "../domain/network/network-installations.js";
import { getSelfHostId } from "../domain/hosts/fanout-contract.js";
import { authBearerTokenMiddleware } from "../middleware/auth-bearer-token.js";
import { OPENRIG_HOME } from "../openrig-compat.js";
/** A local seat's connections to a remote hub (`rig network`). Reads list every binding with its state and last contact,
* and the account enrollments; tokens and keys are accepted and never returned. Writes are gated like the hosts routes:
* enforced when an operator bearer is configured, passed through on a loopback or tailnet-trusted daemon. */
export function networkRoutes(opts?: { bearerToken?: string | null; home?: string; accountFetch?: AccountFetch; selfHostId?: () => string | null; accountTimeoutMs?: number }): Hono {
/** A local seat's connections to a remote hub (`rig network`). Reads list every binding with its state and last contact;
* the token is accepted on connect and never returned. Writes are gated like the hosts routes: enforced when an operator
* bearer is configured, passed through on a loopback or tailnet-trusted daemon. */
export function networkRoutes(opts?: { bearerToken?: string | null; home?: string }): Hono {
const router = new Hono();
const requireAuth = authBearerTokenMiddleware({ expectedToken: opts?.bearerToken ?? null });
router.use("/bindings", async (c, next) => (c.req.method === "GET" ? next() : requireAuth(c, next)));
router.use("/bindings/:id/disconnect", requireAuth);
router.use("/installations", requireAuth);
const home = opts?.home ?? OPENRIG_HOME;
const db = (c: Context) => (c.get("rigRepo" as never) as RigRepository).db;
const store = (c: Context) => new NetworkBindingStore(db(c), home);
const installations = (c: Context) => new NetworkInstallationStore(db(c), home, { fetch: opts?.accountFetch, selfHostId: opts?.selfHostId ?? getSelfHostId, timeoutMs: opts?.accountTimeoutMs });
const store = (c: Context) => new NetworkBindingStore((c.get("rigRepo" as never) as RigRepository).db, opts?.home ?? OPENRIG_HOME);
const refused = (c: Context, error: unknown) => {
if (error instanceof NetworkBindingError) return c.json({ error: error.code, message: error.message }, error.status);
throw error;
};
const json = async (c: Context): Promise<Record<string, unknown> | null> => {
try { return await c.req.json(); } catch { return null; }
};
// The connector's own view (next pass, backoff, last pass) when it runs in this daemon; null otherwise.
router.get("/bindings", (c) => {
const connector = c.get("networkConnector" as never) as { snapshot(): unknown } | undefined;
const db = (c.get("rigRepo" as never) as RigRepository).db;
// Hub items someone else closed before a binding's result reached them: a conflict the person sees.
const enrolled = installations(c);
const bindings = store(c).list().map((b) => ({ ...b, closedElsewhere: networkClosedElsewhere(db(c), b.id), accountRemoval: enrolled.removalOf(b) }));
return c.json({ bindings, installations: enrolled.list(), connector: connector?.snapshot() ?? null });
const bindings = store(c).list().map((b) => ({ ...b, closedElsewhere: networkClosedElsewhere(db, b.id) }));
return c.json({ bindings, connector: connector?.snapshot() ?? null });
});
// With a hub URL and token, bind to that hub directly; with only a participant name, through the enrolled account.
router.post("/bindings", async (c) => {
const body = await json(c);
if (!body) return c.json({ error: "invalid_body", message: "The body must be JSON." }, 400);
let body: Record<string, unknown>;
try { body = await c.req.json(); } catch { return c.json({ error: "invalid_body", message: "The body must be JSON." }, 400); }
try {
if (body.hubUrl === undefined && body.token === undefined && body.participantName !== undefined) {
return c.json(await installations(c).connect(store(c), { session: body.session, name: body.participantName }), 201);
}
return c.json(store(c).create({ session: body.session, hubUrl: body.hubUrl, hubSeat: body.hubSeat, token: body.token, allowHttp: body.allowHttp }), 201);
} catch (error) { return refused(c, error); }
});
router.post("/installations", async (c) => {
const body = await json(c);
if (!body) return c.json({ error: "invalid_body", message: "The body must be JSON." }, 400);
try {
return c.json(await installations(c).enroll(store(c), { code: body.code, site: body.site, allowHttp: body.allowHttp, replace: body.replace }), 201);
} catch (error) { return refused(c, error); }
});
// The binding ends here first, then a binding made through the account is dropped there, which revokes its name.
router.post("/bindings/:id/disconnect", async (c) => {
const body = (await json(c)) ?? {};
const bindings = store(c);
const target = bindings.find(c.req.param("id"));
if (!target) return refused(c, new NetworkBindingError("binding_not_found", `No network binding or connected seat is named '${c.req.param("id")}'.`, 404));
let cleanup: unknown = null;
try { bindings.disconnect(target.id, typeof body.reason === "string" ? body.reason : undefined); } catch (error) { cleanup = error; }
let body: Record<string, unknown> = {};
try { body = await c.req.json(); } catch { /* no reason given */ }
try {
const enrolled = installations(c);
const ended = await enrolled.removeAtAccount(bindings, bindings.get(target.id)!);
return cleanup ? refused(c, cleanup) : c.json({ ...ended, accountRemoval: enrolled.removalOf(ended) });
} catch (error) { return refused(c, cleanup ?? error); }
return c.json(store(c).disconnect(c.req.param("id"), typeof body.reason === "string" ? body.reason : undefined));
} catch (error) { return refused(c, error); }
});
return router;
+1 -2
View File
@@ -6,7 +6,6 @@ import { queueInboxCheckinsSchema } from "../../src/db/migrations/158_queue_inbo
import { queueItemsHandedOffFromIndexSchema } from "../../src/db/migrations/159_queue_items_handed_off_from_index.js";
import { networkBindingsSchema } from "../../src/db/migrations/160_network_bindings.js";
import { networkReceiptsSchema } from "../../src/db/migrations/161_network_receipts.js";
import { networkInstallationsSchema } from "../../src/db/migrations/163_network_installations.js";
import { activityGrantsSchema } from "../../src/db/migrations/143_activity_grants.js";
import { environmentBindingsSchema } from "../../src/db/migrations/144_environment_bindings.js";
import { nodePermissionsToSchema } from "../../src/db/migrations/145_node_permissions_to.js";
@@ -170,7 +169,7 @@ import fs from "node:fs";
/** Seam B R6: the canonical full-fixture migration list, exported so file-backed
* DB-reopen tests migrate IDENTICALLY to createFullTestDb. */
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema, nodeClaudeTokenRefSchema, nodeClaudePermissionsSchema, nodeCodexPermissionsSchema, escalationTargetRefsSchema, sessionAgentRunnerSchema, nodeAccountHomeSchema, nodeClaudeMcpSchema, activityGrantsSchema, environmentBindingsSchema, nodePermissionsToSchema, nodeStartupGuidanceFileSchema, managedBlockWritesSchema, streamIntakeCursorSchema, nodeLaunchWrapperSchema, piAccountSwitchesSchema, eventsRetentionMarksSchema, queueErasureSchema, queueErasureBatchesSchema, queueInboxCheckinsSchema, queueItemsHandedOffFromIndexSchema, networkBindingsSchema, networkReceiptsSchema, sessionAddressReadIndexesSchema, networkInstallationsSchema];
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema, nodeClaudeTokenRefSchema, nodeClaudePermissionsSchema, nodeCodexPermissionsSchema, escalationTargetRefsSchema, sessionAgentRunnerSchema, nodeAccountHomeSchema, nodeClaudeMcpSchema, activityGrantsSchema, environmentBindingsSchema, nodePermissionsToSchema, nodeStartupGuidanceFileSchema, managedBlockWritesSchema, streamIntakeCursorSchema, nodeLaunchWrapperSchema, piAccountSwitchesSchema, eventsRetentionMarksSchema, queueErasureSchema, queueErasureBatchesSchema, queueInboxCheckinsSchema, queueItemsHandedOffFromIndexSchema, networkBindingsSchema, networkReceiptsSchema, sessionAddressReadIndexesSchema];
/**
* P24 — the DECLARED exclusions for {@link migrationsForFullTestDb}. That list is deliberately a
@@ -1,481 +0,0 @@
// Enrolling in an account and connecting seats by name, through the daemon's real network routes, against an account
// that answers the way the account's connector routes do: a one-time code for an installation key, then a participant
// list that answers a bearer for each newly listed name and revokes a name dropped from it.
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import Database from "better-sqlite3";
import { Hono } from "hono";
import { mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
import { setTimeout as sleep } from "node:timers/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { migrate } from "../src/db/migrate.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { readNetworkCredential, readPrivateToken } from "../src/domain/network/network-bindings.js";
import { networkInstallationKeyFile, type AccountFetch } from "../src/domain/network/network-installations.js";
import { networkRoutes } from "../src/routes/network.js";
/** Makes removing a path with this ending fail, the way a busy file does. */
const failRemoval = vi.hoisted(() => ({ suffix: null as string | null }));
vi.mock("node:fs", async (importOriginal) => {
const real = await importOriginal<typeof import("node:fs")>();
return {
...real,
rmSync: (...args: Parameters<typeof real.rmSync>) => {
if (failRemoval.suffix && String(args[0]).endsWith(failRemoval.suffix)) throw Object.assign(new Error("EBUSY: resource busy or locked, rm"), { code: "EBUSY" });
return real.rmSync(...args);
},
};
});
const KEY = `oik1_cust_inst-1_${"k".repeat(43)}`;
class FakeAccount {
readonly codes = new Set(["ec1.good", "ec1.next"]);
/** Each enrollment is a new installation with its own key and an empty list. */
enrollments = 0;
key = KEY;
readonly used = new Set<string>();
instance: string | null = null;
names = new Map<string, { gen: number; state: "connected" | "revoked" }>();
readonly elsewhere = new Set(["taken"]);
readonly puts: string[][] = [];
calls = 0;
readonly inits: Array<{ redirect: string; signal: AbortSignal }> = [];
/** Makes the next call fail the way fetch does on a redirect it won't follow. */
redirectNext = false;
/** The next call never answers. */
stallNext = false;
/** The next participant list is applied, then its answer is lost. */
loseNext = false;
down = false;
mcp = "https://account.example/mcp";
/** The next participant lists the account can't apply, because something else changed the installation. */
dropFails = 0;
/** How long a participant list waits before it's applied, so concurrent requests overlap. */
delayMs = 0;
/** After this many applied lists, the account goes down. */
downAfterPuts: number | null = null;
/** The owner removed this installation on the account page. */
forget(): void { this.instance = null; }
readonly fetch: AccountFetch = async (url, init) => {
this.calls++;
this.inits.push({ redirect: init.redirect, signal: init.signal });
if (this.down) throw new Error("connect ECONNREFUSED");
if (this.redirectNext) { this.redirectNext = false; throw Object.assign(new TypeError("fetch failed"), { cause: new Error("unexpected redirect") }); }
if (this.stallNext) { this.stallNext = false; await new Promise((_resolve, reject) => init.signal.addEventListener("abort", () => reject(init.signal.reason), { once: true })); }
const path = new URL(url).pathname;
const body = JSON.parse(init.body) as { code?: string; instance?: string; participants?: string[] };
const reply = (status: number, json: unknown) => ({ status, json: async () => json });
if (path === "/api/connector/enroll" && init.method === "POST") {
if (this.used.has(body.code!)) return reply(409, { error: "code_not_current" });
if (!this.codes.has(body.code!)) return reply(400, { error: "code_not_current" });
this.used.add(body.code!);
this.instance = body.instance!;
this.enrollments++;
this.key = `${KEY}${this.enrollments}`;
this.names = new Map();
return reply(200, { installation: { id: `inst-${this.enrollments}`, name: "lab" }, key: this.key });
}
if (path === "/api/connector/identities" && init.method === "PUT") {
if (init.headers.Authorization !== `Bearer ${this.key}` || body.instance !== this.instance) return reply(401, { error: "not_enrolled" });
if (this.delayMs) await sleep(this.delayMs);
const list = body.participants!;
if (list.some((n) => this.elsewhere.has(n))) return reply(409, { error: "name_taken" });
if (this.dropFails > 0) { this.dropFails--; return reply(409, { error: "drop_failed" }); }
this.puts.push(list);
for (const [name, entry] of this.names) if (!list.includes(name)) entry.state = "revoked";
const participants = list.map((name) => {
const entry = this.names.get(name);
if (entry?.state === "connected") return { name, state: "connected" };
const gen = (entry?.gen ?? 0) + 1;
this.names.set(name, { gen, state: "connected" });
return { name, state: "connected", bearer: `bear-${name}-${gen}` };
});
if (this.downAfterPuts !== null && this.puts.length >= this.downAfterPuts) this.down = true;
if (this.loseNext) { this.loseNext = false; throw new Error("socket hang up"); }
return reply(200, { participants, mcp: this.mcp, rig: "acct" });
}
return reply(404, { error: "not_found" });
};
}
let db: Database.Database;
let home: string;
let rigs: RigRepository;
let account: FakeAccount;
let app: Hono;
let seq = 0;
let hostId: string | null = "host-a";
beforeEach(() => {
hostId = "host-a";
db = new Database(":memory:");
migrate(db, ALL_MIGRATIONS);
rigs = new RigRepository(db);
home = mkdtempSync(join(tmpdir(), "openrig-network-account-"));
account = new FakeAccount();
app = new Hono();
app.use("*", async (c, next) => { c.set("rigRepo" as never, rigs as never); await next(); });
app.route("/api/network", networkRoutes({ home, accountFetch: account.fetch, selfHostId: () => hostId, accountTimeoutMs: 1000 }));
});
afterEach(() => { failRemoval.suffix = null; db.close(); rmSync(home, { recursive: true, force: true }); });
function seat(session: string): void {
const rig = rigs.listRigs().find((r) => r.name === "lab") ?? rigs.createRig("lab");
const node = rigs.addNode(rig.id, `n${++seq}`, { runtime: "claude-code" });
db.prepare("INSERT INTO sessions (id, node_id, session_name, status) VALUES (?, ?, ?, 'running')").run(`s${seq}`, node.id, session);
}
const post = async (path: string, body: unknown) => {
const res = await app.request(`/api/network${path}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
return { status: res.status, data: await res.json() as Record<string, any> };
};
const enroll = () => post("/installations", { code: "ec1.good", site: "https://account.example" });
const connect = (session: string, name: string) => post("/bindings", { session, participantName: name });
const replace = () => post("/installations", { code: "ec1.next", site: "https://account.example", replace: true });
const status = async () => (await (await app.request("/api/network/bindings")).json()) as {
bindings: Array<{ id: string; state: string; accountRemoval: string | null; accountRemovedAt: string | null }>; installations: Array<{ installationId: string }> };
it("enrolling trades the code for a key kept privately; a used code is refused with its reason; a second enroll at the site sends nothing", async () => {
const enrolled = await enroll();
expect(enrolled).toMatchObject({ status: 201, data: { site: "https://account.example/", installationId: "inst-1", name: "lab" } });
expect(enrolled.data.id).toMatch(/^neti-[0-9a-f]{20}$/);
expect(account.instance).toBe("host-a");
const keyFile = networkInstallationKeyFile(home, enrolled.data.id);
expect(statSync(keyFile).mode & 0o777).toBe(0o600);
expect(readPrivateToken(keyFile, () => "missing")).toBe(account.key);
const listed = await (await app.request("/api/network/bindings")).text();
expect(listed).toContain(enrolled.data.id);
expect(listed).not.toContain(KEY);
const calls = account.calls;
expect(await enroll()).toMatchObject({ status: 409, data: { error: "already_enrolled" } });
expect(account.calls).toBe(calls);
expect(await post("/installations", { code: "ec1.good", site: "http://other.example", allowHttp: false })).toMatchObject({ status: 400, data: { error: "http_not_allowed" } });
expect(account.calls).toBe(calls);
// The same code at another site: the account says it's spent.
expect(await post("/installations", { code: "ec1.good", site: "https://second.example" })).toMatchObject({ status: 409, data: { error: "code_not_current" } });
});
it("connecting by name lists it with the account and binds to the account's MCP front with its bearer; a second seat keeps the first listed", async () => {
seat("a@lab");
seat("b@lab");
const enrolled = await enroll();
const alpha = await connect("a@lab", "alpha");
expect(alpha).toMatchObject({ status: 201, data: { localSession: "a@lab", hubUrl: "https://account.example/mcp", hubSeat: "alpha@acct",
installationId: enrolled.data.id, participantName: "alpha", state: "active" } });
expect(readNetworkCredential(home, alpha.data.id)).toBe("bear-alpha-1");
const beta = await connect("b@lab", "beta");
expect(beta.status).toBe(201);
expect(account.puts.at(-1)).toEqual(["alpha", "beta"]);
expect(readNetworkCredential(home, beta.data.id)).toBe("bear-beta-1");
expect(account.names.get("alpha")).toEqual({ gen: 1, state: "connected" });
});
it("a lost answer to the participant list is recovered on the next connect: the name is dropped and listed again for a fresh bearer", async () => {
seat("a@lab");
await enroll();
account.loseNext = true;
expect(await connect("a@lab", "alpha")).toMatchObject({ status: 503, data: { error: "account_unavailable" } });
expect(account.names.get("alpha")).toEqual({ gen: 1, state: "connected" });
const again = await connect("a@lab", "alpha");
expect(again.status).toBe(201);
expect(account.puts.slice(-2)).toEqual([[], ["alpha"]]);
expect(readNetworkCredential(home, again.data.id)).toBe("bear-alpha-2");
});
it("one deadline covers all of a command's account calls: a connect that needs three lists runs out of time instead of waiting for each", async () => {
seat("a@lab");
await enroll();
account.loseNext = true;
await connect("a@lab", "alpha");
// Each list now takes 600 ms against this daemon's 1 s deadline, and recovering the lost answer needs three.
account.delayMs = 600;
const late = await connect("a@lab", "alpha");
expect(late).toMatchObject({ status: 503, data: { error: "account_unavailable" } });
expect(late.data.message).toContain("this command's time with the account ran out");
expect(account.puts.at(-1)).toEqual([]);
});
it("a name another connection holds is refused, and a seat that can't be connected sends nothing to the account", async () => {
seat("a@lab");
await enroll();
expect(await connect("a@lab", "taken")).toMatchObject({ status: 409, data: { error: "name_taken" } });
const calls = account.calls;
expect(await connect("nobody@lab", "alpha")).toMatchObject({ status: 404, data: { error: "seat_not_found" } });
expect(await connect("a@lab", "Not A Name")).toMatchObject({ status: 400, data: { error: "invalid_name" } });
expect(account.calls).toBe(calls);
const listed = await (await app.request("/api/network/bindings")).json() as { bindings: unknown[] };
expect(listed.bindings).toHaveLength(0);
});
it("disconnect drops the name at the account; when the account can't be reached the binding still ends, and the retry it names drops it", async () => {
seat("a@lab");
seat("b@lab");
await enroll();
const alpha = await connect("a@lab", "alpha");
await connect("b@lab", "beta");
account.down = true;
const failed = await post("/bindings/a@lab/disconnect", {});
expect(failed).toMatchObject({ status: 502, data: { error: "account_update_unconfirmed" } });
expect(failed.data.message).toContain("couldn't confirm that the account dropped alpha");
expect(failed.data.message).toContain(`Run \`rig network disconnect ${alpha.data.id}\` again.`);
expect(account.names.get("alpha")!.state).toBe("connected");
// Status keeps saying it's unconfirmed until an answered list shows otherwise.
const status = await (await app.request("/api/network/bindings")).json() as { bindings: Array<{ id: string; accountRemoval: string | null }> };
expect(status.bindings.find((b) => b.id === alpha.data.id)!.accountRemoval).toBe("unconfirmed");
account.down = false;
const ended = await post(`/bindings/${alpha.data.id}/disconnect`, {});
expect(ended).toMatchObject({ status: 200, data: { state: "disconnected", accountRemoval: "confirmed" } });
expect(ended.data.accountRemovedAt).not.toBeNull();
expect(account.puts.at(-1)).toEqual(["beta"]);
expect(account.names.get("alpha")!.state).toBe("revoked");
expect(account.names.get("beta")!.state).toBe("connected");
});
it("a bind that fails here after the account listed the name has the account drop it again", async () => {
seat("a@lab");
await enroll();
// The account answers a plain-http front, which this binding has no --allow-http choice for.
account.mcp = "http://account.example/mcp";
expect(await connect("a@lab", "alpha")).toMatchObject({ status: 400, data: { error: "http_not_allowed" } });
expect(account.puts).toEqual([["alpha"], []]);
expect(account.names.get("alpha")!.state).toBe("revoked");
});
it("a participant list the account couldn't apply is retried once, and a second failure says to try again, not that the name is taken", async () => {
seat("a@lab");
seat("b@lab");
await enroll();
account.dropFails = 1;
expect((await connect("a@lab", "alpha")).status).toBe(201);
expect(account.names.get("alpha")).toEqual({ gen: 1, state: "connected" });
account.dropFails = 2;
expect(await connect("b@lab", "beta")).toMatchObject({ status: 503, data: { error: "account_busy" } });
const listed = await (await app.request("/api/network/bindings")).json() as { bindings: Array<{ localSession: string }> };
expect(listed.bindings.map((b) => b.localSession)).toEqual(["a@lab"]);
});
it("the installation key goes only to the enrolled site: a redirect isn't followed with it, and a stalled call ends", async () => {
seat("a@lab");
account.redirectNext = true;
expect(await enroll()).toMatchObject({ status: 502, data: { error: "account_redirected" } });
expect(account.inits.every((i) => i.redirect === "error" && i.signal instanceof AbortSignal)).toBe(true);
expect(account.used.size).toBe(0);
await enroll();
account.stallNext = true;
const stalled = await connect("a@lab", "alpha");
expect(stalled).toMatchObject({ status: 503, data: { error: "account_unavailable" } });
expect(account.inits.every((i) => i.redirect === "error")).toBe(true);
});
it("concurrent account writes keep every active name: two connects started together, and a disconnect racing a connect", async () => {
seat("a@lab"); seat("b@lab"); seat("c@lab");
await enroll();
account.delayMs = 20;
const [alpha, beta] = await Promise.all([connect("a@lab", "alpha"), connect("b@lab", "beta")]);
expect([alpha.status, beta.status]).toEqual([201, 201]);
expect(account.names.get("alpha")!.state).toBe("connected");
expect(account.names.get("beta")!.state).toBe("connected");
const [dropped, gamma] = await Promise.all([post(`/bindings/${alpha.data.id}/disconnect`, {}), connect("c@lab", "gamma")]);
expect(dropped).toMatchObject({ status: 200, data: { accountRemoval: "confirmed" } });
expect(gamma.status).toBe(201);
expect(account.names.get("alpha")!.state).toBe("revoked");
expect(account.names.get("beta")!.state).toBe("connected");
expect(account.names.get("gamma")!.state).toBe("connected");
expect(readNetworkCredential(home, gamma.data.id)).toBe(`bear-gamma-1`);
});
it("enroll --replace repairs an installation the account forgot: the old key works until the new code is accepted, and old bindings are ended and named", async () => {
seat("a@lab"); seat("b@lab");
const first = await enroll();
const alpha = await connect("a@lab", "alpha");
account.forget();
const refused = await connect("b@lab", "beta");
expect(refused).toMatchObject({ status: 401, data: { error: "not_enrolled" } });
expect(refused.data.message).toContain("rig network enroll <code> --site https://account.example/ --replace");
expect(await enroll()).toMatchObject({ status: 409, data: { error: "already_enrolled" } });
// A refused new code changes nothing.
const keyFile = networkInstallationKeyFile(home, first.data.id);
const oldKey = readPrivateToken(keyFile, () => "missing");
expect(await post("/installations", { code: "ec1.spent", site: "https://account.example", replace: true })).toMatchObject({ status: 400 });
expect(readPrivateToken(keyFile, () => "missing")).toBe(oldKey);
const replaced = await post("/installations", { code: "ec1.next", site: "https://account.example", replace: true });
expect(replaced).toMatchObject({ status: 201, data: { id: first.data.id, installationId: "inst-2",
replaced: { endedBindings: [{ id: alpha.data.id, localSession: "a@lab", participantName: "alpha" }] } } });
expect(readPrivateToken(keyFile, () => "missing")).toBe(account.key);
const status = await (await app.request("/api/network/bindings")).json() as { bindings: Array<{ id: string; state: string; accountRemoval: string | null }> };
expect(status.bindings.find((b) => b.id === alpha.data.id)).toMatchObject({ state: "disconnected", accountRemoval: "old-installation" });
expect((await connect("b@lab", "beta")).status).toBe(201);
});
it("a lost installation key is repaired the same way, and a failed replace keeps the old enrollment working", async () => {
seat("a@lab"); seat("b@lab");
const first = await enroll();
await connect("a@lab", "alpha");
// A refused replace leaves the old key in use.
expect(await post("/installations", { code: "ec1.spent", site: "https://account.example", replace: true })).toMatchObject({ status: 400 });
expect((await connect("b@lab", "beta")).status).toBe(201);
rmSync(join(networkInstallationKeyFile(home, first.data.id), ".."), { recursive: true });
seat("c@lab");
const missing = await connect("c@lab", "gamma");
expect(missing).toMatchObject({ status: 404, data: { error: "credential_missing" } });
expect(missing.data.message).toContain("--replace");
expect((await post("/installations", { code: "ec1.next", site: "https://account.example", replace: true })).status).toBe(201);
expect((await connect("c@lab", "gamma")).status).toBe(201);
});
it("a replace that fails here after the account accepted the new code puts the old key back", async () => {
const first = await enroll();
const keyFile = networkInstallationKeyFile(home, first.data.id);
const oldKey = readPrivateToken(keyFile, () => "missing");
db.exec("CREATE TRIGGER fail_replace BEFORE UPDATE ON network_installations BEGIN SELECT RAISE(ABORT, 'disk I/O error'); END");
const res = await app.request("/api/network/installations", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: "ec1.next", site: "https://account.example", replace: true }) });
expect(res.status).toBe(500);
expect(account.used.has("ec1.next")).toBe(true);
expect(readPrivateToken(keyFile, () => "missing")).toBe(oldKey);
expect(readdirSync(dirname(keyFile))).toEqual(["key.json"]);
const status = await (await app.request("/api/network/bindings")).json() as { installations: Array<{ installationId: string }> };
expect(status.installations.map((i) => i.installationId)).toEqual(["inst-1"]);
});
it("an unresolved host id reads as try again, not enroll again, and sends nothing", async () => {
seat("a@lab");
await enroll();
hostId = null;
const calls = account.calls;
expect(await connect("a@lab", "alpha")).toMatchObject({ status: 503, data: { error: "instance_unknown" } });
expect(account.calls).toBe(calls);
});
it("a failed bind whose drop at the account also fails says the account may still list the name", async () => {
seat("a@lab");
await enroll();
account.mcp = "http://account.example/mcp";
account.downAfterPuts = 1;
const refused = await connect("a@lab", "alpha");
expect(refused).toMatchObject({ status: 400, data: { error: "http_not_allowed" } });
expect(refused.data.message).toContain("OpenRig couldn't confirm the account dropped alpha again");
expect(account.names.get("alpha")!.state).toBe("connected");
});
it("an unconfirmed removal is confirmed by a later answered list that no longer has the name", async () => {
seat("a@lab"); seat("b@lab");
await enroll();
const alpha = await connect("a@lab", "alpha");
account.down = true;
expect((await post(`/bindings/${alpha.data.id}/disconnect`, {})).data.error).toBe("account_update_unconfirmed");
account.down = false;
expect((await connect("b@lab", "beta")).status).toBe(201);
const status = await (await app.request("/api/network/bindings")).json() as { bindings: Array<{ id: string; accountRemoval: string | null }> };
expect(status.bindings.find((b) => b.id === alpha.data.id)!.accountRemoval).toBe("confirmed");
});
it("a name-only connect without an enrollment says how to enroll", async () => {
seat("a@lab");
const refused = await connect("a@lab", "alpha");
expect(refused).toMatchObject({ status: 404, data: { error: "not_enrolled" } });
expect(refused.data.message).toContain("rig network enroll <code> --site <site>");
expect(account.calls).toBe(0);
});
it("an unconfirmed drop from before a replace stays with the old installation: the new installation's lists never confirm it", async () => {
seat("a@lab"); seat("b@lab");
await enroll();
const alpha = await connect("a@lab", "alpha");
account.down = true;
expect(await post(`/bindings/${alpha.data.id}/disconnect`, {})).toMatchObject({ status: 502, data: { error: "account_update_unconfirmed" } });
account.down = false;
expect(await replace()).toMatchObject({ status: 201, data: { installationId: "inst-2", replaced: { endedBindings: [] } } });
const alphaNow = async () => (await status()).bindings.find((b) => b.id === alpha.data.id);
expect(await alphaNow()).toMatchObject({ accountRemoval: "old-installation", accountRemovedAt: null });
// The new installation's list doesn't have alpha, which was never listed there.
expect((await connect("b@lab", "beta")).status).toBe(201);
expect(account.puts.at(-1)).toEqual(["beta"]);
expect(await alphaNow()).toMatchObject({ accountRemoval: "old-installation", accountRemovedAt: null });
// Disconnecting it again sends nothing this key could use and claims no drop.
const puts = account.puts.length;
expect(await post(`/bindings/${alpha.data.id}/disconnect`, {})).toMatchObject({ status: 200, data: { accountRemoval: "old-installation", accountRemovedAt: null } });
expect(account.puts.length).toBe(puts);
});
it("a plain local failure to save the binding, with the account's drop failing too, still says the account may list the name", async () => {
seat("a@lab");
await enroll();
db.exec("CREATE TRIGGER fail_bind BEFORE INSERT ON network_bindings BEGIN SELECT RAISE(ABORT, 'disk I/O error'); END");
account.downAfterPuts = 1;
const failed = await connect("a@lab", "alpha");
expect(failed).toMatchObject({ status: 500, data: { error: "binding_write_failed" } });
expect(failed.data.message).toContain("disk I/O error");
expect(failed.data.message).toContain("couldn't confirm the account dropped alpha again");
expect(JSON.stringify(failed.data)).not.toContain("bear-alpha");
expect(account.names.get("alpha")!.state).toBe("connected");
});
it("a replace whose wait for the installation's turn uses up its deadline sends no code and changes nothing", async () => {
seat("a@lab");
const first = await enroll();
const keyFile = networkInstallationKeyFile(home, first.data.id);
const oldKey = readPrivateToken(keyFile, () => "missing");
// A list that overruns holds the installation's turn past the replace's 1 s deadline.
account.delayMs = 1800;
const slow = connect("a@lab", "alpha");
await sleep(50);
const late = await replace();
expect(late).toMatchObject({ status: 503, data: { error: "account_unavailable" } });
expect(late.data.message).toContain("The code wasn't sent");
expect(account.used.has("ec1.next")).toBe(false);
expect(readPrivateToken(keyFile, () => "missing")).toBe(oldKey);
await slow;
});
it("a replace that fails here with only the key file missing leaves no key behind, as before it, and says what the account now has", async () => {
seat("a@lab");
const first = await enroll();
const keyFile = networkInstallationKeyFile(home, first.data.id);
rmSync(keyFile);
db.exec("CREATE TRIGGER fail_replace BEFORE UPDATE ON network_installations BEGIN SELECT RAISE(ABORT, 'disk I/O error'); END");
const failed = await replace();
expect(failed).toMatchObject({ status: 500, data: { error: "replace_failed" } });
expect(failed.data.message).toContain("nothing here changed");
expect(failed.data.message).toContain("a new installation whose key nothing here holds");
expect(readdirSync(dirname(keyFile))).toEqual([]);
expect((await status()).installations.map((i) => i.installationId)).toEqual(["inst-1"]);
db.exec("DROP TRIGGER fail_replace");
expect(await connect("a@lab", "alpha")).toMatchObject({ status: 404, data: { error: "credential_missing" } });
});
it("a replace whose old binding can't be ended changes nothing here: the switch and the bindings' end commit together", async () => {
seat("a@lab");
const first = await enroll();
const alpha = await connect("a@lab", "alpha");
const keyFile = networkInstallationKeyFile(home, first.data.id);
const oldKey = readPrivateToken(keyFile, () => "missing");
db.exec("CREATE TRIGGER fail_end BEFORE UPDATE OF state ON network_bindings BEGIN SELECT RAISE(ABORT, 'disk I/O error'); END");
expect(await replace()).toMatchObject({ status: 500, data: { error: "replace_failed" } });
expect(readPrivateToken(keyFile, () => "missing")).toBe(oldKey);
expect(readdirSync(dirname(keyFile))).toEqual(["key.json"]);
const now = await status();
expect(now.installations.map((i) => i.installationId)).toEqual(["inst-1"]);
expect(now.bindings.find((b) => b.id === alpha.data.id)).toMatchObject({ state: "active" });
});
it("a replace whose old key's backup can't be removed after the switch still ends the old bindings, and reports the cleanup", async () => {
seat("a@lab");
const first = await enroll();
const alpha = await connect("a@lab", "alpha");
failRemoval.suffix = "key.json.prev";
const replaced = await replace();
failRemoval.suffix = null;
expect(replaced).toMatchObject({ status: 201, data: { installationId: "inst-2", replaced: { endedBindings: [{ id: alpha.data.id, participantName: "alpha" }] } } });
expect(replaced.data.replaced.problems).toEqual([expect.stringMatching(/backup at .*key\.json\.prev couldn't be removed \(EBUSY/)]);
expect(readPrivateToken(networkInstallationKeyFile(home, first.data.id), () => "missing")).toBe(account.key);
expect((await status()).bindings.find((b) => b.id === alpha.data.id)).toMatchObject({ state: "disconnected", accountRemoval: "old-installation" });
});
@@ -112,6 +112,40 @@ describe("Slice-04 C3 — seat-keyed Claude provider_usage discovery (production
}
});
it("a live seat whose only cache is malformed reports unreadable_cache through real getReadModel", async () => {
const root = fs.mkdtempSync(nodePath.join(os.tmpdir(), "provider-c3-unreadable-"));
try {
const usageDir = nodePath.join(root, "provider-usage");
fs.mkdirSync(usageDir, { recursive: true });
const seatSession = "dev-impl@test-rig";
fs.writeFileSync(nodePath.join(usageDir, `${seatSession}.json`), "{ not json");
const db = createFullTestDb();
seedClaudeSeat(db, seatSession);
const svc = new ProviderServiceImpl({
db,
listRigs: () => [{ id: "rig-1" }],
env: emptyCodexHomeEnv(),
now: () => ASOF,
collectClaudeSignals: () => collectClaudeSignalsFromProviderUsageDirectory(usageDir, () => ASOF),
});
const model = await svc.getReadModel();
const claude = model.signals.filter((signal) => signal.provider === "claude");
expect(claude).toHaveLength(1);
expect(claude[0]).toMatchObject({
seatSession,
sourceClass: "unknown",
authority: "unknown",
unknownReason: "claude_statusline_cache_unreadable",
automationUse: "do_not_automate",
});
expect(claude[0]!.usedPercent).toBeUndefined();
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
it("an exited latest Claude session plus matching stale cache emits zero Claude signals", async () => {
const root = fs.mkdtempSync(nodePath.join(os.tmpdir(), "provider-c3-exited-"));
try {
@@ -76,12 +76,20 @@ describe("collectClaudeStatuslineSignals — reader → provider_statusline / ex
expect(sigs[0]!.unknownReason).toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
});
it("malformed cache JSON → unknown(empty_reading), never throws", () => {
it("malformed cache JSON → unknown(unreadable_cache), never throws", () => {
const sigs = collectClaudeStatuslineSignals(readerDeps({
listClaudeSeats: () => [{ seatSession: "dev-impl@rig" }],
readCacheRaw: () => "{ not json",
}));
expect(sigs[0]!.sourceClass).toBe("unknown");
expect(sigs[0]!.unknownReason).toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
});
it("a valid cache with no windows keeps empty_reading (distinct from an unreadable cache)", () => {
const sigs = collectClaudeStatuslineSignals(readerDeps({
listClaudeSeats: () => [{ seatSession: "dev-impl@rig" }],
readCacheRaw: () => JSON.stringify({ seatSession: "dev-impl@rig", asOf: CACHE_ASOF }),
}));
expect(sigs[0]!.unknownReason).toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
});
});
@@ -111,14 +119,33 @@ describe("0.5.8 provider-usage compatibility bridge", () => {
const malformedCanonical = collectClaudeSignalsFromProviderUsageDirectory(canonical, () => ASOF, legacy);
expect(malformedCanonical.find((signal) => signal.seatSession === "legacy@rig")?.usedPercent).toBeUndefined();
expect(malformedCanonical.find((signal) => signal.seatSession === "legacy@rig")?.unknownReason)
.toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
.toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
fs.rmSync(path.join(canonical, "[email protected]"));
fs.mkdirSync(path.join(canonical, "[email protected]"));
const wrongTypeCanonical = collectClaudeSignalsFromProviderUsageDirectory(canonical, () => ASOF, legacy);
expect(wrongTypeCanonical.find((signal) => signal.seatSession === "legacy@rig")?.usedPercent).toBeUndefined();
expect(wrongTypeCanonical.find((signal) => signal.seatSession === "legacy@rig")?.unknownReason)
.toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
.toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
fs.rmSync(home, { recursive: true, force: true });
});
});
describe("collectClaudeSignalsFromProviderUsageDirectory — a malformed-only cache stays visible", () => {
it("reports unreadable_cache for a seat known only from a malformed file's name", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "provider-unreadable-"));
const directory = path.join(home, "state", "provider-usage");
fs.mkdirSync(directory, { recursive: true });
fs.writeFileSync(path.join(directory, "[email protected]"), "{ not json");
try {
const signals = collectClaudeSignalsFromProviderUsageDirectory(directory, () => ASOF);
expect(signals).toHaveLength(1);
expect(signals[0]!.seatSession).toBe("broken@rig");
expect(signals[0]!.sourceClass).toBe("unknown");
expect(signals[0]!.unknownReason).toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
expect(signals[0]!.usedPercent).toBeUndefined();
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
});
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { claudeStatuslineSignals } from "../src/domain/provider/provider-signals.js";
import { CLAUDE_UNKNOWN_REASON, claudeStatuslineSignals } from "../src/domain/provider/provider-signals.js";
// Slice-04 (OPR.0.5.0.4) — Claude statusline lane normalization (§2 Claude lane + the
// 2026-07-31 RESEARCH verdict). Proof item 6: the statusline sidecar's rate_limits object
@@ -68,5 +68,21 @@ describe("claudeStatuslineSignals — Claude lane honesty", () => {
expect(sigs[0].sourceClass).toBe("unknown");
expect(sigs[0].usedPercent).toBeUndefined();
expect(sigs[0].automationUse).toBe("do_not_automate");
expect(sigs[0].unknownReason).toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
});
it("an unreadable cache gets its own reason, not the empty-reading one", () => {
const sigs = claudeStatuslineSignals({
seatSession: "dev-impl@rig",
cachePresent: true,
cacheUnreadable: true,
asOf: ASOF,
});
expect(sigs).toHaveLength(1);
expect(sigs[0].sourceClass).toBe("unknown");
expect(sigs[0].unknownReason).toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
expect(sigs[0].unknownReason).not.toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
expect(sigs[0].usedPercent).toBeUndefined();
expect(sigs[0].automationUse).toBe("do_not_automate");
});
});
+4 -2
View File
@@ -287,8 +287,10 @@ function toAgentRow(node: NodeInventoryRead): AgentRow {
: node.activityState?.display === "idle"
? "idle"
: (node.terminalActive === true || (node.terminalActive == null && node.agentActivity?.state === "running") ? "active"
// A community harness gives no idle signal: a quiet screen is unknown, not idle.
: node.communityHarness ? "unknown" : "idle"))
// The daemon's activity word decides when it's served (as in `rig ps`): it said unknown, so a quiet
// screen stays unknown. A community harness gives no idle signal either. Only an older daemon that
// serves no activity word keeps the screen fallback.
: node.communityHarness || node.activityState ? "unknown" : "idle"))
: (node.sessionStatus ?? "unknown"),
live: node.lifecycleState === "running",
canRun: node.lifecycleState !== "running"
+23 -3
View File
@@ -710,7 +710,7 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
expect(output.lines.find((line) => /\bmismatch\s/.test(line))).not.toContain("run ▸");
expect(output.lines.find((line) => /\bmissing\s/.test(line))).not.toContain("run ▸");
});
it("shows a quiet community harness as unknown, never idle, and leaves built-in rows as they were", async () => {
it("shows a quiet seat the daemon reads as unknown as unknown, never idle, for a community harness and a built-in runtime alike", async () => {
const base = (FIXTURES["/api/rigs/01JRIG/nodes"] as Array<Record<string, unknown>>)[0]!;
const quiet = { startupStatus: "ready", sessionStatus: "running", terminalActive: false, agentActivity: { state: "unknown" },
activityState: { activity: "unknown", display: "unknown", needsInput: { count: 0, reason: null }, decidedBy: null } };
@@ -727,8 +727,28 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
"dev.app-quiet": "unknown",
"dev.app-moving": "active",
"dev.app-prompt": "needs_input",
"dev.pi-quiet": "idle",
"dev.claude-quiet": "idle",
"dev.pi-quiet": "unknown",
"dev.claude-quiet": "unknown",
});
});
it("keeps the daemon's idle and working words, and the screen fallback only for a daemon that serves no activity word", async () => {
const base = (FIXTURES["/api/rigs/01JRIG/nodes"] as Array<Record<string, unknown>>)[0]!;
const running = { startupStatus: "ready", sessionStatus: "running", runtime: "claude-code", agentActivity: { state: "unknown" } };
const served = (display: string) => ({ activity: display === "idle" ? "idle-at-prompt" : display, display, needsInput: { count: 0, reason: null }, decidedBy: null });
const nodes = [
{ ...base, ...running, logicalId: "dev.served-idle", terminalActive: false, activityState: served("idle") },
{ ...base, ...running, logicalId: "dev.served-working", terminalActive: false, activityState: served("working") },
{ ...base, ...running, logicalId: "dev.unknown-moving", terminalActive: true, activityState: served("unknown") },
{ ...base, ...running, logicalId: "dev.legacy-quiet", terminalActive: false, activityState: undefined },
];
const snap = await hydrateSnapshot(fixtureClient({}, { "/api/rigs/01JRIG/nodes": nodes }));
const statuses = Object.fromEntries(myrig(snap).pods[0]!.agents.map((agent) => [agent.name, agent.status]));
expect(statuses).toEqual({
"dev.served-idle": "idle",
"dev.served-working": "active",
"dev.unknown-moving": "active",
"dev.legacy-quiet": "idle",
});
});
+2 -1
View File
@@ -4,6 +4,7 @@ import fs from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { packEntry } from "./npm-pack-json.mjs";
const script = fs.readFileSync(new URL("./build-package.sh", import.meta.url), "utf8");
const assetsBranch = script.match(/^if \[ -d "\$DAEMON_DIR\/assets" \]; then\n[\s\S]*?^fi$/m)?.[0];
@@ -129,7 +130,7 @@ test("offline npm pack carries staged asset bytes and executability, with npm's
assertStaged(f);
const staged = snapshot(f.staged);
fs.writeFileSync(join(f.cli, "package.json"), JSON.stringify({ name: "openrig-assets-fixture", version: "1.0.0", private: true, files: ["daemon"] }));
const packed = JSON.parse(execFileSync("bash", ["-c", 'umask 0022; exec npm pack --offline --ignore-scripts --json --pack-destination "$1"', "asset-pack", f.root], { cwd: f.cli, encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"] }))[0];
const packed = packEntry(execFileSync("bash", ["-c", 'umask 0022; exec npm pack --offline --ignore-scripts --json --pack-destination "$1"', "asset-pack", f.root], { cwd: f.cli, encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"] }));
const files = new Map(packed.files.map(file => [file.path, file]));
for (const [name, bytes] of Object.entries(contents)) {
const entry = `daemon/assets/${name}`;
+2 -2
View File
@@ -2,14 +2,14 @@ import { execSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { test } from "node:test";
import assert from "node:assert/strict";
import { packEntry } from "./npm-pack-json.mjs";
test("npm pack of @openrig/cli includes scripts/check-abi.mjs in tarball", () => {
const output = execSync("npm pack --dry-run --json 2>/dev/null", {
cwd: "packages/cli",
encoding: "utf-8",
});
const entries = JSON.parse(output);
const files = entries[0]?.files?.map((f) => f.path) ?? [];
const files = packEntry(output).files?.map((f) => f.path) ?? [];
assert.ok(
files.some((f) => f.includes("scripts/check-abi.mjs")),
+14 -4
View File
@@ -19,6 +19,7 @@ import {
scanInternalLeaks,
} from "./internal-leak-scanner.mjs";
import { LEAK_GUARD_RULES_ENV, leakGuardRulesPath } from "./leak-guard-rules.mjs";
import { packEntries } from "./npm-pack-json.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const FIX = "Genericize the content, cite its public home, or re-home it to the internal pack root.";
@@ -155,13 +156,22 @@ function derivePackageArtifacts(packageRoot) {
if (packed.status !== 0) {
throw new Error(`npm artifact enumeration failed:\n${packed.stderr || packed.stdout || `exit ${packed.status}`}`);
}
let parsed;
let entries = [];
try {
parsed = JSON.parse(packed.stdout);
// One parse, in the shared reader: a JSON string (even one holding a serialized list) is not a shape npm prints.
entries = packEntries(packed.stdout);
} catch (error) {
throw new Error(`npm artifact enumeration did not return JSON: ${error.message}`);
if (error instanceof SyntaxError) throw new Error(`npm artifact enumeration did not return JSON: ${error.message}`);
// not a shape npm prints: reported below as no files array
}
const rawFiles = parsed?.[0]?.files;
// One package is packed here; scanning only the first of several would issue a receipt for part of the artifact.
if (entries.length > 1) {
throw new Error(
`npm artifact enumeration listed ${entries.length} packages where one was packed; `
+ "run the gate for one package (check --package-root and any npm workspaces setting)",
);
}
const rawFiles = entries[0]?.files;
if (!Array.isArray(rawFiles)) throw new Error("npm artifact enumeration returned no files array");
const files = rawFiles.map((entry) => entry?.path).filter((path) => typeof path === "string");
if (files.length !== rawFiles.length || files.length === 0) {
+42 -3
View File
@@ -5,6 +5,7 @@ import { execFileSync, spawnSync } from "node:child_process";
import {
existsSync,
mkdirSync,
chmodSync,
mkdtempSync,
readFileSync,
rmSync,
@@ -405,6 +406,44 @@ test("exact-line allowances reach content and derived scans and are recorded in
});
});
test("npm output that is a JSON string is refused, even when the string holds a serialized list", () => {
withFixture(({ root, repo, rules, review, receipt }) => {
write(join(repo, "public/guide.md"), "Generic product guidance.\n");
commitAll(repo, "string output fixture");
writeFileSync(review, JSON.stringify({ surfaces: [] }));
const bin = join(root, "bin");
const serialized = JSON.stringify([{ files: [{ path: "package.json" }, { path: "public/guide.md" }] }]);
write(join(bin, "npm"), `#!${process.execPath}\nprocess.stdout.write(${JSON.stringify(JSON.stringify(serialized))});\n`);
chmodSync(join(bin, "npm"), 0o755);
const result = runGate({ repo, rules, review, receipt, cutSha: gitHead(repo),
env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } });
assert.notEqual(result.status, 0);
assert.match(result.stderr, /npm artifact enumeration returned no files array/);
});
});
test("npm output that lists more than one package is refused before any scan or receipt", () => {
const first = { name: "first", files: [{ path: "package.json" }, { path: "public/guide.md" }] };
const second = { name: "second", files: [{ path: "package.json" }, { path: "public/other.md" }] };
for (const printed of [[first, second], { first, second }]) {
withFixture(({ root, repo, rules, review, receipt }) => {
write(join(repo, "public/guide.md"), "Generic product guidance.\n");
commitAll(repo, "two package output fixture");
writeFileSync(review, JSON.stringify({ surfaces: [] }));
const bin = join(root, "bin");
write(join(bin, "npm"), `#!${process.execPath}\nprocess.stdout.write(${JSON.stringify(JSON.stringify(printed))});\n`);
chmodSync(join(bin, "npm"), 0o755);
const result = runGate({ repo, rules, review, receipt, cutSha: gitHead(repo),
env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } });
assert.notEqual(result.status, 0);
assert.match(result.stderr, /npm artifact enumeration listed 2 packages where one was packed; run the gate for one package \(check --package-root and any npm workspaces setting\)/);
assert.equal(existsSync(receipt), false);
});
}
});
test("the release ceremony names the gate and its durable receipt", () => {
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
assert.equal(pkg.scripts["gate:substance"], "node scripts/check-substance-gate.mjs");
@@ -420,7 +459,7 @@ function writeGateHarness(root, source, invocationPath) {
mkdirSync(root, { recursive: true });
const gate = join(root, "check-substance-gate.mjs");
writeFileSync(gate, source);
for (const module of ["internal-leak-scanner.mjs", "leak-guard-rules.mjs"]) {
for (const module of ["internal-leak-scanner.mjs", "leak-guard-rules.mjs", "npm-pack-json.mjs"]) {
writeFileSync(join(root, module), readFileSync(resolve("scripts", module)));
}
writeFileSync(join(root, "check-internal-leak-guard.mjs"), [
@@ -474,7 +513,7 @@ function withFixture(run) {
}
}
function runGate({ repo, rules, review, receipt, cutSha, gate = GATE }) {
function runGate({ repo, rules, review, receipt, cutSha, gate = GATE, env = process.env }) {
return spawnSync(process.execPath, [
gate,
"--repo", repo,
@@ -485,7 +524,7 @@ function runGate({ repo, rules, review, receipt, cutSha, gate = GATE }) {
"--receipt", receipt,
"--judge", "dev-qa@fixture",
"--cut-sha", cutSha,
], { encoding: "utf8" });
], { encoding: "utf8", env });
}
function write(path, content) {
+6 -5
View File
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { test } from "node:test";
import assert from "node:assert/strict";
import { packEntry } from "./npm-pack-json.mjs";
test("the CLI build carries the unchanged repository LICENSE and README into npm pack", () => {
const root = mkdtempSync(join(tmpdir(), "cli-license-"));
@@ -26,14 +27,14 @@ test("the CLI build carries the unchanged repository LICENSE and README into npm
execFileSync("npm", ["run", "build"], { cwd: cli, env, stdio: "pipe" });
const source = readFileSync(join(root, "LICENSE"));
assert.deepEqual(readFileSync(join(cli, "LICENSE")), source, "build must stage the repository license verbatim");
const packed = JSON.parse(execFileSync("npm", ["pack", "--ignore-scripts", "--json"], { cwd: cli, env, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }));
assert.ok(packed[0].files.some((file) => file.path === "LICENSE"), "npm archive must include LICENSE");
const bytes = execFileSync("tar", ["-xOf", join(cli, packed[0].filename), "package/LICENSE"]);
const packed = packEntry(execFileSync("npm", ["pack", "--ignore-scripts", "--json"], { cwd: cli, env, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }));
assert.ok(packed.files.some((file) => file.path === "LICENSE"), "npm archive must include LICENSE");
const bytes = execFileSync("tar", ["-xOf", join(cli, packed.filename), "package/LICENSE"]);
assert.deepEqual(bytes, source, "packed license must equal the existing root bytes");
// npm shows the package-root README on the registry page; without it the page reads "No README data found".
const readme = readFileSync(join(root, "README.md"));
assert.ok(packed[0].files.some((file) => file.path === "README.md"), "npm archive must include README.md");
const readmeBytes = execFileSync("tar", ["-xOf", join(cli, packed[0].filename), "package/README.md"]);
assert.ok(packed.files.some((file) => file.path === "README.md"), "npm archive must include README.md");
const readmeBytes = execFileSync("tar", ["-xOf", join(cli, packed.filename), "package/README.md"]);
assert.deepEqual(readmeBytes, readme, "packed README must equal the existing root bytes");
} finally {
rmSync(root, { recursive: true, force: true });
+25
View File
@@ -0,0 +1,25 @@
// `npm pack --json` prints an array of entries up to npm 11 and, from npm 12, an object keyed by package name
// ("the --json output of `npm pack` and `npm publish` have changed",
// https://github.com/npm/cli/releases/tag/v12.0.0). Every reader of that output goes through here, so both shapes
// give the same entries.
/** The entries `npm pack --json` printed, in order, from its output text or its parsed value. */
export function packEntries(output) {
const parsed = typeof output === "string" ? JSON.parse(output) : output;
const entries = Array.isArray(parsed)
? parsed
: parsed !== null && typeof parsed === "object" ? Object.values(parsed) : null;
if (!entries || !entries.every((entry) => entry !== null && typeof entry === "object" && !Array.isArray(entry))) {
throw new Error("npm pack --json printed neither an array of entries nor an object of per-package entries");
}
return entries;
}
/** The one entry of a `npm pack --json` run that packed one package; any other count throws. */
export function packEntry(output) {
const entries = packEntries(output);
if (entries.length !== 1) {
throw new Error(`npm pack --json printed ${entries.length} entries where one package was packed`);
}
return entries[0];
}
+35
View File
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { packEntries, packEntry } from "./npm-pack-json.mjs";
const entry = { id: "@openrig/[email protected]", name: "@openrig/cli", version: "1.2.3", filename: "openrig-cli-1.2.3.tgz",
files: [{ path: "LICENSE" }, { path: "package.json" }] };
test("npm 10 and 11: an array of entries", () => {
assert.deepEqual(packEntries(JSON.stringify([entry])), [entry]);
assert.deepEqual(packEntry(JSON.stringify([entry])), entry);
});
test("npm 12: an object of entries keyed by package name", () => {
assert.deepEqual(packEntries(JSON.stringify({ "@openrig/cli": entry })), [entry]);
assert.deepEqual(packEntry(JSON.stringify({ "@openrig/cli": entry })), entry);
});
test("both shapes keep every entry in order, and a parsed value reads like its text", () => {
const other = { ...entry, name: "@openrig/acp", filename: "openrig-acp-1.2.3.tgz" };
assert.deepEqual(packEntries([entry, other]), [entry, other]);
assert.deepEqual(packEntries({ "@openrig/cli": entry, "@openrig/acp": other }), [entry, other]);
});
test("packEntry refuses any count but one", () => {
assert.throws(() => packEntry("[]"), /0 entries/);
assert.throws(() => packEntry("{}"), /0 entries/);
assert.throws(() => packEntry(JSON.stringify([entry, entry])), /2 entries/);
});
test("output that is neither shape throws", () => {
for (const output of ["null", "\"text\"", "3", JSON.stringify(entry), JSON.stringify([[entry]]), JSON.stringify({ a: null })]) {
assert.throws(() => packEntries(output), /neither an array of entries nor an object of per-package entries/, output);
}
assert.throws(() => packEntries("not json"), SyntaxError);
});
+3 -2
View File
@@ -8,6 +8,7 @@ import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { packEntry } from './npm-pack-json.mjs';
const LISTING = ['packages/acp/registry/agent.json', 'packages/acp/registry/icon.svg'];
const readJson = (root, rel) => JSON.parse(readFileSync(join(root, rel), 'utf8'));
@@ -35,8 +36,8 @@ export function packRelease(root, dest) {
mkdirSync(dest, { recursive: true });
// The CLI is packed as the release producer has packed it; the launcher isn't a workspace, so it's packed by path.
// --ignore-scripts skips pack's prepack and postpack, but npm 10 still runs a `prepare`; neither package has one.
const pack = target => JSON.parse(execFileSync('npm', ['pack', ...target, '--ignore-scripts', '--json', '--pack-destination', dest],
{ cwd: root, encoding: 'utf8' }))[0];
const pack = target => packEntry(execFileSync('npm', ['pack', ...target, '--ignore-scripts', '--json', '--pack-destination', dest],
{ cwd: root, encoding: 'utf8' }));
const artifacts = [pack(['--workspace', 'packages/cli']), pack(['./packages/acp'])].map(packed => {
const path = join(dest, packed.filename);
return { name: packed.name, version: packed.version, path, size: packed.size, sha256: sha256(path), integrity: packed.integrity };
+4 -3
View File
@@ -4,6 +4,7 @@ import os from "node:os";
import path from "node:path";
import { execFileSync, spawn, spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { packEntry } from "./npm-pack-json.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const cli = path.join(root, "packages", "cli");
@@ -47,8 +48,8 @@ async function assertDelayedSplits(entry, pkgDir) {
try {
execFileSync("npm", ["run", "build:package"], { cwd: root, stdio: "inherit" });
const packed = JSON.parse(execFileSync("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", temp], { cwd: cli, encoding: "utf8" }));
const tarball = path.join(temp, packed[0].filename);
const packed = packEntry(execFileSync("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", temp], { cwd: cli, encoding: "utf8" }));
const tarball = path.join(temp, packed.filename);
execFileSync("tar", ["-xzf", tarball, "-C", temp]);
const pkgDir = path.join(temp, "package");
@@ -68,7 +69,7 @@ try {
});
assert.equal(launched.status, 0, launched.stderr || `packed TUI exited ${launched.status}`);
await assertDelayedSplits(entry, pkgDir);
console.log(`packed TUI launch PASS: ${packed[0].filename}`);
console.log(`packed TUI launch PASS: ${packed.filename}`);
} finally {
fs.rmSync(temp, { recursive: true, force: true });
}