mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
Multi-host + Living Notes. Multi-host foundation: shareable whole-topology staged spin-up brings a rig topology up across multiple hosts in stages; VPS product-factory multi-host hardening ships with rig host verbs (add / list / doctor, capped at three; one built-in product-factory-vps posture, three- valued per item); the transport posture is documented (ssh for pane ops, http-bearer for daemon REST, no cross-transport fallback); the For-You feed aggregates activity across every registered host; and rig file moves files across the topology's registered hosts. Living Notes: durable INTENT / PLAN / DELIVERED signal layer at mission and slice altitude with agent authorship + timestamps; cheap composer surfaces make it the first-class place agents record decisions, plans, and delivered work; the one-structure review contract reads left-to-right as a single vertical stack (INTENT above, PLAN + mockup in the middle, DELIVERED with paired proof at the bottom); a plan change deletes the old plan and writes a new one - never demotes or stacks multiple competing plans. Operationalize the SDLC control plane: conventions SSOT ships in source at docs/reference/sdlc-conventions.md (Living Notes UI section names, proof-contract format + plannedRef mockup pairing, staged- approval locks, C1 proof header + closed sets, three role contracts, curation rule, elastic-middle doctrine, advisory fail-open audit posture); rig scope slice create scaffolds the convention sections + proof + PROOF.md + an IMPLEMENTATION-PRD.md skeleton for every template kind; rig scope audit + rig workspace doctor gain sdlc- convention advisories (records-and-advises, gates on HIGH only); the mission-slice-sop skill ships in canonical source with byte-parity guard; rig proof / rig scope slice create / rig scope slice approve help + cli-reference teach the SDLC flow. BREAKING: rig ps default flips to consolidated all-active-rigs compact projection (the v0.4.0 current-rig-only default is retired - it hid running rigs from the operator's field of view). --json is scope-not-shape (still a bare array with existing per-entry keys; scope widens to all non-archived rigs including stopped ones; one-line migration for the old fleet firehose: rig ps --nodes -A --full). -A / --all-rigs keeps exactly ONE meaning (the --nodes fleet widener; bare -A is a structured teaching error). --nodes names its scope everywhere (session default local-only; --host <id> --nodes requires --rig or -A; multi-host fan-out is rollup-only by default; the explicit ladder --all-hosts --nodes -A --full fans out per-node with hostId-stamped projected rows). --all-hosts / --hosts --json emits the shared AggregatedPayload (items + hosts with closed-enum statuses: ok | unreachable | unsupported-transport | auth-failed). Operator UX: agent altitude coordination panel scopes multi-agent state to the right altitude (workspace / mission / slice) so the coordination surface reads without per-seat drowning; composes with the workspace observability tabs from 0.4.1. Docs closeout catches docs/as-built/architecture.md, docs/as-built/cli-reference.md, and the codemaps up to shipped state. Migrations: additive only. Existing v0.4.3 databases upgrade by running rig daemon start. Behavior change (rig ps default view): the compact all-active-rigs projection replaces the current-rig-only default. If your call sites relied on the v0.4.3 default shape, add --full for the rich per-node projection or --rig <name> for the prior scope. Public-safety scrub applied inline to packages/ui/twin/corrective/ fixtures-corrective.ts (one demo-fixture role identifier neutralized to the demo-namespace baseline established at 0.4.1). See CHANGELOG.md and docs/releases/v0.4.4.md.
295 lines
14 KiB
TypeScript
295 lines
14 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import * as os from "node:os";
|
|
import { Command } from "commander";
|
|
import { hostCommand, doctorLegs, postureCheck, type DoctorDeps, type CheckRow } from "../src/commands/host.js";
|
|
import type { HostEntry } from "../src/host-registry.js";
|
|
import type { CrossHostResult } from "../src/cross-host-executor.js";
|
|
|
|
// OPR.0.4.4.13 FR-1 — rig host add / list. Registry path is steered via
|
|
// OPENRIG_HOME so tests never touch the operator's real ~/.openrig.
|
|
describe("rig host add/list", () => {
|
|
let dir: string;
|
|
let savedHome: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), "hostcmd-"));
|
|
savedHome = process.env.OPENRIG_HOME;
|
|
process.env.OPENRIG_HOME = dir;
|
|
});
|
|
afterEach(() => {
|
|
if (savedHome === undefined) delete process.env.OPENRIG_HOME;
|
|
else process.env.OPENRIG_HOME = savedHome;
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
function capture(fn: () => Promise<void>): Promise<{ out: string[]; err: string[]; exitCode: number | undefined }> {
|
|
return new Promise(async (resolve) => {
|
|
const out: string[] = []; const err: string[] = [];
|
|
const ol = console.log; const oe = console.error; const oc = process.exitCode;
|
|
process.exitCode = undefined;
|
|
console.log = (...a: unknown[]) => out.push(a.join(" "));
|
|
console.error = (...a: unknown[]) => err.push(a.join(" "));
|
|
try { await fn(); } finally { console.log = ol; console.error = oe; }
|
|
const exitCode = process.exitCode as number | undefined;
|
|
process.exitCode = oc;
|
|
resolve({ out, err, exitCode });
|
|
});
|
|
}
|
|
|
|
function run(argv: string[]) {
|
|
const prog = new Command();
|
|
prog.exitOverride();
|
|
prog.addCommand(hostCommand());
|
|
return prog.parseAsync(["node", "rig", "host", ...argv]);
|
|
}
|
|
|
|
it("add writes the entry and teaches the doctor next step", async () => {
|
|
const { out, exitCode } = await capture(() => run(["add", "--id", "vps-1", "--transport", "ssh", "--target", "vps-1.tailnet", "--user", "openrig"]));
|
|
expect(exitCode).toBeUndefined();
|
|
expect(out.join("\n")).toContain("Added host 'vps-1' (ssh)");
|
|
expect(out.join("\n")).toContain("rig host doctor vps-1");
|
|
});
|
|
|
|
it("add surfaces the loader's own validation error at add-time (both bearers)", async () => {
|
|
const { err, exitCode } = await capture(() => run(["add", "--id", "h1", "--transport", "http", "--url", "http://x", "--bearer-env", "A", "--bearer-file", "/b"]));
|
|
expect(exitCode).toBe(1);
|
|
expect(err.join("\n")).toContain("exactly one of bearer_env or bearer_file");
|
|
});
|
|
|
|
it("list renders pointers and NEVER a resolved secret value (qa1 hygiene guard)", async () => {
|
|
vi.stubEnv("FACTORY_TOKEN", "super-secret-bearer-value");
|
|
await capture(() => run(["add", "--id", "f1", "--transport", "http", "--url", "http://100.64.0.9:7433", "--bearer-env", "FACTORY_TOKEN"]));
|
|
const { out } = await capture(() => run(["list"]));
|
|
const text = out.join("\n");
|
|
expect(text).toContain("env:FACTORY_TOKEN");
|
|
expect(text).not.toContain("super-secret-bearer-value");
|
|
const { out: jsonOut } = await capture(() => run(["list", "--json"]));
|
|
expect(jsonOut.join("")).toContain("FACTORY_TOKEN");
|
|
expect(jsonOut.join("")).not.toContain("super-secret-bearer-value");
|
|
});
|
|
|
|
it("list on an empty registry teaches add", async () => {
|
|
const { out } = await capture(() => run(["list"]));
|
|
expect(out.join("\n")).toContain("rig host add --id");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// OPR.0.4.4.13 FR-1/FR-2 — doctor legs + posture (mocked deps; no network).
|
|
// ---------------------------------------------------------------------------
|
|
describe("rig host doctor — stepwise distinct errors + three-valued posture", () => {
|
|
// Same OPENRIG_HOME steering as the add/list suite: the doctor-CLI case
|
|
// seeds a registry via `host add` and must NEVER touch the operator's
|
|
// real ~/.openrig (the shared-singleton doctrine).
|
|
let dir: string;
|
|
let savedHome: string | undefined;
|
|
beforeEach(() => {
|
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), "hostdoc-"));
|
|
savedHome = process.env.OPENRIG_HOME;
|
|
process.env.OPENRIG_HOME = dir;
|
|
});
|
|
afterEach(() => {
|
|
if (savedHome === undefined) delete process.env.OPENRIG_HOME;
|
|
else process.env.OPENRIG_HOME = savedHome;
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
const sshHost: HostEntry = { id: "vps-1", transport: "ssh", target: "vps-1.tailnet", user: "openrig" };
|
|
|
|
const okRun = (stdout: string): CrossHostResult => ({ ok: true, failedStep: "none", stdout, stderr: "", remoteExitCode: 0 });
|
|
const failRun = (): CrossHostResult => ({ ok: false, failedStep: "remote-command-failed", stdout: "", stderr: "not found", remoteExitCode: 127 });
|
|
|
|
function depsFromScript(script: (argv: readonly string[]) => CrossHostResult, probe: "open" | "closed" = "closed"): DoctorDeps {
|
|
return {
|
|
run: async (_h, argv) => script(argv),
|
|
httpGet: async () => ({ status: 200, body: "ok" }),
|
|
tcpProbe: async () => probe,
|
|
};
|
|
}
|
|
|
|
it("distinct error: SSH unreachable stops at the first leg", async () => {
|
|
const deps = depsFromScript(() => ({ ok: false, failedStep: "ssh-unreachable", sshStderr: "connect refused" }));
|
|
const rows = await doctorLegs(sshHost, deps);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]).toMatchObject({ step: "transport-reachability", status: "fail" });
|
|
expect(rows[0]!.fix).toContain("ssh");
|
|
});
|
|
|
|
it("distinct error: SSH works but rig binary missing", async () => {
|
|
const deps = depsFromScript((argv) => (argv[0] === "true" ? okRun("") : failRun()));
|
|
const rows = await doctorLegs(sshHost, deps);
|
|
expect(rows[0]!.status).toBe("pass");
|
|
expect(rows[1]).toMatchObject({ step: "remote-rig-binary", status: "fail" });
|
|
expect(rows[1]!.fix).toContain("npm install -g @openrig/cli");
|
|
});
|
|
|
|
it("distinct error: rig installed but daemon down", async () => {
|
|
const deps = depsFromScript((argv) => {
|
|
if (argv[0] === "true") return okRun("");
|
|
if (argv[1] === "--version") return okRun("0.4.4");
|
|
if (argv[1] === "daemon") return okRun("Daemon stopped");
|
|
return okRun("{}");
|
|
});
|
|
const rows = await doctorLegs(sshHost, deps);
|
|
expect(rows[2]).toMatchObject({ step: "remote-daemon-health", status: "fail" });
|
|
expect(rows[2]!.fix).toContain("rig daemon start");
|
|
expect(rows).toHaveLength(3); // identity leg not attempted without a daemon
|
|
});
|
|
|
|
it("all legs green when the remote answers", async () => {
|
|
const calls: string[][] = [];
|
|
const deps = depsFromScript((argv) => {
|
|
calls.push([...argv]);
|
|
if (argv[0] === "true") return okRun("");
|
|
if (argv[1] === "--version") return okRun("0.4.4");
|
|
if (argv[1] === "daemon") return okRun("Daemon running (pid 1)");
|
|
if (argv[1] === "ps") return okRun(JSON.stringify({ entries: [{ rigName: "kernel" }] }));
|
|
return failRun();
|
|
});
|
|
const rows = await doctorLegs(sshHost, deps);
|
|
expect(rows.map((r) => r.status)).toEqual(["pass", "pass", "pass", "pass"]);
|
|
expect(calls).toContainEqual(["rig", "ps", "--json", "--limit", "5"]);
|
|
expect(calls).not.toContainEqual(["rig", "whoami", "--json"]);
|
|
});
|
|
|
|
it("posture: UNKNOWN is never pass — unreadable sshd/ufw report unknown WITH fixes", async () => {
|
|
const deps = depsFromScript((argv) => {
|
|
const cmd = String(argv[2] ?? "");
|
|
if (cmd.startsWith("id -u")) return okRun("1001");
|
|
return okRun(""); // sshd -T, ufw, tailscale, ss all unreadable
|
|
});
|
|
const rows = await postureCheck(sshHost, deps);
|
|
const byStep = Object.fromEntries(rows.map((r) => [r.step, r]));
|
|
expect(byStep["nonroot-openrig-user"]!.status).toBe("pass");
|
|
for (const step of ["root-ssh-disabled", "key-only-ssh", "ufw-default-deny-incoming", "tailnet-ingress-allowed", "tailscale-minimal-trust", "daemon-bind-not-public"]) {
|
|
expect(byStep[step]!.status, step).toBe("unknown");
|
|
expect(byStep[step]!.fix, step).toBeTruthy();
|
|
}
|
|
// no public addr given -> probes are unknown, never silently pass
|
|
expect(byStep["public-daemon-port-unreachable"]!.status).toBe("unknown");
|
|
});
|
|
|
|
it("posture: publicly reachable daemon port FAILS LOUDLY (the smoke-test hardening bar)", async () => {
|
|
const deps = depsFromScript(() => okRun(""), "open");
|
|
const rows = await postureCheck(sshHost, deps, { publicAddr: "203.0.113.7" });
|
|
const probe = rows.find((r) => r.step === "public-daemon-port-unreachable")!;
|
|
expect(probe.status).toBe("fail");
|
|
expect(probe.detail).toContain("203.0.113.7:7433");
|
|
});
|
|
|
|
// R2-B1 regressions — the two false-green classes, pinned.
|
|
it("posture: a DENY rule on tailscale0 must NOT pass the ingress item", async () => {
|
|
const deps = depsFromScript((argv) => {
|
|
const cmd = String(argv[2] ?? "");
|
|
if (cmd.includes("ufw status")) return okRun("Status: active\nDefault: deny (incoming), allow (outgoing)\nAnywhere on tailscale0 DENY IN Anywhere\n");
|
|
return okRun("");
|
|
});
|
|
const rows = await postureCheck(sshHost, deps);
|
|
const ingress = rows.find((r) => r.step === "tailnet-ingress-allowed")!;
|
|
expect(ingress.status).toBe("fail");
|
|
expect(ingress.detail).toContain("no ALLOW IN");
|
|
});
|
|
|
|
it("posture: daemon bound to a SPECIFIC public IP fails exactly like a wildcard", async () => {
|
|
const deps = depsFromScript((argv) => {
|
|
const cmd = String(argv[2] ?? "");
|
|
if (cmd.includes("ss -tln")) return okRun("LISTEN 0 511 148.113.200.37:7433 0.0.0.0:*");
|
|
return okRun("");
|
|
});
|
|
const rows = await postureCheck(sshHost, deps);
|
|
const bind = rows.find((r) => r.step === "daemon-bind-not-public")!;
|
|
expect(bind.status).toBe("fail");
|
|
expect(bind.detail).toContain("148.113.200.37");
|
|
});
|
|
|
|
it("posture: tailnet CGNAT bind (100.64/10) passes; out-of-range 100.x fails", async () => {
|
|
const mk = (addr: string) => depsFromScript((argv) => {
|
|
const cmd = String(argv[2] ?? "");
|
|
if (cmd.includes("ss -tln")) return okRun(`LISTEN 0 511 ${addr}:7433 0.0.0.0:*`);
|
|
return okRun("");
|
|
});
|
|
const inRange = await postureCheck(sshHost, mk("100.103.199.127"));
|
|
expect(inRange.find((r) => r.step === "daemon-bind-not-public")!.status).toBe("pass");
|
|
const outOfRange = await postureCheck(sshHost, mk("100.1.2.3"));
|
|
expect(outOfRange.find((r) => r.step === "daemon-bind-not-public")!.status).toBe("fail");
|
|
});
|
|
|
|
it("posture: multi-listener where ONE is public fails, naming the offender", async () => {
|
|
const deps = depsFromScript((argv) => {
|
|
const cmd = String(argv[2] ?? "");
|
|
if (cmd.includes("ss -tln")) return okRun("LISTEN 0 511 127.0.0.1:7433 0.0.0.0:*\nLISTEN 0 511 203.0.113.9:7433 0.0.0.0:*");
|
|
return okRun("");
|
|
});
|
|
const rows = await postureCheck(sshHost, deps);
|
|
const bind = rows.find((r) => r.step === "daemon-bind-not-public")!;
|
|
expect(bind.status).toBe("fail");
|
|
expect(bind.detail).toContain("203.0.113.9");
|
|
expect(bind.detail).not.toContain("127.0.0.1:");
|
|
});
|
|
|
|
it("posture: Tailscale SSH enabled FAILS the minimal-trust item", async () => {
|
|
const deps = depsFromScript((argv) => {
|
|
const cmd = String(argv[2] ?? "");
|
|
if (cmd.includes("tailscale status")) return okRun(JSON.stringify({ Self: { PrimaryRoutes: [] } }));
|
|
if (cmd.includes("tailscale debug prefs")) return okRun(JSON.stringify({ RunSSH: true }));
|
|
return okRun("");
|
|
});
|
|
const rows = await postureCheck(sshHost, deps);
|
|
const ts = rows.find((r) => r.step === "tailscale-minimal-trust")!;
|
|
expect(ts.status).toBe("fail");
|
|
expect(ts.detail).toContain("Tailscale SSH enabled: true");
|
|
expect(ts.fix).toContain("tailscale set --ssh=false");
|
|
});
|
|
|
|
it("posture: green path parses real-shaped outputs", async () => {
|
|
const deps = depsFromScript((argv) => {
|
|
const cmd = String(argv[2] ?? "");
|
|
if (cmd.startsWith("id -u")) return okRun("1001");
|
|
if (cmd.includes("sshd -T")) return okRun("permitrootlogin no\npasswordauthentication no\n");
|
|
if (cmd.includes("ufw status")) return okRun("Status: active\nDefault: deny (incoming), allow (outgoing)\nAnywhere on tailscale0 ALLOW IN Anywhere\n");
|
|
if (cmd.includes("tailscale status")) return okRun(JSON.stringify({ Self: { PrimaryRoutes: [] } }));
|
|
if (cmd.includes("tailscale debug prefs")) return okRun(JSON.stringify({ RunSSH: false }));
|
|
if (cmd.includes("ss -tln")) return okRun("LISTEN 0 511 127.0.0.1:7433 0.0.0.0:*");
|
|
return okRun("");
|
|
});
|
|
const rows = await postureCheck(sshHost, deps, { publicAddr: "203.0.113.7" });
|
|
const nonPass = rows.filter((r) => r.status !== "pass");
|
|
expect(nonPass).toEqual([]);
|
|
});
|
|
|
|
it("posture over http transport: EVERY item unknown with the ssh fix (honest, no shell)", async () => {
|
|
const httpHost: HostEntry = { id: "h1", transport: "http", url: "http://x", bearer_env: "T" };
|
|
const rows = await postureCheck(httpHost, { run: async () => okRun(""), httpGet: async () => ({ status: 200, body: "" }), tcpProbe: async () => "closed" });
|
|
expect(rows.length).toBeGreaterThan(0);
|
|
expect(rows.every((r: CheckRow) => r.status === "unknown")).toBe(true);
|
|
});
|
|
|
|
it("doctor CLI: unknown host id is the DISTINCT registry error", async () => {
|
|
const prog = new Command();
|
|
prog.exitOverride();
|
|
prog.addCommand(hostCommand({ run: async () => okRun(""), httpGet: async () => ({ status: 200, body: "" }), tcpProbe: async () => "closed" }));
|
|
const err: string[] = [];
|
|
const oe = console.error; const oc = process.exitCode; process.exitCode = undefined;
|
|
console.error = (...a: unknown[]) => err.push(a.join(" "));
|
|
try {
|
|
await capturePrelude();
|
|
await prog.parseAsync(["node", "rig", "host", "doctor", "nope"]);
|
|
} finally { console.error = oe; }
|
|
const code = process.exitCode; process.exitCode = oc;
|
|
expect(code).toBe(1);
|
|
expect(err.join("\n")).toContain("registry:");
|
|
expect(err.join("\n")).toContain("unknown host id 'nope'");
|
|
});
|
|
|
|
// seed a registry for the doctor CLI test above
|
|
async function capturePrelude(): Promise<void> {
|
|
const prog = new Command();
|
|
prog.exitOverride();
|
|
prog.addCommand(hostCommand());
|
|
const ol = console.log; console.log = () => {};
|
|
try { await prog.parseAsync(["node", "rig", "host", "add", "--id", "seeded", "--transport", "ssh", "--target", "s.host"]); } finally { console.log = ol; }
|
|
}
|
|
});
|