mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-12 01:41:39 -10:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42564fb1bf | ||
|
|
f48d5a7296 | ||
|
|
6f525ccbc7 | ||
|
|
8dac2356c0 | ||
|
|
7f8d59ba16 | ||
|
|
aae4395b25 | ||
|
|
25b8f05991 | ||
|
|
a093534871 | ||
|
|
65b466235e | ||
|
|
27d5d6d206 | ||
|
|
aa71304a7d | ||
|
|
492170b1b5 |
No files matched your search
@@ -405,6 +405,11 @@ Each startup file and action specifies when it applies:
|
||||
- a launch replaces it, as launches always did, and names it (`guidance_block_refreshed_unrecorded`);
|
||||
- an exact resume or a handover leaves it, and names it as possibly stale (`guidance_block_may_be_stale`).
|
||||
|
||||
**A file keeps one copy of each block.**
|
||||
- **Touched markers:** a marker an editor or formatter rewrapped, with spaces or line breaks inside the comment, still reads as its block, and the next write puts it back in OpenRig's form.
|
||||
- **Collected copies:** they're reduced to the first. Beside a copy you edited, OpenRig's own unedited copies and exact repeats are removed, and every different edited copy stays (`guidance_block_copies_removed`).
|
||||
- **A marker that lost its pair:** OpenRig can't tell where that block starts or ends, so it adds no copy beside it and names it (`guidance_block_markers_damaged`). Put back the missing marker or remove the partial block, and OpenRig's next write keeps one block. `rig package install` leaves such a file as it is, names it in its result (`notes`), and installs the rest. A marker without its pair beside a complete block is named too (`guidance_block_marker_unpaired`), and the block is written.
|
||||
|
||||
An exact resume doesn't type anything into the resumed conversation. So there, the restore result names the block (`guidanceNotes`), but the seat isn't told.
|
||||
|
||||
Use this to avoid re-sending context that a fresh conversation doesn't need twice. For example, a one-time project briefing might only apply on `fresh_start`, while identity grounding should apply on both, so a seat that comes back fresh gets it again.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -342,9 +342,21 @@ startup reconcile leaves when every kernel terminal is gone, for example after a
|
||||
reboot. It does not detect a reboot as such. It uses the same restore as
|
||||
`rig up kernel --existing`, and `rig status` reports it as `Kernel: booting`
|
||||
until the seats are ready. A kernel stopped with `rig down kernel`, including
|
||||
one whose seats were already detached, stays down on later starts. So do a kernel
|
||||
with only some seats stopped and seats you unclaimed. Start one again with
|
||||
`rig up kernel --existing`.
|
||||
one whose seats were already detached, stays down on later starts; start it again
|
||||
with `rig up kernel --existing`. A seat you stopped or unclaimed also stays down.
|
||||
While the kernel's other seats are running, `rig up kernel --existing` refuses
|
||||
(it restores only a kernel with no live session), so start one stopped seat with
|
||||
`rig launch kernel <logical id>` (`--plan` first). It restores that seat from the
|
||||
kernel snapshot that restore ranks first: the newest usable automatic one (taken
|
||||
before `rig down`, on the periodic schedule, or at a runtime switch), even when a
|
||||
manual one is newer, and otherwise the newest usable one. `--plan` names the
|
||||
snapshot it picked and any newer usable one, and `--snapshot-id <id>` picks one
|
||||
yourself. Under the default restore policy, which the kernel's seats keep, it
|
||||
resumes the seat's saved conversation; a seat it can't resume is reported as
|
||||
waiting for a decision, not as started.
|
||||
`rig seat launch <seat> --fresh --reason "…"` instead starts a new conversation
|
||||
with no continuity source: use it only when you want a fresh occupant, since the
|
||||
seat's earlier conversation isn't carried.
|
||||
When the tmux server is gone, an explicit `rig down` of any rig can start an
|
||||
empty server and probe again. It records the stop and clears eligible bindings
|
||||
only after confirming the sessions are absent. This can leave an empty tmux
|
||||
|
||||
@@ -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.
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
OPENRIG_DIR,
|
||||
STATE_FILE,
|
||||
resolveBindIntent,
|
||||
statusGuardMessage,
|
||||
} from "../daemon-lifecycle.js";
|
||||
|
||||
interface ProcessAliveDeps {
|
||||
@@ -37,14 +38,16 @@ export function createIsProcessAlive(deps: ProcessAliveDeps): (pid: number) => b
|
||||
|
||||
export type SignalOutcome = "sent" | "missing" | "not-permitted";
|
||||
|
||||
/** kill(pid, 0) as an outcome. POSIX: only ESRCH means no such process; EPERM means the process
|
||||
* EXISTS but this shell may not signal it (another user's process, or a sandbox such as Codex's). */
|
||||
/** kill(pid, 0) as an outcome. POSIX: kill(pid, 0) fails only with ESRCH (no such process) or EPERM
|
||||
* (the process EXISTS but this shell may not signal it: another user's process, or a sandbox such as
|
||||
* Codex's). Only ESRCH is "missing"; any other error is not evidence of death, so it reads as
|
||||
* "not-permitted" (uninspectable), never as a dead process. */
|
||||
export function signalProbe(pid: number, kill: (pid: number, signal: 0) => unknown = process.kill): SignalOutcome {
|
||||
try {
|
||||
kill(pid, 0);
|
||||
return "sent";
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code === "EPERM" ? "not-permitted" : "missing";
|
||||
return (error as NodeJS.ErrnoException).code === "ESRCH" ? "missing" : "not-permitted";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +162,11 @@ export function realDeps(): LifecycleDeps {
|
||||
isProcessAlive,
|
||||
// RULING 1ae863d2 — sibling-home scan (home-resolution honesty).
|
||||
listDir: (p) => { try { return fs.readdirSync(p); } catch { return []; } },
|
||||
// #2860: where the OS has pid namespaces (Linux), a missing pid is only proof from the daemon's own.
|
||||
pidNamespace: () => {
|
||||
if (process.platform !== "linux") return "host";
|
||||
try { return fs.readlinkSync("/proc/self/ns/pid"); } catch { return undefined; }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -369,6 +377,12 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
|
||||
console.log(` resolved: ${status.siblingHint.resolvedHome}`);
|
||||
console.log(` sibling: ${status.siblingHint.siblingHome}`);
|
||||
console.log(" Fix: point OPENRIG_HOME at the right home (or check your shell env) — this CLI is likely resolving the wrong home.");
|
||||
} else if (status.pidUnseen) {
|
||||
// #2860: the same words as the command guard.
|
||||
const unseen = statusGuardMessage(status);
|
||||
console.log(unseen.fact);
|
||||
console.log(` ${unseen.consequence}`);
|
||||
console.log(` ${unseen.action}`);
|
||||
} else if (!status.startPending) {
|
||||
// A pending start's probe was refused, not inconclusive; its line above says what is known.
|
||||
console.log("Daemon state UNVERIFIED — the probe timed out or was inconclusive (this is NOT evidence the daemon is down).");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -137,6 +137,7 @@ export function packageCommand(depsOverride?: StatusDeps): Command {
|
||||
conflicts?: Array<{ existingPath: string; reason: string }>;
|
||||
verification?: { passed: boolean };
|
||||
policyRejected?: Array<{ entry: { exportType: string; exportName: string }; reason: string }>;
|
||||
notes?: string[];
|
||||
error?: string;
|
||||
errors?: string[];
|
||||
code?: string;
|
||||
@@ -204,6 +205,11 @@ export function packageCommand(depsOverride?: StatusDeps): Command {
|
||||
console.log(` ${r.entry.exportType.padEnd(12)} ${r.entry.exportName.padEnd(20)} ${r.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (res.data.notes && res.data.notes.length > 0) {
|
||||
console.log("Left as it is:");
|
||||
for (const note of res.data.notes) console.log(` ${note}`);
|
||||
}
|
||||
});
|
||||
|
||||
// rig package rollback <installId>
|
||||
|
||||
@@ -1395,6 +1395,13 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
.option("--summary <text>", "Summary persisted onto the item (human-seat parks, or standalone summary update without --state)")
|
||||
.option("--evidence-ref <path>", "Pointer to the durable artifact, persisted onto the item on a human-seat park or a done close (--state done). For a code result, a JSON file of kind openrig.code-result/v1 (repo, base and revision as full commit ids, paths) lets a reader diff it exactly")
|
||||
.option("--note <text>", "Transition note for the audit log")
|
||||
.addHelpText("after", `
|
||||
After reading an existing remote successor, record its exact closure target, including the host:
|
||||
rig queue update <source-id> --note "custody-verified: receiver@team@remote-host. Registered-host read confirmed the successor"
|
||||
The note can accompany the closing update or be appended later; a separate note is not required.
|
||||
A trailing period, comma, semicolon, colon or closing parenthesis is accepted; a longer or different target is not.
|
||||
For a new cross-host transfer, prefer rig queue handoff --host <registered-host>.
|
||||
`)
|
||||
.option("--source <name@external>", "Outside a managed seat: the external routine that appends the --note, recorded as an external actor (claimed:v1), never a seat; note only, on this host's daemon (a --host that resolves to another host is refused). A managed seat stays the actor. Like any note, it resets the item's stall and notification timing (and its recovery and operator episodes) and advances a claimed item's last activity, but never resets how long an unclaimed item has waited")
|
||||
.option("--host <id>", QUEUE_HOST_ROW_OPTION_HELP)
|
||||
.option("--full", "Print the whole response row(s) as today, even when output.compact is on (the receipt's waiting keeps only blocker and nextBackstop)")
|
||||
|
||||
@@ -22,8 +22,15 @@ export interface DaemonState {
|
||||
host?: string;
|
||||
db: string;
|
||||
startedAt: string;
|
||||
/** The pid namespace (Linux `/proc/self/ns/pid`) of the shell that started the daemon. A shell
|
||||
* in another namespace cannot tell a missing pid from an invisible one, nor map the number.
|
||||
* Absent on older records. */
|
||||
pidNamespace?: string;
|
||||
}
|
||||
|
||||
/** Linux' initial pid namespace: where an older record, which names no namespace, is read. */
|
||||
const INITIAL_PID_NAMESPACE = "pid:[4026531836]";
|
||||
|
||||
/**
|
||||
* OPR.0.4.3.21 — event-loop wedge evidence read from the enriched `/healthz`
|
||||
* body. Present only when the daemon actually answered healthz with a monitor
|
||||
@@ -64,6 +71,10 @@ export interface DaemonStatus {
|
||||
eventLoop?: DaemonEventLoopEvidence;
|
||||
/** #275 — the OS code behind a failed health probe (e.g. EPERM when a sandbox blocked it). */
|
||||
probeErrorCode?: string;
|
||||
/** The recorded pid looks missing and the connection was refused, but this shell is not in the
|
||||
* daemon's pid namespace and no clean-shutdown receipt matches: nothing here shows the daemon is
|
||||
* down (#2860). */
|
||||
pidUnseen?: true;
|
||||
/** T2 — a refused listener under a live start reservation (daemon-start.lock): a launch is in
|
||||
* progress and not listening yet. `pid` is the live launcher (else child) PID; `since` its start. */
|
||||
startPending?: { pid: number; since?: string };
|
||||
@@ -152,6 +163,15 @@ export function statusGuardMessage(status: DaemonStatus): DaemonNotRunningError
|
||||
...causeCode,
|
||||
};
|
||||
}
|
||||
// #2860: a refused probe and a missing pid, seen from outside the daemon's pid namespace.
|
||||
if (status.pidUnseen) {
|
||||
return {
|
||||
fact: "Couldn't connect to the daemon from this shell; it may still be running.",
|
||||
consequence: "This shell can't see the daemon's process (a sandbox or another namespace) and no clean shutdown was recorded, so a refused connection here doesn't show it is down.",
|
||||
action: "Don't start another daemon from here. Check from a normal terminal with 'rig daemon status'.",
|
||||
...causeCode,
|
||||
};
|
||||
}
|
||||
// unverified, or running-but-unhealthy: we do NOT know it is down.
|
||||
return {
|
||||
fact: "Daemon did not respond — it may be busy or stopped (state not confirmed).",
|
||||
@@ -248,6 +268,9 @@ export interface LifecycleDeps {
|
||||
/** #275 — three-state liveness for status reads (production: realDeps). Optional so existing
|
||||
* mocks keep the boolean rule; absent = derived from isProcessAlive. */
|
||||
processLiveness?: (pid: number) => ProcessLiveness;
|
||||
/** This shell's pid namespace ("host" where the OS has none, undefined when unreadable).
|
||||
* Production: realDeps. Absent = no namespace evidence, a missing pid counts as dead. */
|
||||
pidNamespace?: () => string | undefined;
|
||||
// OPR.0.4.2.1 — optional injectable delay for the status-probe bounded settle/retry.
|
||||
// Defaults to a real setTimeout in production; tests pass a no-op to stay fast. Optional so
|
||||
// existing deps / mocks / callers are untouched.
|
||||
@@ -889,7 +912,8 @@ async function startOwnedDaemon(opts: StartOptions, deps: LifecycleDeps, lock: D
|
||||
}
|
||||
assertChild();
|
||||
if (!deps.isProcessAlive(pid!)) throw new Error(`Daemon child ${pid} liveness could not be confirmed; state not published`);
|
||||
const state: DaemonState = { pid: pid!, port, host: probeHost, db, startedAt: new Date().toISOString() };
|
||||
const namespace = deps.pidNamespace?.();
|
||||
const state: DaemonState = { pid: pid!, port, host: probeHost, db, startedAt: new Date().toISOString(), ...(namespace && namespace !== "host" ? { pidNamespace: namespace } : {}) };
|
||||
try {
|
||||
deps.writeFile(STATE_FILE, JSON.stringify(state, null, 2));
|
||||
assertChild();
|
||||
@@ -950,6 +974,17 @@ function isCleanShutdown(receipt: DaemonShutdownReceipt | undefined): boolean {
|
||||
return receipt?.outcome === "clean" && receipt.phase === "complete" && receipt.failures.length === 0;
|
||||
}
|
||||
|
||||
/** Whether the recorded pid number means the same process in this shell. A recorded namespace must
|
||||
* be this shell's own: another namespace (even the host's) numbers processes differently, so its
|
||||
* missing number says nothing. Only an older record, which names none, is read as the host's. */
|
||||
function sharesPidNamespace(state: DaemonState, deps: LifecycleDeps): boolean {
|
||||
if (!deps.pidNamespace) return true;
|
||||
const own = deps.pidNamespace();
|
||||
if (own === undefined) return false;
|
||||
if (state.pidNamespace) return own === state.pidNamespace;
|
||||
return own === "host" || own === INITIAL_PID_NAMESPACE;
|
||||
}
|
||||
|
||||
function removeMatchingState(deps: LifecycleDeps, stateFile: string, state: DaemonState): void {
|
||||
// A concurrent start/rebind must not have its state removed by an old stop/status.
|
||||
const current = readState(deps);
|
||||
@@ -1153,7 +1188,23 @@ export async function getDaemonStatus(
|
||||
return { state: "running", port: Number(url.port) || DEFAULT_PORT, host: url.hostname || DEFAULT_HOST, healthy: ev.healthy, reason: ev.reason, eventLoop: ev.eventLoop, ...(ev.databaseWarnings ? { databaseWarnings: ev.databaseWarnings } : {}) };
|
||||
} catch (err) {
|
||||
// 1ae863d2: refusal = positive down; anything else (timeout/wedged/blocked) = unverified.
|
||||
return isRefusedError(err) ? { state: "stopped" } : { state: "unverified", ...endpoint, ...probeErrorCodeOf(err) };
|
||||
if (!isRefusedError(err)) return { state: "unverified", ...endpoint, ...probeErrorCodeOf(err) };
|
||||
// #2860: a loopback URL that is this home's recorded daemon is judged like the record: a refusal
|
||||
// alone proves nothing from a shell that can't see the pid. No record for it keeps the plain rule.
|
||||
const recorded = "explicitRemote" in endpoint ? null : readState(deps);
|
||||
if (!recorded || recorded.port !== ("port" in endpoint ? endpoint.port : undefined)) return { state: "stopped" };
|
||||
const recordedHost = recorded.host ?? DEFAULT_HOST;
|
||||
const recordedLiveness: ProcessLiveness = deps.processLiveness
|
||||
? deps.processLiveness(recorded.pid)
|
||||
: deps.isProcessAlive(recorded.pid) ? "alive" : "dead";
|
||||
const { receipt } = readShutdownReceipt(deps, resolveLifecycleFile(deps, "daemon.json"));
|
||||
if ((receiptMatchesState(receipt, recorded) && isCleanShutdown(receipt))
|
||||
|| (recordedLiveness === "dead" && sharesPidNamespace(recorded, deps))) return { state: "stopped" };
|
||||
const startPending = liveStartReservation(deps);
|
||||
return {
|
||||
state: "unverified", pid: recorded.pid, port: recorded.port, host: recordedHost, ...(startPending ? { startPending } : {}),
|
||||
...(recordedLiveness !== "alive" && !startPending ? { pidUnseen: true as const } : {}), ...probeErrorCodeOf(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1187,17 +1238,21 @@ export async function getDaemonStatus(
|
||||
}
|
||||
}
|
||||
|
||||
const liveness: ProcessLiveness = deps.processLiveness
|
||||
let liveness: ProcessLiveness = deps.processLiveness
|
||||
? deps.processLiveness(state.pid)
|
||||
: deps.isProcessAlive(state.pid) ? "alive" : "dead";
|
||||
// #2860: down needs positive evidence: the daemon's clean-shutdown receipt, or a missing pid seen
|
||||
// from the daemon's own pid namespace. Elsewhere a missing pid may just be invisible.
|
||||
const stateFile = resolveLifecycleFile(deps, "daemon.json");
|
||||
const { receipt } = readShutdownReceipt(deps, stateFile);
|
||||
const cleanShutdown = receiptMatchesState(receipt, state) && isCleanShutdown(receipt);
|
||||
if (liveness === "dead") {
|
||||
// A status read must not erase the identity needed to judge a failed stop.
|
||||
const stateFile = resolveLifecycleFile(deps, "daemon.json");
|
||||
const { receipt } = readShutdownReceipt(deps, stateFile);
|
||||
if (options.cleanupStaleState !== false && receiptMatchesState(receipt, state) && isCleanShutdown(receipt)) {
|
||||
if (cleanShutdown && options.cleanupStaleState !== false) {
|
||||
// A status read must not erase the identity needed to judge a failed stop.
|
||||
removeMatchingState(deps, stateFile, state);
|
||||
}
|
||||
return { state: "stale" };
|
||||
if (cleanShutdown || sharesPidNamespace(state, deps)) return { state: "stale" };
|
||||
liveness = "unknown";
|
||||
}
|
||||
|
||||
const host = state.host ?? DEFAULT_HOST;
|
||||
@@ -1213,7 +1268,9 @@ export async function getDaemonStatus(
|
||||
// T2: unless a live start reservation says a launch is in progress.
|
||||
if (isRefusedError(err)) {
|
||||
const startPending = liveStartReservation(deps);
|
||||
return startPending ? { state: "unverified", pid: state.pid, port: state.port, host, startPending, ...probeErrorCodeOf(err) } : { state: "stopped" };
|
||||
if (startPending) return { state: "unverified", pid: state.pid, port: state.port, host, startPending, ...probeErrorCodeOf(err) };
|
||||
// #2860: a refusal from a shell that can't see the pid proves nothing without a clean shutdown.
|
||||
return cleanShutdown ? { state: "stopped" } : { state: "unverified", pid: state.pid, port: state.port, host, pidUnseen: true, ...probeErrorCodeOf(err) };
|
||||
}
|
||||
return { state: "unverified", pid: state.pid, port: state.port, host, ...probeErrorCodeOf(err) };
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export function getOfflineHelpPath(): string | undefined {
|
||||
* reporting a starved loop) gets the request, under its normal timeout. */
|
||||
export function daemonMayAnswer(status: DaemonStatus): boolean {
|
||||
if (status.state === "stopped" || status.state === "stale") return false;
|
||||
if (status.probeErrorCode === "ECONNREFUSED" || status.startPending) return false;
|
||||
if (status.probeErrorCode === "ECONNREFUSED" || status.startPending || status.pidUnseen) return false;
|
||||
if (status.siblingHint || isBlockedConnectionCode(status.probeErrorCode)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -289,6 +289,12 @@ describe("sandboxed liveness: EPERM on kill(pid, 0) is not a dead daemon", () =>
|
||||
expect(signalProbe(1, throwing("ESRCH"))).toBe("missing");
|
||||
});
|
||||
|
||||
it("an error other than ESRCH is never evidence that the process is gone", () => {
|
||||
const throwing = (code: string | undefined) => () => { throw Object.assign(new Error(String(code)), { code }); };
|
||||
expect(signalProbe(1, throwing("EINVAL"))).toBe("not-permitted");
|
||||
expect(signalProbe(1, throwing(undefined))).toBe("not-permitted");
|
||||
});
|
||||
|
||||
it("unknown liveness plus a blocked probe is unverified with EPERM, never stale, and the guard says blocked", async () => {
|
||||
const status = await getDaemonStatus(withState({
|
||||
isProcessAlive: () => false,
|
||||
@@ -304,14 +310,16 @@ describe("sandboxed liveness: EPERM on kill(pid, 0) is not a dead daemon", () =>
|
||||
expect(guard.fact).not.toMatch(/not running/i);
|
||||
});
|
||||
|
||||
it("unknown liveness plus a refused probe is still a stopped daemon with down advice", async () => {
|
||||
// #2860: a refusal from a shell that can't inspect the pid is "stopped" only with the daemon's clean-shutdown receipt.
|
||||
it("unknown liveness plus a refused probe and no clean shutdown is unverified, not stopped", async () => {
|
||||
const status = await getDaemonStatus(withState({
|
||||
isProcessAlive: () => false,
|
||||
processLiveness: sandboxLiveness,
|
||||
fetch: probeFailing("ECONNREFUSED"),
|
||||
}));
|
||||
expect(status.state).toBe("stopped");
|
||||
expect(statusGuardMessage(status).fact).toMatch(/not running/i);
|
||||
expect(status.state).toBe("unverified");
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
expect(statusGuardMessage(status).fact).toMatch(/couldn't connect.*from this shell/i);
|
||||
});
|
||||
|
||||
it("unknown liveness plus a healthy probe is running", async () => {
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
// #2860 — a sandbox that cuts loopback often also hides the daemon's pid. A refused probe plus a
|
||||
// missing pid then looked exactly like a stopped daemon, and the guard told the agent to run `rig up`.
|
||||
//
|
||||
// Contract pinned here: "stopped" needs positive evidence, either the daemon's own clean-shutdown
|
||||
// receipt or a missing pid seen from the daemon's pid namespace. Otherwise status says the CLI
|
||||
// couldn't connect from this shell and the daemon may still be running, and never advises starting
|
||||
// another daemon.
|
||||
import { afterEach, describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
daemonStatusGuard,
|
||||
getDaemonStatus,
|
||||
statusGuardMessage,
|
||||
STATE_FILE,
|
||||
type DaemonState,
|
||||
type LifecycleDeps,
|
||||
} from "../src/daemon-lifecycle.js";
|
||||
import { daemonCommand } from "../src/commands/daemon.js";
|
||||
import { daemonMayAnswer, shouldAutoStartDaemon } from "../src/daemon-reachability.js";
|
||||
|
||||
const INITIAL_NAMESPACE = "pid:[4026531836]";
|
||||
const DAEMON_NAMESPACE = "pid:[4026532001]";
|
||||
const SANDBOX_NAMESPACE = "pid:[4026532777]";
|
||||
const START = "2026-01-01T00:00:00Z";
|
||||
|
||||
const REFUSED: LifecycleDeps["fetch"] = async () => {
|
||||
const err = new Error("fetch failed") as Error & { cause?: { code: string } };
|
||||
err.cause = { code: "ECONNREFUSED" };
|
||||
throw err;
|
||||
};
|
||||
|
||||
function cleanReceipt(pid: number): string {
|
||||
const now = new Date().toISOString();
|
||||
return JSON.stringify({ schema: "openrig.daemon-shutdown/v1", pid, startedAt: START, completedAt: now, outcome: "clean", phase: "complete", failures: [] });
|
||||
}
|
||||
|
||||
function deps(state: DaemonState, over: Partial<LifecycleDeps> = {}, receipt?: string): LifecycleDeps {
|
||||
return {
|
||||
spawn: vi.fn() as unknown as LifecycleDeps["spawn"],
|
||||
fetch: vi.fn(REFUSED),
|
||||
kill: vi.fn(() => true),
|
||||
readFile: vi.fn((p: string) => (p === STATE_FILE ? JSON.stringify(state) : p.endsWith("daemon-shutdown.json") ? receipt ?? null : null)),
|
||||
writeFile: vi.fn(),
|
||||
removeFile: vi.fn(),
|
||||
exists: vi.fn((p: string) => p === STATE_FILE || (receipt !== undefined && p.endsWith("daemon-shutdown.json"))),
|
||||
mkdirp: vi.fn(),
|
||||
openForAppend: vi.fn(() => 0),
|
||||
isProcessAlive: vi.fn(() => false),
|
||||
processLiveness: vi.fn(() => "dead" as const),
|
||||
pidNamespace: () => SANDBOX_NAMESPACE,
|
||||
sleep: async () => {},
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const recorded = (extra: Partial<DaemonState> = {}): DaemonState =>
|
||||
({ pid: 4242, port: 7433, db: "x.db", startedAt: START, pidNamespace: DAEMON_NAMESPACE, ...extra });
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("#2860: a refused probe with an invisible pid is not a stopped daemon", () => {
|
||||
it("reads 'couldn't connect from this shell' when the pid is missing, the probe is refused and there is no clean shutdown", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded()));
|
||||
expect(status.state).toBe("unverified");
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
const message = statusGuardMessage(status);
|
||||
expect(message.fact).toMatch(/couldn't connect to the daemon from this shell.*may still be running/i);
|
||||
expect(`${message.fact} ${message.consequence} ${message.action}`).not.toMatch(/rig up|rig daemon start/i);
|
||||
expect(message.action).toMatch(/don't start another daemon/i);
|
||||
expect(shouldAutoStartDaemon(status)).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks commands through the guard with the same message", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded()));
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, "error").mockImplementation((line: string) => { lines.push(line); });
|
||||
const savedExit = process.exitCode;
|
||||
try {
|
||||
expect(daemonStatusGuard(status)).toBe(false);
|
||||
} finally { process.exitCode = savedExit; }
|
||||
const message = statusGuardMessage(status);
|
||||
expect(lines).toEqual([`Error: ${message.fact}`, ` ${message.consequence}`, ` ${message.action}`]);
|
||||
});
|
||||
|
||||
it("is the same on an older record that names no namespace", async () => {
|
||||
const status = await getDaemonStatus(deps({ pid: 4242, port: 7433, db: "x.db", startedAt: START }));
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
});
|
||||
|
||||
it("is the same when this shell cannot read its own namespace", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded(), { pidNamespace: () => undefined }));
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
});
|
||||
|
||||
it("is the same when the pid exists but cannot be inspected (unknown liveness) and the probe is refused", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded(), { processLiveness: vi.fn(() => "unknown" as const) }));
|
||||
expect(status.state).toBe("unverified");
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
});
|
||||
|
||||
it("still answers 'running' when the probe gets through, whatever the pid looks like", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded(), { fetch: vi.fn(async () => ({ ok: true })) }));
|
||||
expect(status.state).toBe("running");
|
||||
});
|
||||
|
||||
it("does not remove state it cannot prove stale", async () => {
|
||||
const d = deps(recorded());
|
||||
await getDaemonStatus(d);
|
||||
expect(d.removeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("#2860: the real stopped cases keep reading as down", () => {
|
||||
it("a clean shutdown for the recorded pid reads stopped from any shell", async () => {
|
||||
const d = deps(recorded(), {}, cleanReceipt(4242));
|
||||
const status = await getDaemonStatus(d);
|
||||
expect(status.state).toBe("stale");
|
||||
expect(statusGuardMessage(status).fact).toBe("Daemon not running.");
|
||||
expect(shouldAutoStartDaemon(status)).toBe(true);
|
||||
expect(d.removeFile).toHaveBeenCalledWith(STATE_FILE);
|
||||
});
|
||||
|
||||
it("a clean shutdown plus a refused probe reads stopped even when the pid state is unknown", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded(), { processLiveness: vi.fn(() => "unknown" as const) }, cleanReceipt(4242)));
|
||||
expect(status.state).toBe("stopped");
|
||||
});
|
||||
|
||||
it("a receipt for another pid is no evidence", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded(), {}, cleanReceipt(1111)));
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["the daemon's own namespace", recorded(), () => DAEMON_NAMESPACE],
|
||||
["the initial namespace it was started in", recorded({ pidNamespace: INITIAL_NAMESPACE }), () => INITIAL_NAMESPACE],
|
||||
["an older record, from the initial namespace", recorded({ pidNamespace: undefined }), () => INITIAL_NAMESPACE],
|
||||
["an older record, on a platform without pid namespaces", recorded({ pidNamespace: undefined }), () => "host"],
|
||||
])("a dead pid in a normal shell (%s) still reads down so 'rig up' can start it", async (_label, state, namespace) => {
|
||||
const status = await getDaemonStatus(deps(state, { pidNamespace: namespace }));
|
||||
expect(status.state).toBe("stale");
|
||||
expect(statusGuardMessage(status).fact).toBe("Daemon not running.");
|
||||
expect(shouldAutoStartDaemon(status)).toBe(true);
|
||||
});
|
||||
|
||||
it("a number recorded in a nested namespace says nothing from the initial one, whatever it finds there", async () => {
|
||||
const fetch = vi.fn(REFUSED);
|
||||
const status = await getDaemonStatus(deps(recorded(), { pidNamespace: () => INITIAL_NAMESPACE, fetch }));
|
||||
expect(status.state).toBe("unverified");
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
expect(fetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("...and a healthy endpoint reads running, not stale", async () => {
|
||||
const status = await getDaemonStatus(deps(recorded(), { pidNamespace: () => INITIAL_NAMESPACE, fetch: vi.fn(async () => ({ ok: true })) }));
|
||||
expect(status.state).toBe("running");
|
||||
});
|
||||
|
||||
it("deps without namespace evidence keep the old rule", async () => {
|
||||
const d = deps(recorded());
|
||||
delete d.pidNamespace;
|
||||
expect((await getDaemonStatus(d)).state).toBe("stale");
|
||||
});
|
||||
});
|
||||
|
||||
describe("#2860: a selected loopback OPENRIG_URL is judged like the record", () => {
|
||||
const withUrl = async (url: string, d: LifecycleDeps) => {
|
||||
process.env.OPENRIG_URL = url;
|
||||
try { return await getDaemonStatus(d); } finally { delete process.env.OPENRIG_URL; }
|
||||
};
|
||||
const URL_OF_DAEMON = "http://127.0.0.1:7433";
|
||||
|
||||
it("a refusal with an invisible pid and no clean shutdown is not stopped, and 'rig up' is not advised", async () => {
|
||||
const status = await withUrl(URL_OF_DAEMON, deps(recorded()));
|
||||
expect(status.state).toBe("unverified");
|
||||
expect(status.pidUnseen).toBe(true);
|
||||
expect(shouldAutoStartDaemon(status)).toBe(false);
|
||||
expect(daemonMayAnswer(status)).toBe(false);
|
||||
});
|
||||
|
||||
it("a missing pid in the daemon's own namespace reads stopped", async () => {
|
||||
expect((await withUrl(URL_OF_DAEMON, deps(recorded(), { pidNamespace: () => DAEMON_NAMESPACE }))).state).toBe("stopped");
|
||||
});
|
||||
|
||||
it("a matching clean shutdown reads stopped from any shell", async () => {
|
||||
expect((await withUrl(URL_OF_DAEMON, deps(recorded(), {}, cleanReceipt(4242)))).state).toBe("stopped");
|
||||
});
|
||||
|
||||
it("with no record for that endpoint a refusal keeps reading stopped", async () => {
|
||||
expect((await withUrl("http://127.0.0.1:9999", deps(recorded()))).state).toBe("stopped");
|
||||
expect((await withUrl(URL_OF_DAEMON, deps(recorded(), { exists: vi.fn(() => false), readFile: vi.fn(() => null) }))).state).toBe("stopped");
|
||||
});
|
||||
|
||||
it("a remote URL keeps reading stopped on a refusal", async () => {
|
||||
expect((await withUrl("http://daemon.example:7433", deps(recorded()))).state).toBe("stopped");
|
||||
});
|
||||
});
|
||||
|
||||
describe("#2860: `rig daemon status` says what the guard says", () => {
|
||||
it("prints the guard's three lines", async () => {
|
||||
const d = deps(recorded());
|
||||
const status = await getDaemonStatus(d);
|
||||
const message = statusGuardMessage(status);
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, "log").mockImplementation((line: string) => { lines.push(line); });
|
||||
await daemonCommand(d).parseAsync(["status"], { from: "user" });
|
||||
expect(lines).toEqual([message.fact, ` ${message.consequence}`, ` ${message.action}`]);
|
||||
});
|
||||
});
|
||||
@@ -78,7 +78,8 @@ describe("rig edge", () => {
|
||||
expect(result.out).toHaveLength(1);
|
||||
const error = JSON.parse(result.out[0]!).error;
|
||||
expect(error).toMatchObject({ fact: expect.any(String), consequence: expect.any(String), action: expect.any(String) });
|
||||
if (causeCode === "ECONNREFUSED") expect(error.fact).toBe("Daemon not running.");
|
||||
// #2860: a refused connection alone isn't proof the daemon is stopped, so rig edge says so only on positive evidence.
|
||||
if (causeCode === "ECONNREFUSED") expect(error.fact).toBe("Daemon did not respond — it may be busy or stopped (state not confirmed).");
|
||||
else expect(error).toMatchObject({ causeCode: "EPERM", fact: expect.stringContaining("blocked") });
|
||||
expect(client.get).not.toHaveBeenCalled();
|
||||
expect(client.post).not.toHaveBeenCalled();
|
||||
|
||||
@@ -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:/);
|
||||
});
|
||||
@@ -207,6 +207,20 @@ function createMockDaemon() {
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (sourceRef.includes("skipped")) {
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
installId: "inst-skip-1",
|
||||
packageId: "pkg-1",
|
||||
packageName: "guidance-pkg",
|
||||
applied: [],
|
||||
deferred: [],
|
||||
conflicts: [],
|
||||
verification: { passed: true },
|
||||
notes: ["guidance_block_markers_damaged: /repo/CLAUDE.md: guidance-pkg has a BEGIN or END marker without its pair"],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (sourceRef.includes("merge")) {
|
||||
res.writeHead(201, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
@@ -383,6 +397,14 @@ describe("rig package", () => {
|
||||
expect(output).toContain("inst-merge-1");
|
||||
});
|
||||
|
||||
it("install with a guidance file left as it is: names the file and the reason, exit 0", async () => {
|
||||
const { logs, exitCode } = await captureLogs(() => makeProgram().parseAsync(["node", "rig", "package", "install", "/skipped/path", "--target", "/repo", "--allow-merge"]));
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("Left as it is:");
|
||||
expect(output).toContain(" guidance_block_markers_damaged: /repo/CLAUDE.md: guidance-pkg has a BEGIN or END marker without its pair");
|
||||
expect(exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
// Test 8: install conflicts (409) → exitCode 1
|
||||
it("install conflicts: prints conflicts, exitCode 1", async () => {
|
||||
const { logs, exitCode } = await captureLogs(() => makeProgram().parseAsync(["node", "rig", "package", "install", "/conflict/path", "--target", "/repo"]));
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"homepage": "https://github.com/mvschwarz/openrig-plugins",
|
||||
"repository": "github:mvschwarz/openrig-plugins",
|
||||
"repository": "https://github.com/mvschwarz/openrig-plugins",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": ["openrig", "agent", "topology", "rig", "continuity", "coordination", "compaction", "handoff"],
|
||||
"skills": "./skills",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"homepage": "https://github.com/mvschwarz/openrig-plugins",
|
||||
"repository": "github:mvschwarz/openrig-plugins",
|
||||
"repository": "https://github.com/mvschwarz/openrig-plugins",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": ["openrig", "agent", "topology", "rig", "continuity", "coordination", "compaction", "handoff"],
|
||||
"skills": "./skills",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
@@ -7,7 +7,7 @@
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"homepage": "https://github.com/mvschwarz/openrig",
|
||||
"repository": "github:mvschwarz/openrig",
|
||||
"repository": "https://github.com/mvschwarz/openrig",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": ["openrig", "agents", "team", "delegation", "mcp"],
|
||||
"skills": "./skills",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"homepage": "https://github.com/mvschwarz/openrig",
|
||||
"repository": "github:mvschwarz/openrig",
|
||||
"repository": "https://github.com/mvschwarz/openrig",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": [
|
||||
"openrig",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"homepage": "https://github.com/mvschwarz/openrig",
|
||||
"repository": "github:mvschwarz/openrig",
|
||||
"repository": "https://github.com/mvschwarz/openrig",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": ["openrig", "agents", "team", "delegation", "mcp"]
|
||||
}
|
||||
@@ -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;
|
||||
`,
|
||||
};
|
||||
@@ -643,6 +643,9 @@ export class BootstrapOrchestrator {
|
||||
if (!outcome.ok) {
|
||||
errors.push(`Package install failed for '${ref}' (${runtime}): ${outcome.message}`);
|
||||
packageInstallFailed = true;
|
||||
} else {
|
||||
// A file the install left as it is, and why: the rest installed, and the rig still imports.
|
||||
for (const note of outcome.notes ?? []) warnings.push(`Package '${ref}' (${runtime}): ${note}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { InstallPlanEntry } from "./install-planner.js";
|
||||
import type { PolicyResult } from "./install-policy.js";
|
||||
import type { RefinedInstallPlan } from "./conflict-detector.js";
|
||||
import { InstallRepository, type JournalEntry } from "./install-repository.js";
|
||||
import { damagedManagedBlockNote, findManagedBlocks, managedBlockBodyMatches, mergeManagedBlockText } from "./managed-blocks.js";
|
||||
|
||||
export interface EngineFsOps {
|
||||
readFile: (filePath: string) => string;
|
||||
@@ -19,6 +20,8 @@ export interface InstallResult {
|
||||
applied: JournalEntry[];
|
||||
deferred: InstallPlanEntry[];
|
||||
conflicts: InstallPlanEntry[];
|
||||
/** Entries left as they were, each with the reason: a guidance file whose block has a marker without its pair. */
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface RollbackResult {
|
||||
@@ -52,10 +55,19 @@ export class InstallEngine {
|
||||
): InstallResult {
|
||||
const install = this.installRepo.createInstall(packageId, targetRoot, "project_shared", bootstrapId);
|
||||
const applied: JournalEntry[] = [];
|
||||
const notes: string[] = [];
|
||||
const backupRoot = path.join(targetRoot, ".rigged-backups", install.id);
|
||||
|
||||
try {
|
||||
for (const entry of policyResult.approved) {
|
||||
// A guidance block with a marker that lost its pair can't be replaced, and another copy beside it is the
|
||||
// duplicate to avoid: that file is left as it is and named, and the rest of the install goes on.
|
||||
const unreadable = entry.exportType === "guidance" ? this.unreadableGuidanceBlock(entry, plan.packageName) : null;
|
||||
if (unreadable) {
|
||||
notes.push(unreadable);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure target directory
|
||||
this.fs.mkdirp(path.dirname(entry.targetPath));
|
||||
|
||||
@@ -121,6 +133,7 @@ export class InstallEngine {
|
||||
applied,
|
||||
deferred: plan.deferred,
|
||||
conflicts: plan.conflicts,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -170,6 +183,14 @@ export class InstallEngine {
|
||||
return { restored, deleted };
|
||||
}
|
||||
|
||||
/** The note for a guidance entry whose target holds a marker of the package's block without its pair, and no complete
|
||||
* copy, or null. */
|
||||
private unreadableGuidanceBlock(entry: InstallPlanEntry, packageName: string): string | null {
|
||||
if (!entry.sourcePath || !this.fs.exists(entry.targetPath)) return null;
|
||||
const found = findManagedBlocks(this.fs.readFile(entry.targetPath), packageName);
|
||||
return found.blocks.length === 0 && found.unpaired > 0 ? damagedManagedBlockNote(entry.targetPath, packageName) : null;
|
||||
}
|
||||
|
||||
private applyGuidanceMerge(entry: InstallPlanEntry, packageName: string): void {
|
||||
if (!entry.sourcePath) return;
|
||||
|
||||
@@ -178,34 +199,17 @@ export class InstallEngine {
|
||||
|
||||
if (this.fs.exists(entry.targetPath)) {
|
||||
const existing = this.fs.readFile(entry.targetPath);
|
||||
const startMarker = BLOCK_START(packageName);
|
||||
const endMarker = BLOCK_END(packageName);
|
||||
const legacyStart = `<!-- BEGIN RIGGED MANAGED BLOCK: ${packageName} -->`;
|
||||
const legacyEnd = `<!-- END RIGGED MANAGED BLOCK: ${packageName} -->`;
|
||||
// Prefer the OpenRig form; fall back to legacy markers from
|
||||
// prior installs so re-applying a guidance package over a
|
||||
// pre-rename file replaces the old block instead of appending a
|
||||
// duplicate.
|
||||
let startIdx = existing.indexOf(startMarker);
|
||||
let endIdx = existing.indexOf(endMarker, startIdx + startMarker.length);
|
||||
let matchedEndLength = endMarker.length;
|
||||
if (startIdx === -1 || endIdx === -1) {
|
||||
const legacyStartIdx = existing.indexOf(legacyStart);
|
||||
const legacyEndIdx = existing.indexOf(legacyEnd, legacyStartIdx + legacyStart.length);
|
||||
if (legacyStartIdx !== -1 && legacyEndIdx !== -1) {
|
||||
startIdx = legacyStartIdx;
|
||||
endIdx = legacyEndIdx;
|
||||
matchedEndLength = legacyEnd.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (startIdx !== -1 && endIdx !== -1) {
|
||||
// Update existing block (rewrites legacy markers to OpenRig form).
|
||||
const updated = existing.slice(0, startIdx) + block + existing.slice(endIdx + matchedEndLength);
|
||||
this.fs.writeFile(entry.targetPath, updated);
|
||||
} else {
|
||||
const found = findManagedBlocks(existing, packageName);
|
||||
if (found.blocks.length === 0) {
|
||||
// Insert new block at end
|
||||
this.fs.writeFile(entry.targetPath, existing + "\n\n" + block + "\n");
|
||||
} else {
|
||||
// Update the first copy in place, in the OpenRig form (legacy markers too). A later copy goes when it repeats the
|
||||
// first copy's text or the package's; a copy with other text, which someone wrote, stays.
|
||||
const first = found.blocks[0]!.body;
|
||||
const updated = mergeManagedBlockText(existing, packageName, block, [],
|
||||
(body) => !managedBlockBodyMatches(body, first) && !managedBlockBodyMatches(body, content));
|
||||
if (updated !== null) this.fs.writeFile(entry.targetPath, updated);
|
||||
}
|
||||
} else {
|
||||
// New file
|
||||
|
||||
@@ -45,7 +45,9 @@ export class InstallVerifier {
|
||||
this.fs = fs;
|
||||
}
|
||||
|
||||
verify(installId: string): VerificationResult {
|
||||
/** `skipped`: entries the install deliberately left as they were (its result's notes), so an install whose every entry was
|
||||
* skipped verifies as that, not as an empty journal. */
|
||||
verify(installId: string, opts: { skipped?: number } = {}): VerificationResult {
|
||||
const install = this.installRepo.getInstall(installId);
|
||||
if (!install) {
|
||||
return {
|
||||
@@ -76,6 +78,16 @@ export class InstallVerifier {
|
||||
const journal = this.installRepo.getJournalEntries(installId);
|
||||
const applyEntries = journal.filter((e) => e.action !== "rollback");
|
||||
|
||||
// Every entry left as it was on purpose, each named in the install's notes: nothing to check, and nothing missing.
|
||||
if (applyEntries.length === 0 && (opts.skipped ?? 0) > 0) {
|
||||
return {
|
||||
passed: true,
|
||||
installId,
|
||||
entries: [],
|
||||
statusCheck: { ...statusCheck, name: "entries_skipped", passed: true, expected: "applied entries, or skips named in notes", actual: `0 applied, ${opts.skipped} skipped` },
|
||||
};
|
||||
}
|
||||
|
||||
// Empty journal for applied install is a verification failure
|
||||
if (applyEntries.length === 0) {
|
||||
return {
|
||||
|
||||
@@ -2,7 +2,8 @@ import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import nodePath from "node:path";
|
||||
import type Database from "better-sqlite3";
|
||||
import { frameManagedBlockBody, managedBlockStanding, mergeManagedBlock, type ManagedBlockMergeFsOps, type ManagedBlockStanding } from "./managed-blocks.js";
|
||||
import { damagedManagedBlockNote, frameManagedBlockBody, managedBlockBodyMatches, managedBlockStanding, mergeManagedBlock, pruneManagedBlockCopies,
|
||||
withCanonicalMarkers, type ManagedBlockMergeFsOps, type ManagedBlockStanding } from "./managed-blocks.js";
|
||||
|
||||
/** #2456: OpenRig's record of what it last wrote in each managed guidance block, so a later start, exact resume, handover
|
||||
* or runtime switch can tell a block it wrote (refreshed when its source changes) from a block a person edited (kept). */
|
||||
@@ -92,6 +93,8 @@ export function decideManagedBlock(content: string, blockId: string, source: str
|
||||
const standing = managedBlockStanding(content, blockId, source, recorded, managedBlockBodyHash);
|
||||
const named = opts.sourcePath ?? "its source";
|
||||
if (standing === "absent") return { standing, write: !opts.onlyIfPresent };
|
||||
// A marker without its pair: writing would add a second copy beside the block that can't be read, at every start.
|
||||
if (standing === "damaged") return { standing, write: false, note: damagedManagedBlockNote(targetPath, blockId) };
|
||||
if (unreadable && standing === "unrecorded") {
|
||||
return { standing: "unknown", write: false,
|
||||
note: `guidance_block_record_unreadable: ${targetPath}: ${blockId} wasn't refreshed from ${named} because OpenRig couldn't read its write record` };
|
||||
@@ -103,10 +106,36 @@ export function decideManagedBlock(content: string, blockId: string, source: str
|
||||
return { standing, write: true };
|
||||
}
|
||||
|
||||
/** A block that isn't rewritten (current, edited or unrecorded) can still carry extra copies, or markers an editor
|
||||
* rewrapped: take out the copies that repeat a kept one, and OpenRig's own (its source's text, or what it last wrote)
|
||||
* beside an edited one, so the file keeps one copy of each distinct text, and put the markers in OpenRig's form. Nothing
|
||||
* a person wrote is removed. Best effort: a file that can't be read or written is named in `notes` and left as it is,
|
||||
* and the block's own decision stands. Returns whether the file changed. */
|
||||
export function pruneKeptManagedBlock(files: ManagedBlockMergeFsOps, targetPath: string, blockId: string, source: string,
|
||||
records: ManagedBlockRecords | undefined, notes?: string[]): boolean {
|
||||
let recorded: string | undefined;
|
||||
try { recorded = records?.get(targetPath, blockId); } catch { /* unreadable: only the source's text counts as OpenRig's */ }
|
||||
try {
|
||||
const existing = files.exists(targetPath) ? files.readFile(targetPath) : "";
|
||||
const pruned = pruneManagedBlockCopies(existing, blockId,
|
||||
(body) => managedBlockBodyMatches(body, source) || (recorded !== undefined && managedBlockBodyHash(body) === recorded));
|
||||
const tidy = withCanonicalMarkers(pruned ?? existing, blockId);
|
||||
if (tidy === existing) return false;
|
||||
files.writeFile(targetPath, tidy);
|
||||
if (pruned !== null) {
|
||||
notes?.push(`guidance_block_copies_removed: ${targetPath}: ${blockId} had repeated copies; the repeats were removed and every edited copy was kept`);
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
notes?.push(`guidance_block_copies_not_removed: ${targetPath}: ${blockId}'s repeated copies or markers couldn't be tidied (${(err as Error).message}); the file was left as it is`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** #2456: the one write of a managed guidance block that every adapter's guidance merge goes through. Without `records` it's
|
||||
* the plain merge, as before. With them, `decideManagedBlock` decides; a kept block is named in `warnings` and nothing is
|
||||
* written. A written block is recorded at once, so a later failure in the same start can't lose its record. Returns
|
||||
* whether the block was written. */
|
||||
* the plain merge, as before. With them, `decideManagedBlock` decides; a kept block is named in `warnings`, and only its
|
||||
* repeated copies are taken out (`pruneKeptManagedBlock`). A written block is recorded at once, so a later failure in the
|
||||
* same start can't lose its record. Returns whether the block was written. */
|
||||
export function writeManagedGuidanceBlock(files: ManagedBlockMergeFsOps, targetPath: string, blockId: string, content: string, opts: {
|
||||
records?: ManagedBlockRecords; unrecorded?: "keep" | "refresh"; onlyIfPresent?: boolean; sourcePath?: string;
|
||||
warnings?: string[]; replaceBlockIds?: string[];
|
||||
@@ -115,15 +144,19 @@ export function writeManagedGuidanceBlock(files: ManagedBlockMergeFsOps, targetP
|
||||
// Without records, the plain merge as before; a refresh-only call (`onlyIfPresent`) then writes nothing.
|
||||
if (!opts.records) {
|
||||
if (opts.onlyIfPresent) return false;
|
||||
merge();
|
||||
return true;
|
||||
return merge();
|
||||
}
|
||||
const existing = files.exists(targetPath) ? files.readFile(targetPath) : "";
|
||||
const decision = decideManagedBlock(existing, blockId, content, opts.records, targetPath,
|
||||
{ unrecorded: opts.unrecorded ?? "refresh", onlyIfPresent: opts.onlyIfPresent, sourcePath: opts.sourcePath });
|
||||
if (decision.note) opts.warnings?.push(decision.note);
|
||||
if (!decision.write) return false;
|
||||
merge();
|
||||
if (!decision.write) {
|
||||
if (decision.standing === "edited" || decision.standing === "unrecorded") {
|
||||
pruneKeptManagedBlock(files, targetPath, blockId, content, opts.records, opts.warnings);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!merge()) return false;
|
||||
recordManagedBlockWrite(opts.records, targetPath, blockId, content, opts.warnings);
|
||||
return true;
|
||||
}
|
||||
@@ -41,15 +41,95 @@ export function managedBlockText(blockId: string, content: string): string {
|
||||
/** What `mergeManagedBlock` puts between a file's existing text and a block it adds at the end. */
|
||||
export const MANAGED_BLOCK_APPEND_SEPARATOR = "\n\n";
|
||||
|
||||
/** A BEGIN or END marker for `blockId`, in the current or the legacy form, as an editor or formatter may leave it: any
|
||||
* run of whitespace, line breaks included, between its words. */
|
||||
function markerSource(kind: "BEGIN" | "END", blockId: string): string {
|
||||
return `<!--\\s*${kind}\\s+(?:OpenRig|RIGGED)\\s+MANAGED\\s+BLOCK:\\s*${escapeRegex(blockId)}\\s*-->`;
|
||||
}
|
||||
|
||||
/** One `blockId` block in a file: where it and its body start and end, and the text between its markers. */
|
||||
export interface FoundManagedBlock { start: number; end: number; bodyStart: number; bodyEnd: number; body: string }
|
||||
|
||||
/** Every `blockId` block in `content`, in order: each BEGIN paired with the next marker when that marker is an END. A
|
||||
* marker left without a pair (an END with no BEGIN before it, or a BEGIN followed by another BEGIN or by nothing) is
|
||||
* counted in `unpaired`: where its block ends, or starts, can't be read. */
|
||||
export function findManagedBlocks(content: string, blockId: string): { blocks: FoundManagedBlock[]; unpaired: number } {
|
||||
const markers = [...content.matchAll(new RegExp(`(${markerSource("BEGIN", blockId)})|${markerSource("END", blockId)}`, "g"))]
|
||||
.map((m) => ({ begin: m[1] !== undefined, index: m.index!, length: m[0].length }));
|
||||
const blocks: FoundManagedBlock[] = [];
|
||||
let unpaired = 0;
|
||||
for (let i = 0; i < markers.length; i++) {
|
||||
const marker = markers[i]!;
|
||||
const next = markers[i + 1];
|
||||
if (marker.begin && next && !next.begin) {
|
||||
const bodyStart = marker.index + marker.length;
|
||||
blocks.push({ start: marker.index, end: next.index + next.length, bodyStart, bodyEnd: next.index, body: content.slice(bodyStart, next.index) });
|
||||
i++;
|
||||
} else {
|
||||
unpaired++;
|
||||
}
|
||||
}
|
||||
return { blocks, unpaired };
|
||||
}
|
||||
|
||||
/** The note for a block OpenRig didn't write because a marker of it has lost its pair. */
|
||||
export function damagedManagedBlockNote(targetPath: string, blockId: string): string {
|
||||
return `guidance_block_markers_damaged: ${targetPath}: ${blockId} has a BEGIN or END marker without its pair, so OpenRig `
|
||||
+ "didn't add another copy; restore the missing marker or remove the partial block, and OpenRig's next write keeps one block";
|
||||
}
|
||||
|
||||
/**
|
||||
* `existing` with `blockId`'s block written as `block`, or null when it can't be written without a second copy: the file
|
||||
* has a marker of that block without its pair, and no complete copy. The first copy is replaced in place and a later copy
|
||||
* is removed with the separator before it, so a file that collected copies keeps one; with `keepLater`, a later copy it
|
||||
* names stays, unless it repeats one already kept. Blocks of `replaceBlockIds` (older ids) are removed. A file with none
|
||||
* of these gets the block at its end.
|
||||
*/
|
||||
export function mergeManagedBlockText(existing: string, blockId: string, block: string, replaceBlockIds: readonly string[] = [],
|
||||
keepLater?: (body: string) => boolean): string | null {
|
||||
const own = findManagedBlocks(existing, blockId);
|
||||
if (own.blocks.length === 0 && own.unpaired > 0) return null;
|
||||
const keptLater: string[] = [];
|
||||
const edits: Array<{ start: number; end: number; text: string }> = [];
|
||||
own.blocks.forEach((found, i) => {
|
||||
if (i === 0) edits.push({ start: found.start, end: found.end, text: block });
|
||||
else if (keepLater?.(found.body) && !keptLater.some((body) => managedBlockBodyMatches(body, found.body))) keptLater.push(found.body);
|
||||
else edits.push({ start: withSeparator(existing, found.start), end: found.end, text: "" });
|
||||
});
|
||||
for (const id of new Set(replaceBlockIds)) {
|
||||
if (id === blockId) continue;
|
||||
for (const found of findManagedBlocks(existing, id).blocks) edits.push({ start: found.start, end: found.end, text: "" });
|
||||
}
|
||||
if (edits.length === 0) return `${existing}${MANAGED_BLOCK_APPEND_SEPARATOR}${block}`;
|
||||
// A block of one id inside another's is left to the outer edit.
|
||||
const ordered: typeof edits = [];
|
||||
for (const edit of edits.sort((a, b) => a.start - b.start)) if (edit.start >= (ordered.at(-1)?.end ?? 0)) ordered.push(edit);
|
||||
let updated = existing;
|
||||
// The block goes in by slicing, never a replacement string, so `$&` and the like in authored content stay as written.
|
||||
for (const edit of ordered.reverse()) updated = `${updated.slice(0, edit.start)}${edit.text}${updated.slice(edit.end)}`;
|
||||
if (own.blocks.length === 0) updated = `${updated.trim()}\n\n${block}`.trim();
|
||||
// End with one newline only when the file doesn't already: re-merging on every launch must not add a line each time
|
||||
// (#2372), and blank lines a person left after the last block stay.
|
||||
return updated.endsWith("\n") ? updated : `${updated}\n`;
|
||||
}
|
||||
|
||||
/** Where a later copy's removal starts: before the newlines that separate it from the text above, so removing the copy
|
||||
* takes the separator the writer put there. */
|
||||
function withSeparator(content: string, start: number): number {
|
||||
let from = start;
|
||||
while (from > 0 && content[from - 1] === "\n") from--;
|
||||
return from;
|
||||
}
|
||||
|
||||
/** Write `blockId`'s block into a guidance file (see `mergeManagedBlockText`). Returns false, writing nothing and naming
|
||||
* the block in `warnings`, when a marker of it has lost its pair. */
|
||||
export function mergeManagedBlock(
|
||||
fs: ManagedBlockMergeFsOps,
|
||||
targetPath: string,
|
||||
blockId: string,
|
||||
content: string,
|
||||
options?: MergeManagedBlockOptions,
|
||||
): void {
|
||||
const begin = MANAGED_BLOCK_START(blockId);
|
||||
const end = MANAGED_BLOCK_END(blockId);
|
||||
): boolean {
|
||||
const block = managedBlockText(blockId, content);
|
||||
|
||||
if (!fs.exists(targetPath)) {
|
||||
@@ -57,49 +137,39 @@ export function mergeManagedBlock(
|
||||
fs.writeFile(targetPath, block);
|
||||
const warnings = excludeNewGeneratedFiles(nodePath.dirname(targetPath), [targetPath]);
|
||||
options?.warnings?.push(...warnings);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
const existing = fs.readFile(targetPath);
|
||||
const allReplaceIds = Array.from(new Set([blockId, ...(options?.replaceBlockIds ?? [])]));
|
||||
const replaceableIds = allReplaceIds.filter((id) => {
|
||||
const candidateBegin = MANAGED_BLOCK_START(id);
|
||||
const candidateEnd = MANAGED_BLOCK_END(id);
|
||||
const legacyBegin = LEGACY_BLOCK_START(id);
|
||||
const legacyEnd = LEGACY_BLOCK_END(id);
|
||||
return (
|
||||
(existing.includes(candidateBegin) && existing.includes(candidateEnd)) ||
|
||||
(existing.includes(legacyBegin) && existing.includes(legacyEnd))
|
||||
);
|
||||
});
|
||||
|
||||
if (replaceableIds.length > 0) {
|
||||
let updated = existing;
|
||||
for (const id of replaceableIds) {
|
||||
const candidateBegin = MANAGED_BLOCK_START(id);
|
||||
const candidateEnd = MANAGED_BLOCK_END(id);
|
||||
const regex = new RegExp(`${escapeRegex(candidateBegin)}[\\s\\S]*?${escapeRegex(candidateEnd)}`, "g");
|
||||
// A replacer function inserts the block literally; a replacement string
|
||||
// would expand `$$`, `$&`, `` $` `` and `$'` in the authored content.
|
||||
const replacement = id === blockId ? block : "";
|
||||
updated = updated.replace(regex, () => replacement);
|
||||
// Legacy marker variant from prior installs — replace with the
|
||||
// OpenRig form (or strip when not the active block id).
|
||||
const legacyBegin = LEGACY_BLOCK_START(id);
|
||||
const legacyEnd = LEGACY_BLOCK_END(id);
|
||||
const legacyRegex = new RegExp(`${escapeRegex(legacyBegin)}[\\s\\S]*?${escapeRegex(legacyEnd)}`, "g");
|
||||
updated = updated.replace(legacyRegex, () => replacement);
|
||||
}
|
||||
if (!updated.includes(begin) || !updated.includes(end)) {
|
||||
updated = `${updated.trim()}\n\n${block}`.trim();
|
||||
}
|
||||
// End with one newline only when the file doesn't already: re-merging on every launch must not add a line each time
|
||||
// (#2372), and blank lines a person left after the last block stay.
|
||||
fs.writeFile(targetPath, updated.endsWith("\n") ? updated : `${updated}\n`);
|
||||
return;
|
||||
const merged = mergeManagedBlockText(existing, blockId, block, options?.replaceBlockIds ?? []);
|
||||
if (merged === null) {
|
||||
options?.warnings?.push(damagedManagedBlockNote(targetPath, blockId));
|
||||
return false;
|
||||
}
|
||||
fs.writeFile(targetPath, merged);
|
||||
if (findManagedBlocks(existing, blockId).unpaired > 0) options?.warnings?.push(unpairedMarkerNote(targetPath, blockId));
|
||||
return true;
|
||||
}
|
||||
|
||||
fs.writeFile(targetPath, `${existing}${MANAGED_BLOCK_APPEND_SEPARATOR}${block}`);
|
||||
/** The note for a marker without its pair left beside a block that was written. */
|
||||
export function unpairedMarkerNote(targetPath: string, blockId: string): string {
|
||||
return `guidance_block_marker_unpaired: ${targetPath}: ${blockId} was written, and a BEGIN or END marker of it without its `
|
||||
+ "pair was left as it is; remove it or restore its pair";
|
||||
}
|
||||
|
||||
/** `content` with each `blockId` block whose markers an editor rewrapped written back in OpenRig's own form, its body
|
||||
* exactly as it is. Markers already in the current or legacy form stay as they are. */
|
||||
export function withCanonicalMarkers(content: string, blockId: string): string {
|
||||
let updated = content;
|
||||
for (const found of findManagedBlocks(content, blockId).blocks.reverse()) {
|
||||
const begin = content.slice(found.start, found.bodyStart);
|
||||
const end = content.slice(found.bodyEnd, found.end);
|
||||
const exact = (begin === MANAGED_BLOCK_START(blockId) && end === MANAGED_BLOCK_END(blockId))
|
||||
|| (begin === LEGACY_BLOCK_START(blockId) && end === LEGACY_BLOCK_END(blockId));
|
||||
if (exact) continue;
|
||||
updated = `${updated.slice(0, found.start)}${MANAGED_BLOCK_START(blockId)}${found.body}${MANAGED_BLOCK_END(blockId)}${updated.slice(found.end)}`;
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Each `blockId` block in `content` (current markers): the whole block and the text between its markers. */
|
||||
@@ -136,19 +206,47 @@ export function managedBlockOpenings(content: string, blockId: string): number {
|
||||
* - `current`: it holds the source's text;
|
||||
* - `stale`: it holds what OpenRig last wrote, and the source has changed since, so it's OpenRig's to refresh;
|
||||
* - `edited`: it holds something else, which someone wrote by hand;
|
||||
* - `unrecorded`: it differs from the source and OpenRig has no record of writing it, so which of the two is unknown.
|
||||
* - `unrecorded`: it differs from the source and OpenRig has no record of writing it, so which of the two is unknown;
|
||||
* - `damaged`: it holds a marker of the block without its pair, and no complete copy, so the block can't be read.
|
||||
* A block written more than once counts as edited when any copy is. */
|
||||
export type ManagedBlockStanding = "absent" | "current" | "stale" | "edited" | "unrecorded";
|
||||
export type ManagedBlockStanding = "absent" | "current" | "stale" | "edited" | "unrecorded" | "damaged";
|
||||
|
||||
export function managedBlockStanding(content: string, blockId: string, source: string, recordedHash: string | undefined,
|
||||
hash: (body: string) => string): ManagedBlockStanding {
|
||||
const bodies = managedBlockBodies(content, blockId);
|
||||
if (bodies.length === 0) return "absent";
|
||||
const found = findManagedBlocks(content, blockId);
|
||||
const bodies = found.blocks.map((block) => block.body);
|
||||
if (bodies.length === 0) return found.unpaired > 0 ? "damaged" : "absent";
|
||||
if (bodies.every((body) => managedBlockBodyMatches(body, source))) return "current";
|
||||
if (recordedHash === undefined) return "unrecorded";
|
||||
return bodies.every((body) => hash(body) === recordedHash) ? "stale" : "edited";
|
||||
}
|
||||
|
||||
/** `content` with the extra copies of a `blockId` block taken out where no one's text is lost: a later copy whose body
|
||||
* repeats an earlier copy's, and, beside a copy that's kept, a copy that `isOpenRigs` says holds only what OpenRig
|
||||
* wrote. Every other copy stays where it is. Null when there's nothing to take out. */
|
||||
export function pruneManagedBlockCopies(content: string, blockId: string, isOpenRigs: (body: string) => boolean): string | null {
|
||||
const { blocks } = findManagedBlocks(content, blockId);
|
||||
if (blocks.length < 2) return null;
|
||||
const kept: FoundManagedBlock[] = [];
|
||||
const removed: FoundManagedBlock[] = [];
|
||||
for (const block of blocks) {
|
||||
if (kept.some((other) => managedBlockBodyMatches(other.body, block.body))) removed.push(block);
|
||||
else kept.push(block);
|
||||
}
|
||||
const people = kept.filter((block) => !isOpenRigs(block.body));
|
||||
if (people.length > 0) for (const block of kept.filter((b) => isOpenRigs(b.body))) removed.push(block);
|
||||
if (removed.length === 0) return null;
|
||||
let updated = content;
|
||||
for (const block of removed.sort((a, b) => b.start - a.start)) {
|
||||
// A copy goes with the newlines before it; one that starts the file goes with the newlines after it instead.
|
||||
const from = withSeparator(content, block.start);
|
||||
let to = block.end;
|
||||
if (from === 0) while (updated[to] === "\n") to++;
|
||||
updated = `${updated.slice(0, from)}${updated.slice(to)}`;
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Whether `content` holds a `blockId` block whose body is anything but `written`: someone edited it. */
|
||||
export function hasEditedManagedBlock(content: string, blockId: string, written: string): boolean {
|
||||
return [...content.matchAll(managedBlockPattern(blockId))].some((m) => !managedBlockBodyMatches(m[1]!, written));
|
||||
|
||||
@@ -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 + "/";
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { detectConflicts } from "./conflict-detector.js";
|
||||
import { applyPolicy } from "./install-policy.js";
|
||||
|
||||
export type PackageInstallOutcome =
|
||||
| { ok: true; installId: string; packageId: string; applied: number; deferred: number }
|
||||
| { ok: true; installId: string; packageId: string; applied: number; deferred: number; notes?: string[] }
|
||||
| { ok: false; code: "conflict_blocked"; message: string }
|
||||
| { ok: false; code: "policy_rejected"; message: string }
|
||||
| { ok: false; code: "manifest_hash_mismatch"; message: string }
|
||||
@@ -101,7 +101,7 @@ export class PackageInstallService {
|
||||
}
|
||||
|
||||
// Verify
|
||||
const verification = this.installVerifier.verify(result.installId);
|
||||
const verification = this.installVerifier.verify(result.installId, { skipped: result.notes?.length ?? 0 });
|
||||
if (!verification.passed) {
|
||||
return { ok: false, code: "verification_failed", message: "Post-apply verification failed" };
|
||||
}
|
||||
@@ -112,6 +112,7 @@ export class PackageInstallService {
|
||||
packageId: pkg.id,
|
||||
applied: result.applied.length,
|
||||
deferred: result.deferred.length,
|
||||
...(result.notes?.length ? { notes: result.notes } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -21,12 +21,17 @@
|
||||
// per founder authorization 2026-05-10 (LICENSE only); 404 is the
|
||||
// expected normal-state response until a separate publish authorization.
|
||||
|
||||
import fs from "node:fs";
|
||||
import nodePath from "node:path";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
|
||||
export interface PluginVendorFs {
|
||||
readFile(path: string): string;
|
||||
writeFile(path: string, content: string): void;
|
||||
/** A file's raw bytes. With `writeBytes`, a plugin's files are copied and compared byte for byte, so a binary file
|
||||
* (an icon) arrives intact. Optional: without them files are read and written as text. */
|
||||
readBytes?(path: string): Uint8Array;
|
||||
writeBytes?(path: string, content: Uint8Array): void;
|
||||
/** Copy raw bytes, failing if the recovery path already exists. */
|
||||
copyFileExclusive(source: string, target: string): void;
|
||||
exists(path: string): boolean;
|
||||
@@ -160,7 +165,7 @@ export class PluginVendorService {
|
||||
const destPath = nodePath.join(targetDir, relPath);
|
||||
if (
|
||||
this.fs.exists(destPath) &&
|
||||
hashContent(this.fs.readFile(destPath)) === hashContent(this.fs.readFile(srcPath))
|
||||
hashContent(this.read(destPath)) === hashContent(this.read(srcPath))
|
||||
) {
|
||||
this.preserveMode(srcPath, destPath);
|
||||
}
|
||||
@@ -170,7 +175,7 @@ export class PluginVendorService {
|
||||
}
|
||||
}
|
||||
|
||||
const updates: Array<[string, string]> = this.fs.listFiles(sourceDir).map(rel => [rel, this.fs.readFile(nodePath.join(sourceDir, rel))]);
|
||||
const updates: Array<[string, FileContent]> = this.fs.listFiles(sourceDir).map(rel => [rel, this.read(nodePath.join(sourceDir, rel))]);
|
||||
if (!this.preserveBeforeRefresh(targetDir, updates)) return;
|
||||
this.fs.mkdirp(targetDir);
|
||||
|
||||
@@ -179,23 +184,34 @@ export class PluginVendorService {
|
||||
const destPath = nodePath.join(targetDir, relPath);
|
||||
// Hash-skip: only write if content differs (idempotent re-runs). Reconcile mode even on
|
||||
// skip — a byte-identical copy staged earlier may still carry the wrong (default) mode.
|
||||
if (this.fs.exists(destPath) && hashContent(this.fs.readFile(destPath)) === hashContent(content)) {
|
||||
if (this.fs.exists(destPath) && hashContent(this.read(destPath)) === hashContent(content)) {
|
||||
this.preserveMode(srcPath, destPath);
|
||||
continue;
|
||||
}
|
||||
this.fs.mkdirp(nodePath.dirname(destPath));
|
||||
this.fs.writeFile(destPath, content);
|
||||
this.write(destPath, content);
|
||||
this.preserveMode(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
|
||||
/** A plugin file as the fs reads it exactly: bytes when it can, else text. */
|
||||
private read(path: string): FileContent {
|
||||
return this.fs.readBytes ? this.fs.readBytes(path) : this.fs.readFile(path);
|
||||
}
|
||||
|
||||
private write(path: string, content: FileContent): void {
|
||||
if (typeof content === "string") this.fs.writeFile(path, content);
|
||||
else if (this.fs.writeBytes) this.fs.writeBytes(path, content);
|
||||
else this.fs.writeFile(path, Buffer.from(content).toString("utf-8"));
|
||||
}
|
||||
|
||||
/** Preserve every replaced file before touching a target, including its version authority. */
|
||||
private preserveBeforeRefresh(targetDir: string, updates: Array<[string, string]>): boolean {
|
||||
private preserveBeforeRefresh(targetDir: string, updates: Array<[string, FileContent]>): boolean {
|
||||
const backupDir = nodePath.join(nodePath.dirname(this.userPluginsDir), "backups", "managed-refresh", randomUUID());
|
||||
try {
|
||||
for (const [rel, content] of new Map(updates)) {
|
||||
const dest = nodePath.join(targetDir, rel);
|
||||
if (!this.fs.exists(dest) || hashContent(this.fs.readFile(dest)) === hashContent(content)) continue;
|
||||
if (!this.fs.exists(dest) || hashContent(this.read(dest)) === hashContent(content)) continue;
|
||||
const backup = nodePath.join(backupDir, rel);
|
||||
this.fs.mkdirp(nodePath.dirname(backup));
|
||||
this.fs.copyFileExclusive(dest, backup);
|
||||
@@ -276,7 +292,7 @@ export class PluginVendorService {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const updates: Array<[string, string]> = files.map(rel => [rel, this.fs.readFile(nodePath.join(sourceDir, rel))]);
|
||||
const updates: Array<[string, FileContent]> = files.map(rel => [rel, this.read(nodePath.join(sourceDir, rel))]);
|
||||
if (!this.preserveBeforeRefresh(targetDir, [...updates, [GLOBAL_VENDOR_VERSION, `${sourceVersion}\n`]])) continue;
|
||||
this.fs.mkdirp(targetDir);
|
||||
for (const [relPath, content] of updates) {
|
||||
@@ -284,13 +300,13 @@ export class PluginVendorService {
|
||||
const destPath = nodePath.join(targetDir, relPath);
|
||||
if (
|
||||
this.fs.exists(destPath) &&
|
||||
hashContent(this.fs.readFile(destPath)) === hashContent(content)
|
||||
hashContent(this.read(destPath)) === hashContent(content)
|
||||
) {
|
||||
this.preserveMode(srcPath, destPath);
|
||||
continue;
|
||||
}
|
||||
this.fs.mkdirp(nodePath.dirname(destPath));
|
||||
this.fs.writeFile(destPath, content);
|
||||
this.write(destPath, content);
|
||||
this.preserveMode(srcPath, destPath);
|
||||
}
|
||||
this.fs.writeFile(versionMarker, `${sourceVersion}\n`);
|
||||
@@ -339,6 +355,37 @@ export class PluginVendorService {
|
||||
}
|
||||
}
|
||||
|
||||
function hashContent(content: string): string {
|
||||
/** A plugin file's content: its bytes, or its text from an fs that only reads text. */
|
||||
type FileContent = string | Uint8Array;
|
||||
|
||||
/** The same digest for a text and for its UTF-8 bytes, so a text compare and a byte compare agree. */
|
||||
function hashContent(content: FileContent): string {
|
||||
return createHash("sha256").update(content).digest("hex");
|
||||
}
|
||||
|
||||
/** The daemon's own filesystem for vendoring: files are copied and compared as bytes. */
|
||||
export function nodePluginVendorFs(): PluginVendorFs {
|
||||
return {
|
||||
readFile: (p) => fs.readFileSync(p, "utf-8"),
|
||||
writeFile: (p, c) => fs.writeFileSync(p, c, "utf-8"),
|
||||
readBytes: (p) => fs.readFileSync(p),
|
||||
writeBytes: (p, c) => fs.writeFileSync(p, c),
|
||||
copyFileExclusive: (src, dest) => fs.copyFileSync(src, dest, fs.constants.COPYFILE_EXCL),
|
||||
exists: (p) => fs.existsSync(p),
|
||||
mkdirp: (p) => { fs.mkdirSync(p, { recursive: true }); },
|
||||
listFiles: (dir) => {
|
||||
const r: string[] = [];
|
||||
/** Collect relative file paths recursively. */
|
||||
const w = (d: string, pre: string) => {
|
||||
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
||||
if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name));
|
||||
else r.push(pre ? nodePath.join(pre, e.name) : e.name);
|
||||
}
|
||||
};
|
||||
w(dir, "");
|
||||
return r;
|
||||
},
|
||||
statMode: (p) => fs.statSync(p).mode,
|
||||
chmod: (p, m) => fs.chmodSync(p, m),
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -19,7 +19,8 @@ export interface CodexLimitBanner {
|
||||
evidence: string;
|
||||
}
|
||||
|
||||
const BANNER_LINE_RE = /^\s*■ You've hit your usage limit\b/;
|
||||
// Codex prints a typographic apostrophe (U+2019); older builds use the straight one.
|
||||
const BANNER_LINE_RE = /^\s*■ You['’]ve hit your usage limit\b/u;
|
||||
const RESET_RE = /try again at ([^.!\n]+)/i;
|
||||
|
||||
// Fenced code blocks: banner-shaped text an agent printed is quoted content,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -291,7 +291,9 @@ function custodyVerifiedTargets(db: Database.Database, qitemId: string, targets:
|
||||
for (const { transition_note: note } of notes) {
|
||||
if (!note) continue;
|
||||
for (const token of note.slice(CUSTODY_VERIFIED_PREFIX.length).trim().split(/\s+/)) {
|
||||
const named = [token, ...token.split(",").map((t) => t.trim())].filter(Boolean);
|
||||
const named = [token, ...token.split(",").map((t) => t.trim())]
|
||||
.filter(Boolean)
|
||||
.map((t) => targets.includes(t) ? t : t.replace(/[.,;:)]$/, ""));
|
||||
if (!named.some((t) => targets.includes(t))) continue;
|
||||
for (const t of named) verified.add(t);
|
||||
break;
|
||||
|
||||
@@ -92,7 +92,7 @@ export class Reconciler {
|
||||
// A session this server doesn't show isn't gone while its seat's recorded process still runs: the daemon is
|
||||
// looking at a different tmux server. Detaching it would have the kernel relaunch duplicates and supersede the
|
||||
// live seats. Without that evidence (after a reboot nothing recorded is running), it detaches as before.
|
||||
const elsewhere = await this.recordedProcessStillRunning(session, processes);
|
||||
const elsewhere = await recordedProcessStillRunning(this.db, session, processes);
|
||||
if (elsewhere) {
|
||||
unseen.push({ sessionId: session.id, sessionName: session.sessionName, ...elsewhere });
|
||||
continue;
|
||||
@@ -123,31 +123,32 @@ export class Reconciler {
|
||||
|
||||
return { checked, detached, detachedSessionIds, unseen, errors };
|
||||
}
|
||||
}
|
||||
|
||||
/** The seat's last observed process, as a conservative hint that it still runs, not proof of identity: the last pid
|
||||
* evidence recorded for this very session (kept with its own time, so a later poll that couldn't see the seat doesn't
|
||||
* erase it), and a process with that pid whose start isn't observably later than that observation. Start times are
|
||||
* whole seconds, so a pid reused within the observation's second still passes. An unreadable table or record is no
|
||||
* evidence. */
|
||||
private async recordedProcessStillRunning(
|
||||
session: { nodeId: string; sessionName: string },
|
||||
processes: () => Promise<ProcessTableRow[] | null>,
|
||||
): Promise<{ pid: number; parent: { pid: number; command: string } | null } | null> {
|
||||
const evidence = new SeatIdentityStore(this.db).getProcessEvidence(session.nodeId);
|
||||
if (!evidence || evidence.sessionName !== session.sessionName) return null;
|
||||
const observedAt = Date.parse(evidence.observedAt) / 1000;
|
||||
const pids = [evidence.panePid, evidence.pid]
|
||||
.filter((pid): pid is number => typeof pid === "number" && pid > 0);
|
||||
if (!pids.length || Number.isNaN(observedAt)) return null;
|
||||
const rows = await processes();
|
||||
if (!rows) return null;
|
||||
for (const pid of pids) {
|
||||
const row = rows.find((r) => r.pid === pid);
|
||||
const started = row ? lstartToMinTs(row.startedAt) : undefined;
|
||||
if (!row || started === undefined || started > observedAt) continue;
|
||||
const parent = rows.find((r) => r.pid === row.ppid);
|
||||
return { pid, parent: parent ? { pid: parent.pid, command: parent.command } : null };
|
||||
}
|
||||
return null;
|
||||
/** The seat's last observed process, as a conservative hint that it still runs, not proof of identity: the last pid
|
||||
* evidence recorded for this very session (kept with its own time, so a later poll that couldn't see the seat doesn't
|
||||
* erase it), and a process with that pid whose start isn't observably later than that observation. Start times are
|
||||
* whole seconds, so a pid reused within the observation's second still passes. An unreadable table or record is no
|
||||
* evidence. */
|
||||
export async function recordedProcessStillRunning(
|
||||
db: Database.Database,
|
||||
session: { nodeId: string; sessionName: string },
|
||||
processes: () => Promise<ProcessTableRow[] | null>,
|
||||
): Promise<{ pid: number; parent: { pid: number; command: string } | null } | null> {
|
||||
const evidence = new SeatIdentityStore(db).getProcessEvidence(session.nodeId);
|
||||
if (!evidence || evidence.sessionName !== session.sessionName) return null;
|
||||
const observedAt = Date.parse(evidence.observedAt) / 1000;
|
||||
const pids = [evidence.panePid, evidence.pid]
|
||||
.filter((pid): pid is number => typeof pid === "number" && pid > 0);
|
||||
if (!pids.length || Number.isNaN(observedAt)) return null;
|
||||
const rows = await processes();
|
||||
if (!rows) return null;
|
||||
for (const pid of pids) {
|
||||
const row = rows.find((r) => r.pid === pid);
|
||||
const started = row ? lstartToMinTs(row.startedAt) : undefined;
|
||||
if (!row || started === undefined || started > observedAt) continue;
|
||||
const parent = rows.find((r) => r.pid === row.ppid);
|
||||
return { pid, parent: parent ? { pid: parent.pid, command: parent.command } : null };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -17,25 +17,21 @@ import nodePath from "node:path";
|
||||
import { lstatSync } from "node:fs";
|
||||
import type Database from "better-sqlite3";
|
||||
import {
|
||||
MANAGED_BLOCK_START,
|
||||
MANAGED_BLOCK_END,
|
||||
DEFAULT_CLAUDE_MANAGED_BLOCK_FILE,
|
||||
findManagedBlocks,
|
||||
mergeManagedBlock,
|
||||
stripManagedBlocks,
|
||||
type ManagedBlockMergeFsOps,
|
||||
isPerSeatRoleGuidance,
|
||||
isPerSeatRoleStartupFile,
|
||||
} from "./managed-blocks.js";
|
||||
import { decideManagedBlock, recordManagedBlockWrite, type ManagedBlockRecords } from "./managed-block-records.js";
|
||||
import { decideManagedBlock, pruneKeptManagedBlock, recordManagedBlockWrite, type ManagedBlockRecords } from "./managed-block-records.js";
|
||||
import { resolveConcreteHint } from "./runtime-adapter.js";
|
||||
import { reanchorBuiltinStartupFile, reanchorShippedProjectionEntry, withContextEntryFloor } from "./builtin-startup-files.js";
|
||||
import type { NodeStartupSnapshot } from "./types.js";
|
||||
import { SEAT_SKILLS_BOOTSTRAP_BLOCK_ID } from "./seat-skill-storage.js";
|
||||
import { guidanceFileIsLink } from "./footprint-choice.js";
|
||||
|
||||
const LEGACY_BLOCK_START = (id: string) => `<!-- BEGIN RIGGED MANAGED BLOCK: ${id} -->`;
|
||||
const LEGACY_BLOCK_END = (id: string) => `<!-- END RIGGED MANAGED BLOCK: ${id} -->`;
|
||||
|
||||
|
||||
export interface GuidanceItem {
|
||||
blockId: string;
|
||||
@@ -97,15 +93,10 @@ export function recordedGuidanceFile(db: Pick<Database.Database, "prepare">, nod
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Whether guidance text holds this id's block as it is written: a BEGIN marker followed later by its END, in the
|
||||
* current or legacy form. For reporting an installed file; restore's own `blockState` is unchanged. */
|
||||
/** Whether guidance text holds this id's block as it is written: a BEGIN marker followed by its END, in the current or
|
||||
* legacy form, read as the writer reads it (`findManagedBlocks`). For reporting an installed file. */
|
||||
export function hasCompleteGuidanceBlock(content: string, blockId: string): boolean {
|
||||
const ordered = (start: string, end: string): boolean => {
|
||||
const begin = content.indexOf(start);
|
||||
return begin >= 0 && content.indexOf(end, begin + start.length) >= 0;
|
||||
};
|
||||
return ordered(MANAGED_BLOCK_START(blockId), MANAGED_BLOCK_END(blockId))
|
||||
|| ordered(LEGACY_BLOCK_START(blockId), LEGACY_BLOCK_END(blockId));
|
||||
return findManagedBlocks(content, blockId).blocks.length > 0;
|
||||
}
|
||||
|
||||
/** The guidance file a seat's managed blocks live in, and the blocks it should hold on restore. */
|
||||
@@ -199,14 +190,13 @@ export function applySeatSkillsLineRule(guidance: SeatGuidance, answeredYes: boo
|
||||
|
||||
type BlockState = "complete" | "absent" | "incomplete";
|
||||
|
||||
/** A block counts only with both its markers (current or legacy form). A lone marker is incomplete:
|
||||
* writing next to it is unsafe, because cleanup strips from a BEGIN to the next END. */
|
||||
/** A block counts only with both its markers (current or legacy form), read as the writer reads them. A lone marker is
|
||||
* incomplete: writing next to it is unsafe, because cleanup strips from a BEGIN to the next END, and it would add a
|
||||
* second copy beside one that can't be read. */
|
||||
function blockState(content: string, blockId: string): BlockState {
|
||||
const current = [content.includes(MANAGED_BLOCK_START(blockId)), content.includes(MANAGED_BLOCK_END(blockId))];
|
||||
const legacy = [content.includes(LEGACY_BLOCK_START(blockId)), content.includes(LEGACY_BLOCK_END(blockId))];
|
||||
if ((current[0] && current[1]) || (legacy[0] && legacy[1])) return "complete";
|
||||
if (current[0] || current[1] || legacy[0] || legacy[1]) return "incomplete";
|
||||
return "absent";
|
||||
const found = findManagedBlocks(content, blockId);
|
||||
if (found.blocks.length > 0) return "complete";
|
||||
return found.unpaired > 0 ? "incomplete" : "absent";
|
||||
}
|
||||
|
||||
function blockStates(guidance: SeatGuidance, fs: Pick<ManagedBlockMergeFsOps, "exists" | "readFile">): Map<string, BlockState> {
|
||||
@@ -228,11 +218,13 @@ export interface GuidanceRepair {
|
||||
refreshed: string[];
|
||||
/** #2456: blocks left as they are although they differ from their source, each named with the reason
|
||||
* (`guidance_block_edited`, `guidance_block_may_be_stale` or `guidance_block_record_unreadable`). The seat runs them as
|
||||
* they are. A block written whose record wasn't saved is named here too (`guidance_block_record_not_saved`). */
|
||||
* they are. A block written whose record wasn't saved is named here too (`guidance_block_record_not_saved`), and so are
|
||||
* a kept block's removed repeats (`guidance_block_copies_removed`). */
|
||||
kept: string[];
|
||||
}
|
||||
|
||||
const KEPT_GUIDANCE_PREFIXES = ["guidance_block_edited: ", "guidance_block_may_be_stale: ", "guidance_block_record_unreadable: "];
|
||||
const KEPT_GUIDANCE_PREFIXES = ["guidance_block_edited: ", "guidance_block_may_be_stale: ", "guidance_block_record_unreadable: ",
|
||||
"guidance_block_markers_damaged: "];
|
||||
|
||||
/** #2456: the one line a seat is told when a managed guidance block was left as it is (edited by hand, or possibly stale),
|
||||
* naming each one, or undefined when none was. */
|
||||
@@ -261,7 +253,8 @@ export function appendIsCleanupSafe(content: string, blocks: Array<{ blockId: st
|
||||
|
||||
/** Put back the missing managed guidance blocks. With `records` (#2456), a block already there that OpenRig wrote is
|
||||
* refreshed from its changed source, and one edited since, or with no record, is kept and named (`decideManagedBlock`).
|
||||
* Without them a block already there is left as it is, as before. Any other text in the file is left exactly as it is,
|
||||
* Without them a block already there keeps its text. Either way a block left in place loses its exact repeats, and
|
||||
* OpenRig's own copies beside an edited one, and rewrapped markers go back to OpenRig's form (`pruneKeptManagedBlock`). Any other text in the file is left exactly as it is,
|
||||
* and nothing is written unless cleanup would later remove exactly what was written. Never throws: a failure becomes a
|
||||
* reported gap. */
|
||||
export function restoreMissingGuidance(guidance: SeatGuidance, fs: ManagedBlockMergeFsOps, records?: ManagedBlockRecords): GuidanceRepair {
|
||||
@@ -279,17 +272,27 @@ export function restoreMissingGuidance(guidance: SeatGuidance, fs: ManagedBlockM
|
||||
for (const item of guidance.items) {
|
||||
const state = states.get(item.blockId);
|
||||
if (state === "complete") {
|
||||
if (!records || item.unresolved) continue;
|
||||
if (item.unresolved) continue;
|
||||
let source: string;
|
||||
try {
|
||||
if (!fs.exists(item.sourcePath)) continue;
|
||||
source = fs.readFile(item.sourcePath);
|
||||
} catch { continue; }
|
||||
// Without records the block's text stays as it is; only its exact repeats and rewrapped markers are tidied.
|
||||
if (!records) {
|
||||
pruneKeptManagedBlock(fs, guidance.targetPath, item.blockId, source, undefined, repair.kept);
|
||||
continue;
|
||||
}
|
||||
// #2456: the same decision every adapter's guidance write makes; an exact resume and a handover keep an unrecorded block.
|
||||
const decision = decideManagedBlock(existing ?? "", item.blockId, source, records, guidance.targetPath, { unrecorded: "keep", sourcePath: item.sourcePath });
|
||||
if (decision.note) repair.kept.push(decision.note);
|
||||
else if (decision.standing === "current") recordManagedBlockWrite(records, guidance.targetPath, item.blockId, source, repair.kept);
|
||||
else if (decision.write) toWrite.push({ blockId: item.blockId, content: source, refresh: true });
|
||||
if (decision.write && decision.standing !== "current") {
|
||||
toWrite.push({ blockId: item.blockId, content: source, refresh: true });
|
||||
continue;
|
||||
}
|
||||
if (decision.standing === "current") recordManagedBlockWrite(records, guidance.targetPath, item.blockId, source, repair.kept);
|
||||
// A block left as it is (current, edited or unrecorded) keeps one copy of each distinct text, in OpenRig's markers.
|
||||
pruneKeptManagedBlock(fs, guidance.targetPath, item.blockId, source, records, repair.kept);
|
||||
continue;
|
||||
}
|
||||
if (state === "incomplete") {
|
||||
@@ -313,6 +316,9 @@ export function restoreMissingGuidance(guidance: SeatGuidance, fs: ManagedBlockM
|
||||
if (toWrite.length === 0) return repair;
|
||||
// A file that doesn't exist yet holds no text to lose. An existing one is written only if the
|
||||
// next rig down would remove exactly what is added.
|
||||
// Read again: removing a kept block's repeats above may have changed the file.
|
||||
try { existing = fs.exists(guidance.targetPath) ? fs.readFile(guidance.targetPath) : null; }
|
||||
catch (err) { repair.gaps.push(`could not read ${guidance.targetPath}: ${(err as Error).message}`); return repair; }
|
||||
if (existing !== null && !appendIsCleanupSafe(existing, toWrite)) {
|
||||
for (const block of toWrite) {
|
||||
repair.gaps.push(`${block.blockId}: not written, because ${guidance.targetPath} has a managed-block marker that the next rig down would strip together with other text, so adding blocks there could cost that text`);
|
||||
@@ -321,7 +327,7 @@ export function restoreMissingGuidance(guidance: SeatGuidance, fs: ManagedBlockM
|
||||
}
|
||||
for (const block of toWrite) {
|
||||
try {
|
||||
mergeManagedBlock(fs, guidance.targetPath, block.blockId, block.content);
|
||||
if (!mergeManagedBlock(fs, guidance.targetPath, block.blockId, block.content, { warnings: repair.gaps })) continue;
|
||||
if (records) recordManagedBlockWrite(records, guidance.targetPath, block.blockId, block.content, repair.kept);
|
||||
(block.refresh ? repair.refreshed : repair.restored).push(block.blockId);
|
||||
} catch (err) {
|
||||
|
||||
@@ -141,6 +141,22 @@ export class SeatIdentityReconciler {
|
||||
`).all() as RunningSeatRow[];
|
||||
}
|
||||
|
||||
/** Nodes whose latest session `rig unclaim` released (detached, origin claimed). Unclaim leaves the process running,
|
||||
* so the node's last recorded process stays on record: a fresh launch reads it before reopening a tmux server, and
|
||||
* must not start a second occupant beside a process that still runs. An older schema without `origin` keeps none. */
|
||||
private releasedSeats(): string[] {
|
||||
try {
|
||||
return (this.db.prepare(`
|
||||
SELECT n.id as node_id FROM nodes n
|
||||
JOIN sessions s ON s.node_id = n.id
|
||||
AND s.id = (SELECT s2.id FROM sessions s2 WHERE s2.node_id = n.id ORDER BY s2.id DESC LIMIT 1)
|
||||
WHERE s.status = 'detached' AND s.origin = 'claimed'
|
||||
`).all() as Array<{ node_id: string }>).map((r) => r.node_id);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconcile every running tmux-bound seat once and persist the verdicts. */
|
||||
async reconcileAll(): Promise<void> {
|
||||
// Skip ticks while actual reads are pending; never release on a deadline
|
||||
@@ -156,8 +172,8 @@ export class SeatIdentityReconciler {
|
||||
|
||||
private async reconcileSweep(generation: number): Promise<void> {
|
||||
const seats = this.runningSeats();
|
||||
// Prune verdicts for nodes no longer running (keep the table bounded).
|
||||
this.store.pruneExcept(seats.map((s) => s.node_id));
|
||||
// Prune verdicts for nodes no longer running (keep the table bounded), except a released seat's.
|
||||
this.store.pruneExcept([...seats.map((s) => s.node_id), ...this.releasedSeats()]);
|
||||
if (seats.length === 0) return;
|
||||
|
||||
const observedAt = this.now().toISOString();
|
||||
|
||||
@@ -55,6 +55,8 @@ import { sessionCommunityHarnessSelect } from "./session-registry.js";
|
||||
import { seatLaunchAdditionsReport } from "./seat-launch-additions.js";
|
||||
import { seatSkillPaths } from "./seat-skill-storage.js";
|
||||
import { resolveSeatKind, seatProcessOwner } from "./seat-kinds.js";
|
||||
import { recordedProcessStillRunning } from "./reconciler.js";
|
||||
import { readProcessTable, type ProcessTableRow } from "./process-table.js";
|
||||
|
||||
/**
|
||||
* S5 (OPR.0.5.4.7) — the seat-lifecycle verb surface: set-model, single-seat stop,
|
||||
@@ -110,6 +112,8 @@ export interface SeatLifecycleDeps {
|
||||
eventBus: EventBus;
|
||||
tmuxAdapter: TmuxAdapter;
|
||||
listProcesses?: NativeProcessLister;
|
||||
/** The OS process table, read before reopening a released seat's tmux server; injectable for tests. */
|
||||
readProcesses?: () => Promise<ProcessTableRow[]>;
|
||||
nodeLauncher?: NodeLauncher;
|
||||
startupOrchestrator?: StartupOrchestrator;
|
||||
runtimeAdapters?: Record<string, RuntimeAdapter>;
|
||||
@@ -338,6 +342,7 @@ export class SeatLifecycleService {
|
||||
private readonly eventBus: EventBus;
|
||||
private readonly tmuxAdapter: TmuxAdapter;
|
||||
private readonly listProcesses?: NativeProcessLister;
|
||||
private readonly readProcesses: () => Promise<ProcessTableRow[]>;
|
||||
private readonly nodeLauncher: NodeLauncher | null;
|
||||
private readonly startupOrchestrator: StartupOrchestrator | null;
|
||||
private readonly runtimeAdapters: Record<string, RuntimeAdapter>;
|
||||
@@ -360,6 +365,7 @@ export class SeatLifecycleService {
|
||||
this.eventBus = deps.eventBus;
|
||||
this.tmuxAdapter = deps.tmuxAdapter;
|
||||
this.listProcesses = deps.listProcesses;
|
||||
this.readProcesses = deps.readProcesses ?? readProcessTable;
|
||||
this.nodeLauncher = deps.nodeLauncher ?? null;
|
||||
this.startupOrchestrator = deps.startupOrchestrator ?? null;
|
||||
this.runtimeAdapters = deps.runtimeAdapters ?? {};
|
||||
@@ -1575,17 +1581,34 @@ export class SeatLifecycleService {
|
||||
// probe sees unavailable transport instead of positive absence. Reopen an
|
||||
// empty server only when the persisted state proves this seat's managed
|
||||
// occupant was deliberately stopped and no other managed row remains live.
|
||||
// `rig unclaim` is as deliberate for an adopted session: it marks the row
|
||||
// detached and clears the binding, and the person ends the process and its
|
||||
// tmux session, which can be the server's last session too.
|
||||
// The probe below still decides absence; a recreated session or failed
|
||||
// transport remains a refusal.
|
||||
if ("code" in canonicalProbe) {
|
||||
const latest = this.latestSession(node.id);
|
||||
const hasCurrentBinding = this.sessionRegistry.getBindingForNode(node.id) !== null;
|
||||
const released = latest?.status === "detached" && latest.origin === "claimed";
|
||||
const stoppedManagedOccupant = latest !== null
|
||||
&& latest.session_name === canonicalSessionName
|
||||
&& latest.status === "exited"
|
||||
&& latest.origin !== "claimed"
|
||||
&& ((latest.status === "exited" && latest.origin !== "claimed") || released)
|
||||
&& !hasCurrentBinding
|
||||
&& this.nonTerminalSessions(node.id).length === 0;
|
||||
// Unclaim doesn't end the process. While the released session's recorded process still runs, its server may
|
||||
// only have lost its socket: a server started there would read the session absent and launch a second occupant
|
||||
// beside it. So keep the refusal, as the reconciler keeps such a session (the same evidence).
|
||||
const stillRuns = stoppedManagedOccupant && released
|
||||
? await recordedProcessStillRunning(this.db, { nodeId: node.id, sessionName: canonicalSessionName },
|
||||
() => this.readProcesses().catch(() => null))
|
||||
: null;
|
||||
if (stillRuns) {
|
||||
return {
|
||||
...canonicalProbe,
|
||||
guidance: `This seat's released session last ran as process ${stillRuns.pid}, which is still running, so it may be `
|
||||
+ "on a tmux server this daemon can't reach. End that process, then launch again.",
|
||||
};
|
||||
}
|
||||
if (stoppedManagedOccupant) {
|
||||
try {
|
||||
const restored = await this.tmuxAdapter.startServer();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -273,7 +273,7 @@ packagesRoutes.post("/install", async (c) => {
|
||||
}
|
||||
|
||||
// Verify
|
||||
const verification = installVerifier.verify(result.installId);
|
||||
const verification = installVerifier.verify(result.installId, { skipped: result.notes?.length ?? 0 });
|
||||
if (!verification.passed) {
|
||||
eventBus.emit({ type: "package.install_failed", packageName: resolved.manifest.name, code: "verification_failed", message: "Post-apply verification failed" });
|
||||
return c.json({
|
||||
@@ -281,6 +281,7 @@ packagesRoutes.post("/install", async (c) => {
|
||||
code: "verification_failed",
|
||||
installId: result.installId,
|
||||
verification,
|
||||
...(result.notes?.length ? { notes: result.notes } : {}),
|
||||
}, 500);
|
||||
}
|
||||
|
||||
@@ -302,6 +303,7 @@ packagesRoutes.post("/install", async (c) => {
|
||||
conflicts: result.conflicts,
|
||||
verification,
|
||||
...(policyResult.rejected.length > 0 ? { policyRejected: policyResult.rejected } : {}),
|
||||
...(result.notes?.length ? { notes: result.notes } : {}),
|
||||
}, 201);
|
||||
});
|
||||
|
||||
|
||||
@@ -1049,30 +1049,9 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
// (repo currently empty as of 2026-05-10; vendored
|
||||
// copy is the source of truth at v0).
|
||||
try {
|
||||
const { PluginVendorService } = await import("./domain/plugin-vendor-service.js");
|
||||
const { PluginVendorService, nodePluginVendorFs } = await import("./domain/plugin-vendor-service.js");
|
||||
const vendoredAssetsDir = nodePath.resolve(import.meta.dirname, "../assets/plugins");
|
||||
const userPluginsDir = getDefaultOpenRigPath("plugins");
|
||||
const realFs = {
|
||||
readFile: (p: string) => fs.readFileSync(p, "utf-8"),
|
||||
writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"),
|
||||
copyFileExclusive: (src: string, dest: string) => fs.copyFileSync(src, dest, fs.constants.COPYFILE_EXCL),
|
||||
exists: (p: string) => fs.existsSync(p),
|
||||
mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }),
|
||||
listFiles: (dir: string) => {
|
||||
const r: string[] = [];
|
||||
/** Collect relative file paths recursively. */
|
||||
function w(d: string, pre: string) {
|
||||
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
||||
if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name));
|
||||
else r.push(pre ? nodePath.join(pre, e.name) : e.name);
|
||||
}
|
||||
}
|
||||
w(dir, "");
|
||||
return r;
|
||||
},
|
||||
statMode: (p: string) => fs.statSync(p).mode,
|
||||
chmod: (p: string, m: number) => fs.chmodSync(p, m),
|
||||
};
|
||||
const httpClient = async (url: string, opts?: { timeoutMs?: number }) => {
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), opts?.timeoutMs ?? 5000);
|
||||
@@ -1086,7 +1065,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
const vendorService = new PluginVendorService({
|
||||
vendoredAssetsDir,
|
||||
userPluginsDir,
|
||||
fs: realFs,
|
||||
fs: nodePluginVendorFs(),
|
||||
httpClient,
|
||||
logger: (...args) => console.log("[openrig]", ...args),
|
||||
});
|
||||
|
||||
@@ -1387,6 +1387,38 @@ exports:
|
||||
expect(result.errors.some((e) => e.includes("skipped"))).toBe(true);
|
||||
});
|
||||
|
||||
it("a package whose only guidance entry is left as it is still imports the rig, with the note in warnings", async () => {
|
||||
writePkg(path.join(tmpDir, "test-pkg"), VALID_MANIFEST_YAML, { "skills/helper/SKILL.md": "# Helper" });
|
||||
const guidanceManifest = `
|
||||
schema_version: 1
|
||||
name: guidance-pkg
|
||||
version: "1.0.0"
|
||||
summary: Guidance only
|
||||
compatibility:
|
||||
runtimes: [claude-code]
|
||||
exports:
|
||||
guidance:
|
||||
- source: guidance/rules.md
|
||||
name: rules
|
||||
kind: claude_md
|
||||
supported_scopes: [project_shared]
|
||||
default_scope: project_shared
|
||||
merge_strategy: managed_block
|
||||
`.trim();
|
||||
writePkg(path.join(tmpDir, "guidance-pkg"), guidanceManifest, { "guidance/rules.md": "Follow these rules." });
|
||||
const damaged = "# Project\n\n<!-- BEGIN OpenRig MANAGED BLOCK: guidance-pkg -->\nhalf a block\n";
|
||||
fs.writeFileSync(path.join(tmpDir, "CLAUDE.md"), damaged);
|
||||
const specPath = writeSpec(SPEC_WITH_PACKAGES_YAML.replace(" - ./test-pkg", " - ./test-pkg\n - ./guidance-pkg"));
|
||||
|
||||
const result = await buildOrchestrator().bootstrap({ mode: "apply", sourceRef: specPath, autoApprove: true });
|
||||
|
||||
expect(fs.readFileSync(path.join(tmpDir, "CLAUDE.md"), "utf-8")).toBe(damaged);
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.status).toBe("completed");
|
||||
expect(result.stages.find((s) => s.stage === "import_rig")?.status).not.toBe("skipped");
|
||||
expect(result.warnings).toContainEqual(expect.stringMatching(/^Package '\.\/guidance-pkg' \(claude-code\): guidance_block_markers_damaged: .*CLAUDE\.md: guidance-pkg has a BEGIN or END marker without its pair/));
|
||||
});
|
||||
|
||||
// === P7-T05: Bundle source tests ===
|
||||
|
||||
// T25: Real bundle bootstrap happy path
|
||||
|
||||
@@ -43,6 +43,16 @@ describe("detectCodexLimitBanner", () => {
|
||||
expect(detectCodexLimitBanner(BANNER)).toMatchObject({ resetText: "4:14 AM" });
|
||||
});
|
||||
|
||||
it("matches the typographic apostrophe Codex prints (U+2019)", () => {
|
||||
const pane = [
|
||||
"■ You’ve hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit",
|
||||
"https://chatgpt.com/settings/usage to purchase more credits or try again at Nov 1st, 2026 12:30 AM.",
|
||||
].join("\n");
|
||||
expect(detectCodexLimitBanner(pane)).toMatchObject({
|
||||
resetText: "Nov 1st, 2026 12:30 AM",
|
||||
});
|
||||
});
|
||||
|
||||
it("tolerates leading indent but still requires the marker first", () => {
|
||||
expect(detectCodexLimitBanner(" ■ You've hit your usage limit. try again at 4:14 AM.")).toMatchObject({
|
||||
resetText: "4:14 AM",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -234,6 +234,48 @@ describe("InstallEngine", () => {
|
||||
expect(fs.readFileSync(targetPath, "utf8")).toBe(`${prefix}<!-- BEGIN OpenRig MANAGED BLOCK: test-pkg -->\nUpdated guidance.\n<!-- END OpenRig MANAGED BLOCK: test-pkg -->\n# Footer\n`);
|
||||
});
|
||||
|
||||
it("keeps one copy of its block: the first is updated in place and the repeats go", () => {
|
||||
const pkg = seedPackage();
|
||||
const sourcePath = writeSource("guidance/AGENTS.md", "Updated guidance.");
|
||||
const targetPath = path.join(repoRoot, "AGENTS.md");
|
||||
const old = "<!-- BEGIN OpenRig MANAGED BLOCK: test-pkg -->\nOld content.\n<!-- END OpenRig MANAGED BLOCK: test-pkg -->";
|
||||
fs.writeFileSync(targetPath, `# Header\n\n${old}\n\n${old}\n# Footer\n`);
|
||||
const entry = makeEntry({ exportType: "guidance", classification: "managed_merge", targetPath, sourcePath });
|
||||
new InstallEngine(installRepo, realFs(tmpDir)).apply(makePolicy([entry]), makePlan([entry]), pkg.id, repoRoot);
|
||||
expect(fs.readFileSync(targetPath, "utf8")).toBe(
|
||||
"# Header\n\n<!-- BEGIN OpenRig MANAGED BLOCK: test-pkg -->\nUpdated guidance.\n<!-- END OpenRig MANAGED BLOCK: test-pkg -->\n# Footer\n");
|
||||
});
|
||||
|
||||
it("keeps a later copy someone edited, and every different edit, with the text between", () => {
|
||||
const pkg = seedPackage();
|
||||
const sourcePath = writeSource("guidance/AGENTS.md", "Updated guidance.");
|
||||
const targetPath = path.join(repoRoot, "AGENTS.md");
|
||||
const copy = (body: string) => `<!-- BEGIN OpenRig MANAGED BLOCK: test-pkg -->\n${body}\n<!-- END OpenRig MANAGED BLOCK: test-pkg -->`;
|
||||
fs.writeFileSync(targetPath, `# Header\n\n${copy("Old content.")}\n\nmine\n\n${copy("Old content, with my rule.")}\n\n${copy("Another edit.")}\n`);
|
||||
const entry = makeEntry({ exportType: "guidance", classification: "managed_merge", targetPath, sourcePath });
|
||||
new InstallEngine(installRepo, realFs(tmpDir)).apply(makePolicy([entry]), makePlan([entry]), pkg.id, repoRoot);
|
||||
expect(fs.readFileSync(targetPath, "utf8")).toBe(
|
||||
`# Header\n\n${copy("Updated guidance.")}\n\nmine\n\n${copy("Old content, with my rule.")}\n\n${copy("Another edit.")}\n`);
|
||||
});
|
||||
|
||||
it("installs the rest when a guidance block lost its END: that file is left as it is and named, with no copy added", () => {
|
||||
const pkg = seedPackage();
|
||||
const skillSource = writeSource("skills/foo/SKILL.md", "# Foo Skill");
|
||||
const skillTarget = path.join(repoRoot, ".agents/skills/foo/SKILL.md");
|
||||
const guidanceSource = writeSource("guidance/AGENTS.md", "Updated guidance.");
|
||||
const guidanceTarget = path.join(repoRoot, "AGENTS.md");
|
||||
const text = "# Header\n<!-- BEGIN OpenRig MANAGED BLOCK: test-pkg -->\nOld content.\n# Footer\n";
|
||||
fs.writeFileSync(guidanceTarget, text);
|
||||
const skill = makeEntry({ exportName: "foo", targetPath: skillTarget, sourcePath: skillSource });
|
||||
const guidance = makeEntry({ exportType: "guidance", classification: "managed_merge", targetPath: guidanceTarget, sourcePath: guidanceSource });
|
||||
const result = new InstallEngine(installRepo, realFs(tmpDir)).apply(makePolicy([skill, guidance]), makePlan([skill, guidance]), pkg.id, repoRoot);
|
||||
expect(fs.readFileSync(guidanceTarget, "utf8")).toBe(text);
|
||||
expect(fs.readFileSync(skillTarget, "utf8")).toBe("# Foo Skill");
|
||||
expect(result.applied.map((e) => e.targetPath)).toEqual([skillTarget]);
|
||||
expect(result.notes).toEqual([expect.stringMatching(/^guidance_block_markers_damaged: .*AGENTS\.md: test-pkg has a BEGIN or END marker without its pair/)]);
|
||||
expect(installRepo.getInstall(result.installId)?.status).toBe("applied");
|
||||
});
|
||||
|
||||
// Test 7: Backup created before overwrite
|
||||
it("backup created before overwrite", () => {
|
||||
const pkg = seedPackage();
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
// A guidance file keeps exactly one copy of each OpenRig-managed block, however often OpenRig writes it: after an editor
|
||||
// or formatter touched its markers, after copies collected, and when a marker lost its pair. A person's edit is kept.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
managedBlockStanding,
|
||||
managedBlockText,
|
||||
mergeManagedBlock,
|
||||
type ManagedBlockMergeFsOps,
|
||||
} from "../src/domain/managed-blocks.js";
|
||||
import { managedBlockBodyHash, writeManagedGuidanceBlock, type ManagedBlockRecords } from "../src/domain/managed-block-records.js";
|
||||
import { restoreMissingGuidance } from "../src/domain/restore-guidance.js";
|
||||
|
||||
const FILE = "/repo/AGENTS.md";
|
||||
const SOURCE = "/specs/culture.md";
|
||||
|
||||
/** An in-memory guidance file and source, so nothing here touches a real instruction file. */
|
||||
function memory(files: Record<string, string>): ManagedBlockMergeFsOps & { files: Record<string, string> } {
|
||||
return {
|
||||
files,
|
||||
exists: (p) => p in files,
|
||||
readFile: (p) => files[p]!,
|
||||
writeFile: (p, content) => { files[p] = content; },
|
||||
};
|
||||
}
|
||||
|
||||
function recordsOf(entries: Record<string, string> = {}): ManagedBlockRecords & { entries: Record<string, string> } {
|
||||
return {
|
||||
entries,
|
||||
get: (file, id) => entries[`${file}#${id}`],
|
||||
set: (file, id, body) => { entries[`${file}#${id}`] = managedBlockBodyHash(body); },
|
||||
};
|
||||
}
|
||||
|
||||
const count = (text: string, id = "CULTURE.md") => text.split(`<!-- BEGIN OpenRig MANAGED BLOCK: ${id} -->`).length - 1;
|
||||
|
||||
describe("the writer keeps one copy", () => {
|
||||
it("reads markers a formatter rewrapped as the block, and replaces it in place at every launch", () => {
|
||||
const touched = "# Notes\n\n<!--\n BEGIN OpenRig MANAGED BLOCK: CULTURE.md\n-->\nold culture\n<!-- END OpenRig MANAGED BLOCK: CULTURE.md -->\n\nafter\n";
|
||||
const fs = memory({ [FILE]: touched });
|
||||
const sizes: number[] = [];
|
||||
const written: unknown[] = [];
|
||||
for (let launch = 0; launch < 4; launch++) {
|
||||
written.push(mergeManagedBlock(fs, FILE, "CULTURE.md", "new culture"));
|
||||
sizes.push(fs.files[FILE]!.length);
|
||||
}
|
||||
expect(fs.files[FILE]).toBe(`# Notes\n\n${managedBlockText("CULTURE.md", "new culture")}\n\nafter\n`);
|
||||
expect(new Set(sizes).size).toBe(1);
|
||||
expect(written).toEqual([true, true, true, true]);
|
||||
});
|
||||
|
||||
it("collapses copies that collected to the first, and keeps a person's text before, between and after them", () => {
|
||||
const block = managedBlockText("CULTURE.md", "culture");
|
||||
const fs = memory({ [FILE]: `# Notes\n\n${block}\n\nperson text\n\n${block}\n\n${block}\n\nthe end\n` });
|
||||
mergeManagedBlock(fs, FILE, "CULTURE.md", "culture");
|
||||
expect(fs.files[FILE]).toBe(`# Notes\n\n${block}\n\nperson text\n\nthe end\n`);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a BEGIN whose END was lost", "# Notes\n\n<!-- BEGIN OpenRig MANAGED BLOCK: CULTURE.md -->\nculture\n<!-- END OpenRig MANAGED BLOCK -->\n"],
|
||||
["an END whose BEGIN was lost", "# Notes\n\n<!-- BEGIN MANAGED BLOCK: CULTURE.md -->\nculture\n<!-- END OpenRig MANAGED BLOCK: CULTURE.md -->\n"],
|
||||
])("adds no copy beside %s, at any launch, and names it", (_name, text) => {
|
||||
const fs = memory({ [FILE]: text });
|
||||
const warnings: string[] = [];
|
||||
const written: unknown[] = [];
|
||||
for (let launch = 0; launch < 3; launch++) written.push(mergeManagedBlock(fs, FILE, "CULTURE.md", "culture", { warnings }));
|
||||
expect(fs.files[FILE]).toBe(text);
|
||||
expect(warnings).toHaveLength(3);
|
||||
expect(written).toEqual([false, false, false]);
|
||||
expect(warnings[0]).toMatch(/^guidance_block_markers_damaged: \/repo\/AGENTS\.md: CULTURE\.md has a BEGIN or END marker without its pair/);
|
||||
});
|
||||
|
||||
it("still replaces a complete copy when a lost marker sits beside it, leaves that marker, and names it", () => {
|
||||
const lone = "<!-- BEGIN OpenRig MANAGED BLOCK: CULTURE.md -->\nstray\n";
|
||||
const fs = memory({ [FILE]: `${lone}\n${managedBlockText("CULTURE.md", "old")}\n` });
|
||||
const warnings: string[] = [];
|
||||
const written = mergeManagedBlock(fs, FILE, "CULTURE.md", "new", { warnings });
|
||||
expect(fs.files[FILE]).toBe(`${lone}\n${managedBlockText("CULTURE.md", "new")}\n`);
|
||||
expect(written).toBe(true);
|
||||
expect(warnings).toEqual([expect.stringMatching(/^guidance_block_marker_unpaired: \/repo\/AGENTS\.md: CULTURE\.md was written/)]);
|
||||
});
|
||||
|
||||
it("appends to a file with no marker at all, as before", () => {
|
||||
const fs = memory({ [FILE]: "# Notes\n" });
|
||||
mergeManagedBlock(fs, FILE, "CULTURE.md", "culture");
|
||||
expect(fs.files[FILE]).toBe(`# Notes\n\n\n${managedBlockText("CULTURE.md", "culture")}`);
|
||||
});
|
||||
|
||||
it("reads a lost marker as damaged, not absent", () => {
|
||||
expect(managedBlockStanding("<!-- BEGIN OpenRig MANAGED BLOCK: CULTURE.md -->\nculture\n", "CULTURE.md", "culture", undefined, managedBlockBodyHash))
|
||||
.toBe("damaged");
|
||||
expect(managedBlockStanding("# no block\n", "CULTURE.md", "culture", undefined, managedBlockBodyHash)).toBe("absent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a launch's guidance write keeps a person's edit and drops only repeats", () => {
|
||||
const launch = (fs: ManagedBlockMergeFsOps, records: ManagedBlockRecords, source: string, warnings: string[]) =>
|
||||
writeManagedGuidanceBlock(fs, FILE, "CULTURE.md", source, { records, sourcePath: SOURCE, warnings });
|
||||
|
||||
it("refreshes OpenRig's own copies to one", () => {
|
||||
const old = managedBlockText("CULTURE.md", "old culture");
|
||||
const fs = memory({ [FILE]: `# Notes\n\n${old}\n\n${old}\n` });
|
||||
const records = recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("old culture") });
|
||||
expect(launch(fs, records, "new culture", [])).toBe(true);
|
||||
expect(fs.files[FILE]).toBe(`# Notes\n\n${managedBlockText("CULTURE.md", "new culture")}\n`);
|
||||
});
|
||||
|
||||
it("keeps copies a formatter changed as edits, and leaves one of them", () => {
|
||||
const formatted = managedBlockText("CULTURE.md", "- culture, reformatted");
|
||||
const fs = memory({ [FILE]: `# Notes\n\n${formatted}\n\n${formatted}\n\n${formatted}\n` });
|
||||
const records = recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("* culture") });
|
||||
const warnings: string[] = [];
|
||||
expect(launch(fs, records, "* culture", warnings)).toBe(false);
|
||||
expect(fs.files[FILE]).toBe(`# Notes\n\n${formatted}\n`);
|
||||
expect(warnings.map((w) => w.split(":")[0])).toEqual(["guidance_block_edited", "guidance_block_copies_removed"]);
|
||||
});
|
||||
|
||||
it("keeps a person's edited copy and drops OpenRig's unedited copy beside it", () => {
|
||||
const edited = managedBlockText("CULTURE.md", "culture, with my change");
|
||||
const fs = memory({ [FILE]: `${managedBlockText("CULTURE.md", "culture")}\n\n${edited}\n` });
|
||||
const records = recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("culture") });
|
||||
expect(launch(fs, records, "culture", [])).toBe(false);
|
||||
expect(fs.files[FILE]).toBe(`${edited}\n`);
|
||||
});
|
||||
|
||||
it("keeps two different edited copies: it can't tell which one is meant", () => {
|
||||
const text = `${managedBlockText("CULTURE.md", "edit one")}\n\n${managedBlockText("CULTURE.md", "edit two")}\n`;
|
||||
const fs = memory({ [FILE]: text });
|
||||
expect(launch(fs, recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("culture") }), "culture", [])).toBe(false);
|
||||
expect(fs.files[FILE]).toBe(text);
|
||||
});
|
||||
|
||||
it("records nothing and adds no copy beside a lost marker", () => {
|
||||
const text = "<!-- BEGIN OpenRig MANAGED BLOCK: CULTURE.md -->\nculture\n";
|
||||
const fs = memory({ [FILE]: text });
|
||||
const records = recordsOf();
|
||||
const warnings: string[] = [];
|
||||
const written: unknown[] = [];
|
||||
for (let i = 0; i < 3; i++) written.push(launch(fs, records, "culture", warnings));
|
||||
expect(fs.files[FILE]).toBe(text);
|
||||
expect(written).toEqual([false, false, false]);
|
||||
expect(records.entries).toEqual({});
|
||||
expect(warnings.every((w) => w.startsWith("guidance_block_markers_damaged: "))).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps going when the file can't be written: the edited block is kept as decided, and the tidy is named", () => {
|
||||
const formatted = managedBlockText("CULTURE.md", "- culture, reformatted");
|
||||
const text = `${formatted}\n\n${formatted}\n`;
|
||||
const fs = memory({ [FILE]: text });
|
||||
fs.writeFile = () => { throw new Error("EACCES: permission denied"); };
|
||||
const warnings: string[] = [];
|
||||
expect(launch(fs, recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("* culture") }), "* culture", warnings)).toBe(false);
|
||||
expect(fs.files[FILE]).toBe(text);
|
||||
expect(warnings.map((w) => w.split(":")[0])).toEqual(["guidance_block_edited", "guidance_block_copies_not_removed"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restore keeps one copy", () => {
|
||||
it("removes repeats of a current block, and a second restore changes nothing", () => {
|
||||
const current = managedBlockText("CULTURE.md", "* culture");
|
||||
const fs = memory({ [FILE]: `# Notes\n\n${current}\n\nmine\n\n${current}\n`, [SOURCE]: "* culture" });
|
||||
const records = recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("* culture") });
|
||||
const guidance = { targetPath: FILE, items: [{ blockId: "CULTURE.md", sourcePath: SOURCE }] };
|
||||
restoreMissingGuidance(guidance, fs, records);
|
||||
const once = fs.files[FILE];
|
||||
restoreMissingGuidance(guidance, fs, records);
|
||||
expect(once).toBe(`# Notes\n\n${current}\n\nmine\n`);
|
||||
expect(fs.files[FILE]).toBe(once);
|
||||
});
|
||||
|
||||
it("without write records keeps the block's text, and still removes its exact repeats and puts rewrapped markers back", () => {
|
||||
const rewrapped = "<!--\n BEGIN OpenRig MANAGED BLOCK: CULTURE.md -->\nmy edit\n<!-- END OpenRig MANAGED BLOCK: CULTURE.md -->";
|
||||
const fs = memory({ [FILE]: `${rewrapped}\n\n${managedBlockText("CULTURE.md", "my edit")}\n`, [SOURCE]: "* culture" });
|
||||
restoreMissingGuidance({ targetPath: FILE, items: [{ blockId: "CULTURE.md", sourcePath: SOURCE }] }, fs);
|
||||
expect(fs.files[FILE]).toBe(`${managedBlockText("CULTURE.md", "my edit")}\n`);
|
||||
});
|
||||
|
||||
it("removes a kept block's repeats, and puts back nothing beside a lost marker", () => {
|
||||
const formatted = managedBlockText("CULTURE.md", "- culture, reformatted");
|
||||
const fs = memory({
|
||||
[FILE]: `# Notes\n\n${formatted}\n\n${formatted}\n\n<!-- BEGIN OpenRig MANAGED BLOCK: ROLE.md -->\nrole\n`,
|
||||
[SOURCE]: "* culture",
|
||||
"/specs/role.md": "role",
|
||||
});
|
||||
const records = recordsOf({ [`${FILE}#CULTURE.md`]: managedBlockBodyHash("* culture") });
|
||||
const repair = restoreMissingGuidance({ targetPath: FILE, items: [
|
||||
{ blockId: "CULTURE.md", sourcePath: SOURCE },
|
||||
{ blockId: "ROLE.md", sourcePath: "/specs/role.md" },
|
||||
] }, fs, records);
|
||||
expect(fs.files[FILE]).toBe(`# Notes\n\n${formatted}\n\n<!-- BEGIN OpenRig MANAGED BLOCK: ROLE.md -->\nrole\n`);
|
||||
expect(count(fs.files[FILE]!)).toBe(1);
|
||||
expect(repair.restored).toEqual([]);
|
||||
expect(repair.gaps.join("\n")).toContain("ROLE.md: /repo/AGENTS.md holds only part of this block");
|
||||
expect(repair.kept.map((k) => k.split(":")[0])).toEqual(["guidance_block_edited", "guidance_block_copies_removed"]);
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
@@ -90,7 +90,8 @@ describe("openrig-core plugin — manifest shape (HG-2.2)", () => {
|
||||
// Hook + skills wiring
|
||||
expect(manifest["skills"]).toBe("./skills");
|
||||
expect(manifest["hooks"]).toBe("./hooks/claude.json");
|
||||
expect(manifest["repository"]).toMatch(/github:mvschwarz\/openrig-plugins/);
|
||||
// An https URL: Claude's plugin directory drops a `github:` shorthand from its listing.
|
||||
expect(manifest["repository"]).toBe("https://github.com/mvschwarz/openrig-plugins");
|
||||
expect(manifest["license"]).toBeDefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -322,6 +322,32 @@ roles:
|
||||
expect(claudeMd).toContain("# Existing content");
|
||||
});
|
||||
|
||||
it("POST /api/packages/install whose only guidance entry is left as it is → 201 with the note, verified", async () => {
|
||||
writePkg(pkgDir, GUIDANCE_MANIFEST_YAML, {
|
||||
"guidance/rules.md": "Follow these rules.",
|
||||
});
|
||||
const damaged = "# Existing content\n\n<!-- BEGIN OpenRig MANAGED BLOCK: guidance-pkg -->\nhalf a block\n";
|
||||
fs.writeFileSync(path.join(targetDir, "CLAUDE.md"), damaged);
|
||||
|
||||
const res = await app.request("/api/packages/install", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
sourceRef: pkgDir,
|
||||
targetRoot: targetDir,
|
||||
runtime: "claude-code",
|
||||
allowMerge: true,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(fs.readFileSync(path.join(targetDir, "CLAUDE.md"), "utf-8")).toBe(damaged);
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.applied).toEqual([]);
|
||||
expect(body.notes).toEqual([expect.stringMatching(/^guidance_block_markers_damaged: .*CLAUDE\.md: guidance-pkg has a BEGIN or END marker without its pair/)]);
|
||||
expect(body.verification.passed).toBe(true);
|
||||
});
|
||||
|
||||
// --- Test 8: POST /install mixed policy: skills approved, guidance rejected ---
|
||||
it("POST /api/packages/install mixed policy → 201 with applied + policyRejected", async () => {
|
||||
writePkg(pkgDir, MIXED_MANIFEST_YAML, {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// Vendoring copies a bundled plugin into ~/.openrig/plugins byte for byte, binary files included: a plugin's icon.png
|
||||
// must arrive exactly as it ships, and a text file must arrive as it always did.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import nodePath from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { nodePluginVendorFs, PluginVendorService } from "../src/domain/plugin-vendor-service.js";
|
||||
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => { for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); });
|
||||
|
||||
/** A PNG header and bytes that aren't valid UTF-8, so a text round trip would change them. */
|
||||
const ICON = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.from([0xff, 0xfe, 0x80, 0x00, 0xc3, 0x28, 0xa0, 0xa1])]);
|
||||
const sha = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex");
|
||||
const httpClient = async () => ({ ok: false, status: 404 });
|
||||
|
||||
function scratch(): { assets: string; plugins: string } {
|
||||
const root = fs.mkdtempSync(nodePath.join(os.tmpdir(), "openrig-vendor-binary-"));
|
||||
dirs.push(root);
|
||||
return { assets: nodePath.join(root, "assets"), plugins: nodePath.join(root, "home", "plugins") };
|
||||
}
|
||||
|
||||
function shipPlugin(assets: string, version: string, icon = ICON): void {
|
||||
const plugin = nodePath.join(assets, "openrig");
|
||||
fs.mkdirSync(nodePath.join(plugin, ".claude-plugin"), { recursive: true });
|
||||
fs.mkdirSync(nodePath.join(plugin, "skills", "demo"), { recursive: true });
|
||||
fs.writeFileSync(nodePath.join(plugin, ".claude-plugin", "plugin.json"), JSON.stringify({ name: "openrig", version, description: "Team — work" }));
|
||||
fs.writeFileSync(nodePath.join(plugin, ".claude-plugin", "icon.png"), icon);
|
||||
fs.writeFileSync(nodePath.join(plugin, "skills", "demo", "SKILL.md"), "# Demo — a skill\n");
|
||||
fs.writeFileSync(nodePath.join(plugin, "skills", "demo", "badge.png"), icon);
|
||||
}
|
||||
|
||||
const service = (assets: string, plugins: string) =>
|
||||
new PluginVendorService({ vendoredAssetsDir: assets, userPluginsDir: plugins, fs: nodePluginVendorFs(), httpClient });
|
||||
|
||||
describe("vendoring a plugin with a binary file", () => {
|
||||
it("installs the icon byte for byte, and its text files as before", async () => {
|
||||
const { assets, plugins } = scratch();
|
||||
shipPlugin(assets, "0.1.0");
|
||||
await service(assets, plugins).ensureVendored("openrig");
|
||||
const installed = fs.readFileSync(nodePath.join(plugins, "openrig", ".claude-plugin", "icon.png"));
|
||||
expect(sha(installed)).toBe(sha(ICON));
|
||||
expect(fs.readFileSync(nodePath.join(plugins, "openrig", "skills", "demo", "SKILL.md"), "utf-8")).toBe("# Demo — a skill\n");
|
||||
});
|
||||
|
||||
it("leaves an identical install alone at the same version, and refreshes a newer one exactly, keeping the old bytes", async () => {
|
||||
const { assets, plugins } = scratch();
|
||||
shipPlugin(assets, "0.1.0");
|
||||
await service(assets, plugins).ensureVendored("openrig");
|
||||
await service(assets, plugins).ensureVendored("openrig");
|
||||
const backups = nodePath.join(nodePath.dirname(plugins), "backups");
|
||||
expect(fs.existsSync(backups)).toBe(false);
|
||||
|
||||
const newer = Buffer.concat([ICON, Buffer.from([0xfd, 0xbe])]);
|
||||
fs.rmSync(assets, { recursive: true });
|
||||
shipPlugin(assets, "0.2.0", newer);
|
||||
await service(assets, plugins).ensureVendored("openrig");
|
||||
expect(sha(fs.readFileSync(nodePath.join(plugins, "openrig", ".claude-plugin", "icon.png")))).toBe(sha(newer));
|
||||
const kept = fs.readdirSync(nodePath.join(backups, "managed-refresh"))
|
||||
.map((id) => nodePath.join(backups, "managed-refresh", id, ".claude-plugin", "icon.png"))
|
||||
.filter((path) => fs.existsSync(path));
|
||||
expect(kept.map((path) => sha(fs.readFileSync(path)))).toEqual([sha(ICON)]);
|
||||
});
|
||||
|
||||
it("projects a skill's binary file into a global skill root byte for byte", () => {
|
||||
const { assets, plugins } = scratch();
|
||||
shipPlugin(assets, "0.1.0");
|
||||
const root = nodePath.join(nodePath.dirname(plugins), "global-skills");
|
||||
service(assets, plugins).ensureSkillDirGlobally(nodePath.join(assets, "openrig", "skills", "demo"), "demo", "0.1.0", [root]);
|
||||
expect(sha(fs.readFileSync(nodePath.join(root, "demo", "badge.png")))).toBe(sha(ICON));
|
||||
expect(fs.readFileSync(nodePath.join(root, "demo", "SKILL.md"), "utf-8")).toBe("# Demo — a skill\n");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -1767,6 +1767,36 @@ describe("S02 standing stuck sweep — both halves, routed findings, quiet-but-o
|
||||
expect(await findingsFor(row.qitemId)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each(["", ".", ",", ";", ":", ")"])("CUSTODY PUNCTUATION: a closing note accepts the exact target followed by %j", async (punctuation) => {
|
||||
const row = await mkRow();
|
||||
const target = "qitem-20990101000000-punctuation@remote-host";
|
||||
repo.update({ qitemId: row.qitemId, actorSession: "worker@r", state: "done",
|
||||
closureReason: "handed_off_to", closureTarget: target,
|
||||
transitionNote: `custody-verified: ${target}${punctuation} registered-host read confirmed the successor` });
|
||||
await runSweep();
|
||||
expect(await findingsFor(row.qitemId)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each(["x.", ".other.", ":123)"])("CUSTODY PUNCTUATION: extra target characters %j do not verify a prefix", async (suffix) => {
|
||||
const row = await mkRow();
|
||||
const target = "qitem-20990101000000-punctuation@remote-host";
|
||||
repo.update({ qitemId: row.qitemId, actorSession: "worker@r", state: "done",
|
||||
closureReason: "handed_off_to", closureTarget: target,
|
||||
transitionNote: `custody-verified: ${target}${suffix} registered-host read confirmed another successor` });
|
||||
await runSweep();
|
||||
expect(await findingsFor(row.qitemId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("CUSTODY PUNCTUATION: a shortened target is not an exact target", async () => {
|
||||
const row = await mkRow();
|
||||
const target = "qitem-20990101000000-punctuation@remote-host";
|
||||
repo.update({ qitemId: row.qitemId, actorSession: "worker@r", state: "done",
|
||||
closureReason: "handed_off_to", closureTarget: target,
|
||||
transitionNote: `custody-verified: ${target.slice(0, -1)}. checked elsewhere` });
|
||||
await runSweep();
|
||||
expect(await findingsFor(row.qitemId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("DISPOSITION CLOSES THE OPEN FINDING: verification landing after the finding minted auto-closes it on the next sweep, and the finding taught the recipe", async () => {
|
||||
const row = await mkRow();
|
||||
const missing = "qitem-20990101000000-precon02";
|
||||
|
||||
@@ -16,6 +16,7 @@ import { SeatLifecycleService } from "../src/domain/seat-lifecycle-service.js";
|
||||
import { getNodeInventory } from "../src/domain/node-inventory.js";
|
||||
import { PsProjectionService } from "../src/domain/ps-projection.js";
|
||||
import { SeatIdentityStore } from "../src/domain/seat-identity-store.js";
|
||||
import { SeatIdentityReconciler } from "../src/domain/seat-identity-reconciler.js";
|
||||
import type { RuntimeAdapter } from "../src/domain/runtime-adapter.js";
|
||||
import type { ProjectionPlan } from "../src/domain/projection-planner.js";
|
||||
import type { TmuxAdapter, TmuxResult } from "../src/adapters/tmux.js";
|
||||
@@ -24,6 +25,7 @@ import { deriveOriented } from "../src/domain/startup-proof.js";
|
||||
import { deriveRehydrateSessionIdByNode } from "../src/domain/active-occupant.js";
|
||||
import { readFreshOccupantRelations } from "../src/domain/fresh-occupant-relation.js";
|
||||
import type { NativeProcessLister, NativeProcessRow } from "../src/domain/native-process-lineage.js";
|
||||
import type { ProcessTableRow } from "../src/domain/process-table.js";
|
||||
|
||||
import { ClaudeCodeAdapter } from "../src/adapters/claude-code-adapter.js";
|
||||
import { CLAUDE_BYPASS_CONSENT } from "./fixtures/claude-bypass-consent.js";
|
||||
@@ -59,6 +61,7 @@ describe("SeatLifecycleService.launchFresh", () => {
|
||||
let activitySwaps: Array<{ nodeId: string; generation: string }>;
|
||||
let service: SeatLifecycleService;
|
||||
let listProcesses: NativeProcessLister;
|
||||
let readProcesses: () => Promise<ProcessTableRow[]>;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createFullTestDb();
|
||||
@@ -131,6 +134,7 @@ describe("SeatLifecycleService.launchFresh", () => {
|
||||
listProcesses = async () => [{ pid: 4242, ppid: 1, pgid: 4242, tpgid: 4242,
|
||||
executableName: "codex", command: "/opt/native/codex -m model", argv: ["/opt/native/codex", "-m", "model"],
|
||||
startedAt: "Sat Jan 1 12:00:00 2000" }];
|
||||
readProcesses = async () => [];
|
||||
runtimeAdapters = { "claude-code": adapter, codex: { ...adapter, runtime: "codex" } };
|
||||
service = new SeatLifecycleService({
|
||||
db,
|
||||
@@ -139,6 +143,7 @@ describe("SeatLifecycleService.launchFresh", () => {
|
||||
eventBus,
|
||||
tmuxAdapter: tmux,
|
||||
listProcesses: () => listProcesses(),
|
||||
readProcesses: () => readProcesses(),
|
||||
nodeLauncher,
|
||||
startupOrchestrator,
|
||||
runtimeAdapters,
|
||||
@@ -843,6 +848,105 @@ describe("SeatLifecycleService.launchFresh", () => {
|
||||
expect(alive.has(seat.sessionName)).toBe(true);
|
||||
});
|
||||
|
||||
// `rig unclaim` releases an adopted session (its row detached, its binding cleared) and leaves its process running;
|
||||
// the person then ends it and its tmux session. When that was the server's last session, tmux exits with it.
|
||||
const releaseAdopted = (seat: ReturnType<typeof seedSeat>) => {
|
||||
sessionRegistry.markDetached(seat.session!.id);
|
||||
sessionRegistry.clearBinding(seat.node.id);
|
||||
};
|
||||
|
||||
it("reopens an empty tmux server after rig unclaim released the seat's last session", async () => {
|
||||
const seat = seedSeat({ adopted: true });
|
||||
const server = modelServerLifetime();
|
||||
releaseAdopted(seat);
|
||||
alive.delete(seat.sessionName);
|
||||
livePanes.delete(seat.sessionName);
|
||||
server.up = false;
|
||||
|
||||
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, reason: "start a new occupant" });
|
||||
|
||||
expect(result).toMatchObject({ ok: true, status: "ready", sessionName: seat.sessionName });
|
||||
expect(server).toEqual({ up: true, starts: 1 });
|
||||
expect(tmux.createSession).toHaveBeenCalledTimes(1);
|
||||
expect(killed).toEqual([]);
|
||||
});
|
||||
|
||||
it("still refuses a released seat's fresh launch when its session is live behind a transport blip", async () => {
|
||||
const seat = seedSeat({ adopted: true });
|
||||
const server = modelServerLifetime();
|
||||
releaseAdopted(seat);
|
||||
// The released process is still running in its session; only the first probe fails.
|
||||
vi.mocked(tmux.probeSession).mockResolvedValueOnce({ state: "transport_unavailable", cause: "probe blip" });
|
||||
|
||||
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, reason: "start a new occupant" });
|
||||
|
||||
expect(result).toMatchObject({ ok: false, code: "unmanaged_session_collision" });
|
||||
expect(server.starts).toBe(0);
|
||||
expect(tmux.createSession).not.toHaveBeenCalled();
|
||||
expect(killed).toEqual([]);
|
||||
expect(alive.has(seat.sessionName)).toBe(true);
|
||||
});
|
||||
|
||||
// Unclaim doesn't end the process, and tmux can lose its socket while its server keeps running: the probe then
|
||||
// can't reach the server, and a server started at that path is a new, empty one. So the model keeps the two apart:
|
||||
// `alive` and `server.up` are what the socket answers, and the process table is what still runs.
|
||||
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
const lstart = (d: Date) => `${DAYS[d.getDay()]} ${MONTHS[d.getMonth()]} ${String(d.getDate()).padStart(2, " ")} `
|
||||
+ `${[d.getHours(), d.getMinutes(), d.getSeconds()].map((n) => String(n).padStart(2, "0")).join(":")} ${d.getFullYear()}`;
|
||||
const processRow = (pid: number, ppid: number, startedAt: Date, command: string): ProcessTableRow =>
|
||||
({ pid, ppid, pgid: pid, tpgid: -1, executableName: command.split(" ")[0]!, startedAt: lstart(startedAt), command });
|
||||
const observed = new Date(Date.now() - 60_000);
|
||||
const releasedWithLostSocket = async () => {
|
||||
const seat = seedSeat({ adopted: true });
|
||||
const server = modelServerLifetime();
|
||||
// Recorded while the adopted seat ran; then released, and the daemon's identity sweep (every 5 s) runs.
|
||||
new SeatIdentityStore(db).upsert({
|
||||
nodeId: seat.node.id, verdict: "verified", evidenceSource: null, reason: null,
|
||||
evidence: { registeredPane: "%old", observedPid: 5151, observedPanePid: 5150, observedCommand: "claude", matchedLayer: 1 },
|
||||
sessionName: seat.sessionName, observedAt: observed.toISOString(),
|
||||
});
|
||||
releaseAdopted(seat);
|
||||
const blindPoll = { listSessions: async () => [] } as unknown as ConstructorParameters<typeof SeatIdentityReconciler>[0]["tmux"];
|
||||
await new SeatIdentityReconciler({ db, tmux: blindPoll }).reconcileAll();
|
||||
alive.delete(seat.sessionName);
|
||||
livePanes.delete(seat.sessionName);
|
||||
server.up = false;
|
||||
return { seat, server };
|
||||
};
|
||||
|
||||
it("keeps refusing a released seat's fresh launch while its recorded process still runs behind a lost socket", async () => {
|
||||
const { seat } = await releasedWithLostSocket();
|
||||
const before = new Date(observed.getTime() - 10 * 60_000);
|
||||
readProcesses = async () => [processRow(5150, 900, before, "zsh"), processRow(900, 1, before, "tmux new-session")];
|
||||
|
||||
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, reason: "start a new occupant" });
|
||||
|
||||
expect(result).toMatchObject({ ok: false, code: "tmux_probe_failed",
|
||||
guidance: expect.stringContaining("last ran as process 5150, which is still running") });
|
||||
expect(tmux.startServer).not.toHaveBeenCalled();
|
||||
expect(tmux.createSession).not.toHaveBeenCalled();
|
||||
expect(killed).toEqual([]);
|
||||
expect(sessionRegistry.getSessionsForRig(seat.rig.id).map((s) => s.status)).toEqual(["detached"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "its recorded process has ended", rows: async () => [] as ProcessTableRow[] },
|
||||
{ name: "its pid now belongs to a later process",
|
||||
rows: async () => [processRow(5150, 1, new Date(observed.getTime() + 30_000), "vim")] },
|
||||
{ name: "the process table can't be read", rows: async (): Promise<ProcessTableRow[]> => { throw new Error("ps failed"); } },
|
||||
])("reopens the server for a released seat when $name", async ({ rows }) => {
|
||||
const { seat, server } = await releasedWithLostSocket();
|
||||
readProcesses = rows;
|
||||
|
||||
const result = await service.launchFresh({ seatRef: seat.sessionName, fresh: true, reason: "start a new occupant" });
|
||||
|
||||
expect(result).toMatchObject({ ok: true, status: "ready", sessionName: seat.sessionName });
|
||||
expect(server).toEqual({ up: true, starts: 1 });
|
||||
expect(tmux.createSession).toHaveBeenCalledTimes(1);
|
||||
expect(killed).toEqual([]);
|
||||
});
|
||||
|
||||
it("proves older non-terminal rows absent on the restored server before superseding them", async () => {
|
||||
const seat = seedSeat({ clean: true });
|
||||
const older = sessionRegistry.registerSession(seat.node.id, "r00-dev-impl@fresh-rig");
|
||||
|
||||
@@ -440,6 +440,28 @@ describe("SeatIdentityReconciler.reconcileAll", () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("keeps a released seat's recorded process through the prune, and still prunes a stopped one", async () => {
|
||||
// rig unclaim leaves the latest row detached with origin claimed, and the process running. A fresh launch reads
|
||||
// that process's record before reopening a tmux server, so the 5 s sweep mustn't drop it.
|
||||
const db = createFullTestDb();
|
||||
seedSeat(db, { nodeId: "n1", sessionName: "s1@rig", pane: "%1", status: "detached" });
|
||||
db.prepare("UPDATE sessions SET origin = 'claimed' WHERE node_id = 'n1'").run();
|
||||
seedSeat(db, { nodeId: "n2", sessionName: "s2@rig", pane: "%2", status: "exited" });
|
||||
const store = new SeatIdentityStore(db);
|
||||
for (const [nodeId, sessionName, pid] of [["n1", "s1@rig", 5151], ["n2", "s2@rig", 6161]] as const) {
|
||||
store.upsert({ nodeId, verdict: "verified", evidenceSource: null, reason: null,
|
||||
evidence: { registeredPane: "%1", observedPid: pid, observedPanePid: pid - 1, observedCommand: "claude", matchedLayer: 1 },
|
||||
sessionName, observedAt: NOW().toISOString() });
|
||||
}
|
||||
const rec = new SeatIdentityReconciler({ db, tmux: makeTmux({ sessions: [] }), now: NOW });
|
||||
|
||||
await rec.reconcileAll();
|
||||
|
||||
expect(store.getProcessEvidence("n1")).toEqual({ sessionName: "s1@rig", pid: 5151, panePid: 5150, observedAt: NOW().toISOString() });
|
||||
expect(store.getForNode("n2")).toBeNull();
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("liveness is NOT derived from heartbeats — a dead pane stays non-green regardless of any heartbeat", async () => {
|
||||
// The reconciler never reads queue/classifier/hook state; a seat whose pane
|
||||
// is gone yields pane_missing even if (hypothetically) work/heartbeats exist.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,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")),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,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 });
|
||||
}
|
||||
Reference in new issue
Block a user