Files
openrig/packages/cli/test/host.test.ts
T
mvschwarz ac75777a3c Release 0.4.4
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.
2026-07-06 01:20:09 -07:00

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; }
}
});