mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 17:32:22 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d6f5a2d24 | ||
|
|
a8397dd711 | ||
|
|
e35eec247b | ||
|
|
96fc0f3e88 | ||
|
|
f95fa4cd77 |
No files matched your search
@@ -8,7 +8,67 @@ 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.
|
||||
|
||||
## Connect
|
||||
## 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
|
||||
|
||||
```bash
|
||||
rig network connect <seat> --hub <url> --as <participant> --token-file <path>
|
||||
@@ -69,8 +129,9 @@ 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. `--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. 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.
|
||||
|
||||
## When things go wrong
|
||||
|
||||
@@ -88,7 +149,8 @@ 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. The hub still lists the participant until it's removed there too.
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -104,11 +166,14 @@ 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.
|
||||
tailnet address doesn't prove the connection is protected. `rig network enroll` asks the same for a plain-http site.
|
||||
- **Redirects:** the connector follows a hub's redirect only within its own origin, or to its `https:` form on the same
|
||||
host. Any other redirect stops the connection with its target named, and the token isn't sent there. Connect the
|
||||
binding to the hub's own URL.
|
||||
@@ -15,8 +15,25 @@ 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);
|
||||
@@ -30,7 +47,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}`;
|
||||
return `${b.id} ${b.localSession} -> ${b.hubSeat} at ${b.hubUrl}${plain} ${state}${b.lastError && b.state === "active" ? ` last error: ${b.lastError}` : ""}${conflicts}${accountNote(b)}`;
|
||||
}
|
||||
|
||||
/** `rig network`: connect a selected local seat to a participant of a remote hub (an account's network rig, or
|
||||
@@ -56,25 +73,60 @@ 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, using that participant's token")
|
||||
.description("Connect a local seat to a hub participant: by name through the enrolled account, or with a hub URL and token file")
|
||||
.argument("<seat>", "The local seat to connect (its address, member@rig)")
|
||||
.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")
|
||||
.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)")
|
||||
.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 }) => {
|
||||
let token: string;
|
||||
try { token = (getDeps().readTokenFile ?? defaultReadTokenFile)(opts.tokenFile); } catch (error) {
|
||||
console.error(`Can't read the token file: ${(error as Error).message}`);
|
||||
.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.");
|
||||
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", { 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.`);
|
||||
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.`);
|
||||
});
|
||||
|
||||
cmd.command("status")
|
||||
@@ -83,10 +135,11 @@ 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[] }>("/api/network/bindings");
|
||||
const res = await daemon.get<{ bindings: Binding[]; installations?: Installation[] }>("/api/network/bindings");
|
||||
finish(res.status, res.data, opts.json, (d) => {
|
||||
const bindings = (d.bindings ?? []) as Binding[];
|
||||
return bindings.length ? bindings.map(line).join("\n") : "No seats are connected to a hub.";
|
||||
const installations = (d.installations ?? []) as Installation[];
|
||||
return [...installations.map(enrolled), ...(bindings.length ? bindings.map(line) : ["No seats are connected to a hub."])].join("\n");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,8 +151,14 @@ 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() });
|
||||
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.`);
|
||||
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.`;
|
||||
});
|
||||
});
|
||||
|
||||
return cmd;
|
||||
|
||||
@@ -17,10 +17,22 @@ 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 }));
|
||||
next.route("/api/network", networkRoutes({ home, bearerToken, accountFetch, selfHostId: () => "host-a" }));
|
||||
return next;
|
||||
};
|
||||
beforeEach(() => {
|
||||
@@ -31,6 +43,7 @@ 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; });
|
||||
|
||||
@@ -46,7 +59,10 @@ 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> }) => 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>; timeoutMs?: number }) => {
|
||||
posts.push({ url, timeoutMs: options?.timeoutMs });
|
||||
return 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));
|
||||
@@ -63,7 +79,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\)/);
|
||||
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(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -111,6 +127,39 @@ 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,3 +157,46 @@ 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:/);
|
||||
});
|
||||
@@ -10,6 +10,7 @@ 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";
|
||||
@@ -327,4 +328,5 @@ export const ALL_MIGRATIONS: Migration[] = [
|
||||
networkBindingsSchema,
|
||||
networkReceiptsSchema,
|
||||
sessionAddressReadIndexesSchema,
|
||||
networkInstallationsSchema,
|
||||
];
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/** This daemon enrolled in an account: the account's site, its installation id, and whether the person chose plain http
|
||||
* for it. The installation key lives in a private file under the OpenRig home, never in this table. A binding made
|
||||
* through the account names its installation, its participant and the account's installation id the name was listed under,
|
||||
* and records when the account stopped listing it. */
|
||||
export const networkInstallationsSchema: Migration = {
|
||||
name: "163_network_installations.sql",
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS network_installations (
|
||||
id TEXT PRIMARY KEY,
|
||||
site TEXT NOT NULL,
|
||||
installation_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
allow_http INTEGER NOT NULL DEFAULT 0 CHECK (allow_http IN (0, 1)),
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_network_installations_site ON network_installations(site);
|
||||
ALTER TABLE network_bindings ADD COLUMN installation_id TEXT;
|
||||
ALTER TABLE network_bindings ADD COLUMN participant_name TEXT;
|
||||
ALTER TABLE network_bindings ADD COLUMN account_removed_at TEXT;
|
||||
ALTER TABLE network_bindings ADD COLUMN account_installation_id TEXT;
|
||||
`,
|
||||
};
|
||||
@@ -28,10 +28,18 @@ 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 | 404 | 409 | 500 = 400) {
|
||||
constructor(readonly code: string, message: string, readonly status: 400 | 401 | 404 | 409 | 500 | 502 | 503 = 400) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -39,12 +47,15 @@ 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;
|
||||
@@ -58,10 +69,15 @@ 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 {
|
||||
const file = networkCredentialFile(home, bindingId);
|
||||
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 {
|
||||
let stat;
|
||||
try { stat = lstatSync(file); } catch {
|
||||
throw new NetworkBindingError("credential_missing", `The credential for ${bindingId} isn't at ${file}. Connect the seat again with its token file.`, 404);
|
||||
throw new NetworkBindingError("credential_missing", missing(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.`);
|
||||
@@ -77,7 +93,29 @@ export function readNetworkCredential(home: string, bindingId: string): string {
|
||||
return value.token;
|
||||
}
|
||||
|
||||
function validHubUrl(raw: unknown, allowHttp: boolean): string {
|
||||
/** 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 {
|
||||
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.`); }
|
||||
@@ -140,37 +178,39 @@ 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. */
|
||||
create(input: { session: unknown; hubUrl: unknown; hubSeat: unknown; token: unknown; allowHttp?: unknown }): NetworkBinding {
|
||||
const session = typeof input.session === "string" ? input.session.trim() : "";
|
||||
/** 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() : "";
|
||||
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();
|
||||
const file = networkCredentialFile(this.home, id);
|
||||
let created = false;
|
||||
let madeFolder: string | undefined;
|
||||
// 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);
|
||||
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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?)`).run(id, nodeId, session, hubUrl, hubSeat, allowHttp ? 1 : 0, at, at);
|
||||
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);
|
||||
} catch (error) {
|
||||
// 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 });
|
||||
cleanup();
|
||||
throw error;
|
||||
}
|
||||
return this.get(id)!;
|
||||
@@ -187,6 +227,18 @@ 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 = ?")
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
// 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 + "/";
|
||||
}
|
||||
@@ -2,48 +2,76 @@ 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;
|
||||
* 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 {
|
||||
/** 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 {
|
||||
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 store = (c: Context) => new NetworkBindingStore((c.get("rigRepo" as never) as RigRepository).db, opts?.home ?? OPENRIG_HOME);
|
||||
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 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 bindings = store(c).list().map((b) => ({ ...b, closedElsewhere: networkClosedElsewhere(db, b.id) }));
|
||||
return c.json({ bindings, connector: connector?.snapshot() ?? null });
|
||||
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 });
|
||||
});
|
||||
|
||||
// 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) => {
|
||||
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); }
|
||||
const body = await json(c);
|
||||
if (!body) 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("/bindings/:id/disconnect", async (c) => {
|
||||
let body: Record<string, unknown> = {};
|
||||
try { body = await c.req.json(); } catch { /* no reason given */ }
|
||||
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(store(c).disconnect(c.req.param("id"), typeof body.reason === "string" ? body.reason : undefined));
|
||||
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; }
|
||||
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 router;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
@@ -169,7 +170,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];
|
||||
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];
|
||||
|
||||
/**
|
||||
* P24 — the DECLARED exclusions for {@link migrationsForFullTestDb}. That list is deliberately a
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
// 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" });
|
||||
});
|
||||
Reference in new issue
Block a user