Files
Wu Shuwen 36b0ae6464 fix(cli): refuse daemon start when a probe is blocked (#2502)
When OS policy prevents a reliable process or port check, daemon startup now reports the uncertainty instead of treating a blocked probe as proof that an address is free. Missing state and preflight cases warn. The auto-start stop is narrowed to the shared PID path for a reused or uninspectable PID. Stop refuses unknown process liveness before probing or signalling and preserves the state file. A denied signal probe yields unknown even when the process state is readable, and a confirmed zombie remains dead. A blocked explicit non-loopback OPENRIG_URL stays on the blocked-status path instead of starting a local daemon (rig up and rig start); loopback targets, including localhost, [::1] and 127.x addresses, keep the existing startup behaviour. Refs #275 and #1650. Contributed by dajiaohuang.
2026-11-06 22:18:20 -08:00

1917 lines
86 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import http from "node:http";
import fs from "node:fs";
import os from "node:os";
import nodePath from "node:path";
import { EventEmitter } from "node:events";
import { Command } from "commander";
import { formatRemoteUpFailure, upCommand } from "../src/commands/up.js";
import { DaemonClient } from "../src/client.js";
import { LOG_FILE, STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
import type { StatusDeps } from "../src/commands/status.js";
function mockLifecycleDeps(overrides?: Partial<LifecycleDeps>): LifecycleDeps {
return {
acquireStartLock: () => ({ recordChild: vi.fn(), release: vi.fn() }),
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
fetch: vi.fn(async () => ({ ok: true })),
kill: vi.fn(() => true),
readFile: vi.fn(() => null),
writeFile: vi.fn(),
removeFile: vi.fn(),
exists: vi.fn(() => false),
mkdirp: vi.fn(),
openForAppend: vi.fn(() => 3),
isProcessAlive: vi.fn(() => true),
...overrides,
};
}
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; exitCode: number | undefined }> {
return new Promise(async (resolve) => {
const logs: string[] = [];
const origLog = console.log;
const origErr = console.error;
const origExitCode = process.exitCode;
process.exitCode = undefined;
console.log = (...args: unknown[]) => logs.push(args.join(" "));
console.error = (...args: unknown[]) => logs.push(args.join(" "));
try { await fn(); } finally { console.log = origLog; console.error = origErr; }
const exitCode = process.exitCode;
process.exitCode = origExitCode;
resolve({ logs, exitCode });
});
}
function runningDeps(port: number): StatusDeps {
return {
lifecycleDeps: mockLifecycleDeps({
exists: vi.fn((p: string) => p === STATE_FILE),
readFile: vi.fn((p: string) => {
if (p === STATE_FILE) return JSON.stringify({ pid: 123, port, db: "test.sqlite", startedAt: "2026-03-26T00:00:00Z" } as DaemonState);
return null;
}),
fetch: vi.fn(async () => ({ ok: true })),
}),
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
};
}
function healthyPreflightExec(cmd: string): Promise<string> {
if (cmd === "tmux -V") return Promise.resolve("tmux 3.6a");
if (cmd === "tmux list-sessions") return Promise.resolve("");
return Promise.resolve("");
}
describe("Up CLI", () => {
let server: http.Server;
let port: number;
beforeAll(async () => {
server = http.createServer(async (req, res) => {
let body = "";
for await (const chunk of req) body += chunk;
if (req.url === "/api/up" && req.method === "POST") {
const parsed = JSON.parse(body);
if (String(parsed.sourceRef).includes("reinstall-cause")) {
const code = String(parsed.sourceRef).match(/reinstall-cause-([\w]+)\.rigbundle$/)?.[1];
res.writeHead(code ? 400 : 500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "failed", code, errors: ["Install target has different content at README.md"], warnings: ["Original files are kept at /fixture/backup"], stages: [] }));
return;
}
if (parsed.plan) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "planned", runId: "run-1", stages: [{ stage: "resolve_spec", status: "ok" }], errors: [], warnings: [] }));
} else {
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "completed", runId: "run-2", rigId: "rig-1", stages: [{ stage: "resolve_spec", status: "ok" }, { stage: "import_rig", status: "ok" }], errors: [], warnings: [], attachCommand: "tmux attach -t dev-impl@test-rig" }));
}
} else {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "not found" }));
}
});
await new Promise<void>((resolve) => { server.listen(0, resolve); });
port = (server.address() as { port: number }).port;
});
afterAll(() => { server.close(); });
function makeCmd(): Command {
const prog = new Command();
prog.exitOverride();
prog.addCommand(upCommand(runningDeps(port)));
return prog;
}
it("local bundle reinstall renders errors[] and file disposition instead of unknown error", async () => {
const { logs, exitCode } = await captureLogs(() => makeCmd().parseAsync(["node", "rig", "up", "reinstall-cause.rigbundle"]).then(() => {}));
expect(exitCode).toBe(2);
expect(logs.join("\n")).toContain("different content at README.md");
expect(logs.join("\n")).toContain("Original files are kept at /fixture/backup");
expect(logs.join("\n")).not.toContain("unknown error");
});
it.each(["validation_failed", "preflight_failed", "cycle_error", "generation_unconfirmed", "target_conflict"])("round 2: %s prints file disposition once", async code => {
const { logs, exitCode } = await captureLogs(() => makeCmd().parseAsync(["node", "rig", "up", `reinstall-cause-${code}.rigbundle`]).then(() => {}));
expect(exitCode).toBe(2);
expect(logs.join("\n").match(/Original files are kept at \/fixture\/backup/g)).toHaveLength(1);
});
it("renders structured remote up failures and attention hints as text", () => {
const lines = formatRemoteUpFailure("build-host", {
error: "HTTP 409",
data: {
error: {
fact: "Seat trust approval is required.",
consequence: "The rig was not fully started.",
action: "Attach to the affected seat and approve the prompt.",
},
attentionNodes: [{ logicalId: "worker", sessionName: "worker@demo", reason: "hook trust" }],
},
});
expect(lines.join("\n")).toContain("Error on host build-host: HTTP 409");
expect(lines.join("\n")).toContain("Seat trust approval is required.");
expect(lines.join("\n")).toContain("worker (worker@demo): hook trust");
expect(lines.join("\n")).not.toContain("[object Object]");
});
it("renders message-only and partial remote error bodies without unsafe stringification", () => {
expect(formatRemoteUpFailure("build-host", {
error: "HTTP 500", data: { error: "daemon startup failed", code: "daemon_start_failed" },
}).join("\n")).toContain("daemon startup failed");
expect(formatRemoteUpFailure("build-host", {
error: "HTTP 502", data: { error: { fact: "Remote proxy rejected the request." } },
}).join("\n")).toContain("Error: Remote proxy rejected the request.");
const malformed = formatRemoteUpFailure("build-host", { error: "HTTP 502", data: { error: { unknown: true } } });
expect(malformed.join("\n")).toContain("did not return a recognized error message");
expect(malformed.join("\n")).not.toContain("[object Object]");
});
it("help positions managed apps as first-class launch targets", () => {
const logs: string[] = [];
const cmd = makeCmd().commands.find((c) => c.name() === "up")!;
cmd.configureOutput({ writeOut: (str) => logs.push(str), writeErr: (str) => logs.push(str) });
cmd.outputHelp();
const help = logs.join("");
expect(help).toContain("Launch a rig or managed app from a spec, library entry, or bundle");
const flat = help.replace(/\s+/g, " ");
expect(flat).toContain("Install target for a .rigbundle (default: current directory)");
expect(flat).toContain("relative member cwds resolve against it; --cwd still overrides launch cwd");
expect(flat).not.toContain("does not change agent cwd");
expect(help).toContain("--cwd <path>");
expect(help).toContain("rig up secrets-manager");
});
// T7: up from .yaml -> stages + rig ID
it("up prints stages and rig ID", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/rig.yaml"]);
});
expect(logs.some((l) => l.includes("resolve_spec"))).toBe(true);
expect(logs.some((l) => l.includes("rig-1"))).toBe(true);
expect(logs.some((l) => l.includes("completed"))).toBe(true);
});
// T8: up --plan
it("up --plan prints planned status", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/rig.yaml", "--plan"]);
});
expect(logs.some((l) => l.includes("planned"))).toBe(true);
});
// T9: --yes sends autoApprove
it("up --yes sends autoApprove=true", async () => {
let lastBody: Record<string, unknown> = {};
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
if (req.url === "/api/up") {
lastBody = JSON.parse(body);
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
return;
}
// OPR.0.3.2.22 Bug 3 — path-form sourceRef triggers a /api/info
// call for install-root awareness; respond with a 404 so the CLI
// falls through (this test does not exercise the install-internal
// path-form gate).
res.writeHead(404).end();
});
await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/rig.yaml", "--yes"]);
});
expect(lastBody.autoApprove).toBe(true);
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
});
// T10: --json
it("up --json outputs parseable JSON", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/rig.yaml", "--json"]);
});
const parsed = JSON.parse(logs.join(""));
expect(parsed.status).toBe("completed");
});
// T12: Failure -> exit 2
it("failure response returns exit 2", async () => {
const failServer = http.createServer((_, res) => {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "failed", error: "boom", stages: [], errors: ["boom"] }));
});
await new Promise<void>((resolve) => { failServer.listen(0, resolve); });
const failPort = (failServer.address() as { port: number }).port;
const prog = new Command();
prog.exitOverride();
prog.addCommand(upCommand(runningDeps(failPort)));
const { exitCode } = await captureLogs(async () => {
await prog.parseAsync(["node", "rig", "up", "/tmp/rig.yaml"]);
});
expect(exitCode).toBe(2);
failServer.close();
});
it("agent_ref resolution failures include local-ref guidance", async () => {
const failServer = http.createServer((_, res) => {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({
status: "failed",
error: "dev.impl: agent_ref resolution failed: No agent.yaml found at /tmp/agents/impl/agent.yaml",
stages: [],
errors: [],
}));
});
await new Promise<void>((resolve) => { failServer.listen(0, resolve); });
const failPort = (failServer.address() as { port: number }).port;
const prog = new Command();
prog.exitOverride();
prog.addCommand(upCommand(runningDeps(failPort)));
const { logs, exitCode } = await captureLogs(async () => {
await prog.parseAsync(["node", "rig", "up", "/tmp/rig.yaml"]);
});
const output = logs.join("\n");
expect(output).toContain("agent_ref resolution failed");
expect(output).toContain("local: agent_ref paths resolve relative to the rig spec directory");
expect(exitCode).toBe(2);
failServer.close();
});
it("prints the Compose project conflict and retry instruction without generic boot failure", async () => {
const message = "Replacement has multiple predecessor Compose projects (rig-1: old; rig-2: other). Set services.project_name in the rig spec YAML to the project you intend to use, then re-run the same command. No services were started.";
const prog = new Command();
prog.exitOverride();
prog.addCommand(upCommand({ ...runningDeps(port), clientFactory: () => ({
post: async () => ({ status: 400, data: { status: "failed", code: "compose_project_conflict", stages: [], errors: [message] } }),
} as unknown as DaemonClient) }));
const { logs, exitCode } = await captureLogs(async () => {
await prog.parseAsync(["node", "rig", "up", "/tmp/rig.yaml"]);
});
expect(logs.join("\n")).toContain(message);
expect(logs.join("\n")).not.toContain("unknown error");
expect(exitCode).toBe(2);
});
// T13: Relative path resolved to absolute before sending
it("resolves relative path to absolute in POST body", async () => {
let lastBody: Record<string, unknown> = {};
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
if (req.url === "/api/up") {
lastBody = JSON.parse(body);
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
return;
}
// OPR.0.3.2.22 Bug 3 /api/info fallback (404 → CLI falls through).
res.writeHead(404).end();
});
await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "relative/spec.yaml"]);
});
// sourceRef must be an absolute path, not the raw relative input
expect(lastBody.sourceRef).toMatch(/^\//);
expect((lastBody.sourceRef as string).endsWith("relative/spec.yaml")).toBe(true);
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
});
it("up from .rigbundle defaults targetRoot to current working directory", async () => {
let lastBody: Record<string, unknown> = {};
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
if (req.url === "/api/up") {
lastBody = JSON.parse(body);
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
}
});
await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/demo.rigbundle"]);
});
expect(lastBody.sourceRef).toBe("/tmp/demo.rigbundle");
expect(lastBody.targetRoot).toBe(process.cwd());
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
});
it("up from .rigbundle preserves explicit --target", async () => {
let lastBody: Record<string, unknown> = {};
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
if (req.url === "/api/up") {
lastBody = JSON.parse(body);
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
}
});
await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/demo.rigbundle", "--target", "/tmp/custom-root"]);
});
expect(lastBody.targetRoot).toBe("/tmp/custom-root");
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
});
it("up from .rigbundle resolves a relative --target against the client cwd on the local route", async () => {
let lastBody: Record<string, unknown> = {};
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
if (req.url === "/api/up") {
lastBody = JSON.parse(body);
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
}
});
await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/demo.rigbundle", "--target", "rel/target-root"]);
});
expect(lastBody.targetRoot).toBe(`${process.cwd()}/rel/target-root`);
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
});
it("up from .rigbundle with --cwd and no --target keeps the default target and sends --cwd only as cwdOverride", async () => {
let lastBody: Record<string, unknown> = {};
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
if (req.url === "/api/up") {
lastBody = JSON.parse(body);
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
}
});
await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/demo.rigbundle", "--cwd", "rel/launch-dir"]);
});
expect(lastBody.targetRoot).toBe(process.cwd());
expect(lastBody.cwdOverride).toBe(`${process.cwd()}/rel/launch-dir`);
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
});
it("up from a library name defaults cwdOverride to the caller working directory", async () => {
let lastBody: Record<string, unknown> = {};
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
if ((req.url ?? "").startsWith("/api/specs/library") && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([
{
id: "builtin:rig:demo",
kind: "rig",
name: "demo",
version: "0.0.0",
sourceType: "builtin",
sourcePath: "/install/@openrig/cli/daemon/specs/rigs/launch/demo/rig.yaml",
relativePath: "rigs/launch/demo/rig.yaml",
updatedAt: "2026-04-10T00:00:00Z",
},
]));
return;
}
if (req.url === "/api/rigs/summary" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([]));
return;
}
if (req.url === "/api/up" && req.method === "POST") {
lastBody = JSON.parse(body);
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "not found" }));
});
try {
await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "demo"]);
});
expect(lastBody.sourceRef).toBe("/install/@openrig/cli/daemon/specs/rigs/launch/demo/rig.yaml");
expect(lastBody.cwdOverride).toBe(process.cwd());
} finally {
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
}
});
// #226: a launch from '/' continues, and the person is told why their agents would work in '/'.
// Only the cwd the CLI defaults warns; an explicit --cwd / is the person's choice.
it.each([
{ args: ["demo"], warns: true },
{ args: ["demo", "--cwd", "/"], warns: false },
])("up $args from / sends cwdOverride / and warns only when / was defaulted", async ({ args, warns }) => {
let lastBody: Record<string, unknown> = {};
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
const reply = (status: number, data: unknown) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(data)); };
if ((req.url ?? "").startsWith("/api/specs/library")) return reply(200, [{
id: "builtin:rig:demo", kind: "rig", name: "demo", version: "0.0.0", sourceType: "builtin",
sourcePath: "/install/@openrig/cli/daemon/specs/rigs/launch/demo/rig.yaml", relativePath: "rigs/launch/demo/rig.yaml", updatedAt: "2026-04-10T00:00:00Z",
}]);
if (req.url === "/api/rigs/summary") return reply(200, []);
if (req.url === "/api/up") { lastBody = JSON.parse(body); return reply(201, { status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }); }
reply(404, { error: "not found" });
});
const cwd = vi.spyOn(process, "cwd").mockReturnValue("/");
try {
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", ...args]);
});
expect(lastBody.cwdOverride).toBe("/");
expect(exitCode).toBeUndefined();
expect(logs.some((l) => l.includes("warning:") && l.includes("--cwd <project dir>"))).toBe(warns);
} finally {
cwd.mockRestore();
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
}
});
it("up --cwd sends absolute cwdOverride", async () => {
let lastBody: Record<string, unknown> = {};
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
if (req.url === "/api/up") {
lastBody = JSON.parse(body);
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
}
});
await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/rig.yaml", "--cwd", "relative/project"]);
});
expect(lastBody.cwdOverride).toMatch(/^\//);
expect((lastBody.cwdOverride as string).endsWith("relative/project")).toBe(true);
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
});
it("up --env sends each KEY=VALUE as envOverride, splitting at the first equals sign", async () => {
let lastBody: Record<string, unknown> = {};
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
if (req.url === "/api/up") {
lastBody = JSON.parse(body);
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
}
});
await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/rig.yaml", "--env", "DEBUG=1", "--env", "URL=http://x/?a=b"]);
});
expect(lastBody.envOverride).toEqual({ DEBUG: "1", URL: "http://x/?a=b" });
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
});
it("up --env leaves out CLAUDE_CONFIG_DIR and CODEX_HOME with a note, and still sends the rest", async () => {
let lastBody: Record<string, unknown> = {};
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
if (req.url === "/api/up") {
lastBody = JSON.parse(body);
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
}
});
try {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/rig.yaml", "--env", "CODEX_HOME=/homes/b", "--env", "CLAUDE_CONFIG_DIR=/homes/c", "--env", "DEBUG=1"]);
});
expect(lastBody.envOverride).toEqual({ DEBUG: "1" });
expect(logs.join("\n")).toContain("--env CODEX_HOME isn't applied");
expect(logs.join("\n")).toContain("--env CLAUDE_CONFIG_DIR isn't applied");
} finally {
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
}
});
it("up without --env sends no envOverride", async () => {
let lastBody: Record<string, unknown> = {};
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
if (req.url === "/api/up") {
lastBody = JSON.parse(body);
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
}
});
await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/rig.yaml"]);
});
expect(lastBody).not.toHaveProperty("envOverride");
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
});
it("up --env without an equals sign stops before contacting the daemon", async () => {
let called = false;
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", (_req: http.IncomingMessage, res: http.ServerResponse) => { called = true; res.writeHead(500); res.end(); });
try {
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/rig.yaml", "--env", "NOEQUALS"]);
});
expect(called).toBe(false);
expect(exitCode).toBe(1);
expect(logs.join("\n")).toContain("--env expects KEY=VALUE");
} finally {
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
}
});
async function withListener(handler: (url: string) => { status: number; body: unknown }, run: (upCalls: () => number) => Promise<void>) {
let upCalls = 0;
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
for await (const _chunk of req) { /* drain */ }
if (req.url === "/api/up") upCalls++;
const { status, body } = handler(req.url ?? "");
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(body));
});
try {
await run(() => upCalls);
} finally {
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
}
}
it.each([
["a lowercase name", "lower=x", "not a valid name"],
["a reserved name", "OPENRIG_URL=http://elsewhere", "reserved"],
["an empty value", "EMPTY=", "must not be empty"],
])("up --env with %s stops before contacting the daemon", async (_label, assignment, fragment) => {
await withListener(() => ({ status: 500, body: {} }), async (upCalls) => {
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/rig.yaml", "--env", assignment]);
});
expect(upCalls()).toBe(0);
expect(exitCode).toBe(1);
expect(logs.join("\n")).toContain(fragment);
});
});
it.each([
["a rig name", ["up", "my-rig", "--env", "DEBUG=1"]],
["a rig name with --existing", ["up", "my-rig", "--existing", "--env", "DEBUG=1"]],
])("up --env with %s says a restore takes no --env and does not call /api/up", async (_label, args) => {
await withListener((url) => ({ status: 200, body: url.startsWith("/api/rigs/summary") ? [{ id: "r1", name: "my-rig", lifecycleState: "stopped" }] : [] }), async (upCalls) => {
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", ...args]);
});
expect(upCalls()).toBe(0);
expect(exitCode).toBe(1);
expect(logs.join("\n")).toContain("A restore keeps the rig's own env: block");
});
});
it("up --env with a topology source says a topology takes no --env and does not call /api/up", async () => {
const dir = fs.mkdtempSync(nodePath.join(os.tmpdir(), "up-topology-env-"));
try {
const manifest = nodePath.join(dir, "factory.rigtopology");
fs.writeFileSync(manifest, "rigs:\n - source: ./a.yaml\n");
await withListener(() => ({ status: 200, body: {} }), async (upCalls) => {
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", manifest, "--env", "DEBUG=1"]);
});
expect(upCalls()).toBe(0);
expect(exitCode).toBe(1);
expect(logs.join("\n")).toContain("A topology launches several rigs");
});
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("up without --name sends no rigName", async () => {
let lastBody: Record<string, unknown> = {};
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
if (req.url === "/api/up") lastBody = JSON.parse(body);
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
});
try {
await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/rig.yaml"]);
});
} finally {
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
}
expect(lastBody).toHaveProperty("sourceRef");
expect(lastBody).not.toHaveProperty("rigName");
});
it.each([
["a rig name", ["up", "my-rig", "--name", "alpha"], "renaming an existing rig"],
["--existing", ["up", "my-rig", "--existing", "--name", "alpha"], "renaming an existing rig"],
["an empty name", ["up", "/tmp/rig.yaml", "--name", ""], "--name needs a rig name"],
])("up --name with %s is refused and does not call /api/up", async (_label, args, fragment) => {
await withListener((url) => ({ status: 200, body: url.startsWith("/api/rigs/summary") ? [{ id: "r1", name: "my-rig", lifecycleState: "stopped" }] : [] }), async (upCalls) => {
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", ...args]);
});
expect(upCalls()).toBe(0);
expect(exitCode).toBe(1);
expect(logs.join("\n")).toContain(fragment);
});
});
it.each([
["a topology", "factory.rigtopology", "A topology launches several rigs"],
["a GitHub bundle link", "https://github.com/acme/rigs/tree/main/team", "GitHub bundle link"],
])("up <%s> --name is refused and does not call /api/up", async (_label, source, fragment) => {
const dir = fs.mkdtempSync(nodePath.join(os.tmpdir(), "up-name-"));
try {
const target = source.endsWith(".rigtopology") ? nodePath.join(dir, source) : source;
if (target !== source) fs.writeFileSync(target, "rigs:\n - source: ./a.yaml\n");
await withListener(() => ({ status: 200, body: {} }), async (upCalls) => {
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", target, "--name", "alpha"]);
});
expect(upCalls()).toBe(0);
expect(exitCode).toBe(1);
expect(logs.join("\n")).toContain(fragment);
});
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
// OPR.0.3.2.22 Bug 3 — path-form `rig up <spec-inside-install>` without
// --cwd should default cwdOverride to process.cwd() so the spec's
// member-level cwd: "." does not resolve into the OpenRig install root
// and trip getOpenRigInstallCwdError at preflight. The bare-name form
// is already rescued at the resolveLibrarySpec branch (covered by the
// earlier library-name test); this pins the path-form gap.
it.each(["rigs", "..cache"])("up path-form spec inside install root (%s) defaults cwdOverride to caller cwd and prints notice", async (directory) => {
const fs = await import("node:fs");
const path = await import("node:path");
const os = await import("node:os");
const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), "up-bug3-install-"));
const specPath = path.join(installRoot, directory, "demo", "rig.yaml");
fs.mkdirSync(path.dirname(specPath), { recursive: true });
fs.writeFileSync(specPath, "version: '0.2'\nname: demo\npods: []\nedges: []\n");
let lastBody: Record<string, unknown> = {};
let infoCalls = 0;
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
if (req.url === "/api/info" && req.method === "GET") {
infoCalls += 1;
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ installRoot }));
return;
}
if (req.url === "/api/up" && req.method === "POST") {
lastBody = JSON.parse(body);
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "not found" }));
});
try {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", specPath]);
});
expect(infoCalls, "expected /api/info to be queried for install-root awareness").toBe(1);
expect(lastBody.cwdOverride).toBe(process.cwd());
expect(logs.some((l) => l.includes("Defaulting cwd to current directory because the spec lives inside the OpenRig install"))).toBe(true);
} finally {
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
fs.rmSync(installRoot, { recursive: true, force: true });
}
});
it("up path-form spec OUTSIDE install root leaves cwdOverride undefined", async () => {
const fs = await import("node:fs");
const path = await import("node:path");
const os = await import("node:os");
const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), "up-bug3-install-"));
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "up-bug3-project-"));
const specPath = path.join(projectRoot, "rig.yaml");
fs.writeFileSync(specPath, "version: '0.2'\nname: outside-rig\npods: []\nedges: []\n");
let lastBody: Record<string, unknown> = {};
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
if (req.url === "/api/info" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ installRoot }));
return;
}
if (req.url === "/api/up" && req.method === "POST") {
lastBody = JSON.parse(body);
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "not found" }));
});
try {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", specPath]);
});
expect(lastBody.cwdOverride).toBeUndefined();
expect(logs.some((l) => l.includes("Defaulting cwd to current directory because the spec lives inside the OpenRig install"))).toBe(false);
} finally {
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
fs.rmSync(installRoot, { recursive: true, force: true });
fs.rmSync(projectRoot, { recursive: true, force: true });
}
});
it("up apply uses a long-running daemon timeout budget", async () => {
let timeoutMs: number | undefined;
const post = vi.fn(async (_path: string, _body: unknown, options?: { timeoutMs?: number }) => {
timeoutMs = options?.timeoutMs;
return {
status: 201,
data: { status: "completed", runId: "r", rigId: "g", stages: [], errors: [], warnings: [] },
};
});
const deps: StatusDeps = {
lifecycleDeps: runningDeps(port).lifecycleDeps,
clientFactory: () => ({ post } as unknown as DaemonClient),
};
const prog = new Command();
prog.exitOverride();
prog.addCommand(upCommand({ ...deps, preflightExec: healthyPreflightExec }));
await captureLogs(async () => {
await prog.parseAsync(["node", "rig", "up", "/tmp/rig.yaml"]);
});
expect(timeoutMs).toBe(120_000);
});
// NS-T14: fresh boot handoff includes dashboard URL + attach command
it("fresh boot success shows dashboard URL and attach command", async () => {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/test.yaml"]);
});
const output = logs.join("\n");
expect(output).toContain("Dashboard: rig ui open");
expect(output).toContain("Attach:");
expect(output).toContain("tmux attach -t dev-impl@test-rig");
});
it("restored rig output prints degraded rig result and exits nonzero", async () => {
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
if (req.url === "/api/rigs/summary" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ id: "rig-1", name: "restore-me", nodeCount: 1 }]));
return;
}
if (req.url === "/api/up" && req.method === "POST") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
status: "restored",
rigId: "rig-1",
rigName: "restore-me",
rigResult: "partially_restored",
nodes: [{ logicalId: "worker", status: "fresh" }],
warnings: [],
}));
return;
}
res.writeHead(404).end();
});
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "restore-me"]);
});
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
const output = logs.join("\n");
expect(output).toContain("Result: partially_restored");
expect(output).toContain("worker: fresh");
expect(exitCode).toBe(1);
});
it("restored rig validation block prints not_attempted blockers and exits nonzero", async () => {
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
if (req.url === "/api/rigs/summary" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ id: "rig-1", name: "restore-me", nodeCount: 1 }]));
return;
}
if (req.url === "/api/up" && req.method === "POST") {
res.writeHead(409, { "Content-Type": "application/json" });
res.end(JSON.stringify({
status: "not_attempted",
rigId: "rig-1",
rigName: "restore-me",
error: "Restore pre-validation failed; no restore mutation was attempted.",
code: "pre_restore_validation_failed",
snapshotId: "snap-1",
preRestoreSnapshotId: null,
rigResult: "not_attempted",
nodes: [],
warnings: [],
blockers: [{
code: "required_startup_file_missing",
severity: "critical",
logicalId: "worker",
path: "/workspace/app/STARTUP.md",
message: "Required startup file is missing for worker: /workspace/app/STARTUP.md",
remediation: "Restore the missing startup file or capture a new snapshot before retrying restore.",
}],
}));
return;
}
res.writeHead(404).end();
});
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "restore-me"]);
});
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
const output = logs.join("\n");
expect(output).toContain("Result: not_attempted");
expect(output).toContain("Required startup file is missing");
expect(output).toContain("/workspace/app/STARTUP.md");
expect(output).toContain("Restore the missing startup file");
expect(exitCode).toBe(1);
});
// PNS-T06: fresh boot warnings (e.g. transcript attach failures) surface to stderr
it("fresh boot success prints warnings when present", async () => {
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
req.on("data", (chunk: Buffer) => { body += chunk; });
req.on("end", () => {
if (req.url === "/api/up" && req.method === "POST") {
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({
status: "completed", runId: "run-3", rigId: "rig-2",
stages: [{ stage: "import_rig", status: "ok" }],
errors: [],
warnings: ["Transcript capture failed for dev-impl@test-rig: pipe-pane failed", "Startup submission unverified in dev-impl@test-rig: capture unavailable"],
}));
} else {
res.writeHead(404).end();
}
});
});
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "/tmp/test.yaml"]);
});
const output = logs.join("\n");
expect(output).toContain("warning: Transcript capture failed");
expect(output).toContain("Startup submission unverified in dev-impl@test-rig");
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
});
// PNS-T03: rig up aborts on preflight failure (daemon not running, port in use)
it("up aborts with preflight error when daemon not running and port in use", async () => {
// Start a TCP server on a port to trigger port-in-use
const net = await import("node:net");
const blockingServer = net.createServer();
await new Promise<void>((resolve) => blockingServer.listen(0, resolve));
const blockedPort = (blockingServer.address() as { port: number }).port;
// Create deps where daemon is NOT running (triggers auto-start → preflight)
const stoppedDeps: StatusDeps = {
lifecycleDeps: {
...mockLifecycleDeps(),
exists: vi.fn(() => false), // No daemon.json → stopped
readFile: vi.fn(() => null),
fetch: vi.fn(async () => { throw new Error("refused"); }),
},
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
};
// Set OPENRIG_PORT to the blocked port so preflight detects collision
const savedPort = process.env["OPENRIG_PORT"];
process.env["OPENRIG_PORT"] = String(blockedPort);
const prog = new Command();
prog.exitOverride();
prog.addCommand(upCommand(stoppedDeps));
const { logs, exitCode } = await captureLogs(async () => {
try {
await prog.parseAsync(["node", "rig", "up", "/tmp/test.yaml"]);
} catch { /* commander may throw on exitOverride */ }
});
blockingServer.close();
if (savedPort !== undefined) process.env["OPENRIG_PORT"] = savedPort;
else delete process.env["OPENRIG_PORT"];
const output = logs.join("\n");
expect(output).toContain("port");
expect(exitCode).toBe(1);
});
it("up --plan with no daemon running starts nothing and says how to start one", async () => {
const spawn = vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never);
const clientFactory = vi.fn();
const deps: StatusDeps = {
// No state file and nothing answering: the daemon is down, as on a fresh instance.
lifecycleDeps: { ...mockLifecycleDeps(), exists: vi.fn(() => false), readFile: vi.fn(() => null), spawn, fetch: vi.fn(async (url: string) => { throw new Error(`refused:${url}`); }) },
clientFactory: clientFactory as unknown as StatusDeps["clientFactory"],
};
const run = async (args: string[]) => {
const prog = new Command();
prog.exitOverride();
prog.addCommand(upCommand({ ...deps, preflightExec: healthyPreflightExec }));
return captureLogs(async () => { await prog.parseAsync(["node", "rig", "up", "/tmp/test.yaml", "--plan", ...args]); });
};
const text = await run([]);
expect(text.exitCode).toBe(1);
expect(text.logs.join("\n")).toContain("A preview doesn't start one");
expect(text.logs.join("\n")).toContain("rig daemon start --no-kernel");
const json = await run(["--json"]);
expect(JSON.parse(json.logs.at(-1)!)).toMatchObject({ ok: false, code: "daemon_not_running", plan: true, changed: false });
expect(spawn).not.toHaveBeenCalled(); // no daemon, so no kernel and no agent turns
expect(clientFactory).not.toHaveBeenCalled();
});
it("auto-start uses resolved daemon port instead of default 7433", async () => {
const savedPort = process.env["OPENRIG_PORT"];
process.env["OPENRIG_PORT"] = "7461";
let daemonState: DaemonState | null = null;
let daemonStarted = false;
let spawnedPort: string | undefined;
let clientBaseUrl: string | undefined;
const deps: StatusDeps = {
lifecycleDeps: {
...mockLifecycleDeps(),
exists: vi.fn((p: string) => p === STATE_FILE ? daemonState !== null : false),
readFile: vi.fn((p: string) => p === STATE_FILE && daemonState ? JSON.stringify(daemonState) : null),
writeFile: vi.fn((p: string, content: string) => {
if (p === STATE_FILE) daemonState = JSON.parse(content) as DaemonState;
}),
openForAppend: vi.fn(() => 3),
mkdirp: vi.fn(),
spawn: vi.fn((cmd, args, opts) => {
daemonStarted = true;
spawnedPort = opts.env["OPENRIG_PORT"];
return Object.assign(new EventEmitter(), { pid: 321, exitCode: null, signalCode: null, unref: vi.fn() }) as never;
}),
fetch: vi.fn(async (url: string) => {
if (!daemonStarted) throw new Error(`refused:${url}`);
return { ok: url === "http://127.0.0.1:7461/healthz", json: async () => ({ pid: 321, bind: { mode: "explicit", hosts: ["127.0.0.1"], tailscaleDetected: false } }) };
}),
},
clientFactory: (baseUrl) => {
clientBaseUrl = baseUrl;
return {
post: vi.fn(async () => ({
status: 201,
data: { status: "completed", rigId: "rig-1", stages: [], errors: [], warnings: [] },
})),
} as unknown as DaemonClient;
},
};
const prog = new Command();
prog.exitOverride();
prog.addCommand(upCommand({ ...deps, preflightExec: healthyPreflightExec }));
const { exitCode } = await captureLogs(async () => {
await prog.parseAsync(["node", "rig", "up", "/tmp/test.yaml"]);
});
if (savedPort !== undefined) process.env["OPENRIG_PORT"] = savedPort;
else delete process.env["OPENRIG_PORT"];
expect(exitCode).toBeUndefined();
expect(spawnedPort).toBe("7461");
expect(clientBaseUrl).toBe("http://127.0.0.1:7461");
});
// bug-fix slice auth-bearer-tailscale-trust forward-fix #2: `rig up`
// auto-start path was the second product launch path materializing
// the default daemon.host into OPENRIG_HOST. S20 re-grounded the env half:
// source=default → omit (daemon multi-binds); source=env is the overloaded
// ROUTING channel and NEVER creates bind intent (r2 NOT-CLEAR at 95150982d:
// this very suite carried a positive pin REQUIRING the incident behavior).
describe("auth-bearer-tailscale-trust: up auto-start respects daemon.host source", () => {
function makeAutoStartDeps(captureSpawn: (env: Record<string, string>) => void): StatusDeps {
let daemonState: DaemonState | null = null;
let daemonStarted = false;
return {
lifecycleDeps: {
...mockLifecycleDeps(),
exists: vi.fn((p: string) => p === STATE_FILE ? daemonState !== null : false),
readFile: vi.fn((p: string) => p === STATE_FILE && daemonState ? JSON.stringify(daemonState) : null),
writeFile: vi.fn((p: string, content: string) => {
if (p === STATE_FILE) daemonState = JSON.parse(content) as DaemonState;
}),
openForAppend: vi.fn(() => 3),
mkdirp: vi.fn(),
spawn: vi.fn((cmd, args, opts) => {
daemonStarted = true;
captureSpawn(opts.env as Record<string, string>);
return Object.assign(new EventEmitter(), { pid: 999, exitCode: null, signalCode: null, unref: vi.fn() }) as never;
}),
fetch: vi.fn(async (url: string) => {
if (!daemonStarted) throw new Error(`refused:${url}`);
return { ok: url.includes("/healthz"), json: async () => ({ pid: 999, bind: { mode: "explicit", hosts: ["127.0.0.1"], tailscaleDetected: false } }) };
}),
},
clientFactory: () => ({
post: vi.fn(async () => ({
status: 201,
data: { status: "completed", rigId: "rig-1", stages: [], errors: [], warnings: [] },
})),
} as unknown as DaemonClient),
};
}
it("default (no env, no config) does NOT export OPENRIG_HOST so daemon multi-binds", async () => {
const savedHost = process.env["OPENRIG_HOST"];
const savedPort = process.env["OPENRIG_PORT"];
delete process.env["OPENRIG_HOST"];
process.env["OPENRIG_PORT"] = "7471";
let spawnedEnv: Record<string, string> = {};
const deps = makeAutoStartDeps((env) => { spawnedEnv = env; });
const prog = new Command();
prog.exitOverride();
prog.addCommand(upCommand({ ...deps, preflightExec: healthyPreflightExec }));
await captureLogs(async () => {
await prog.parseAsync(["node", "rig", "up", "/tmp/test.yaml"]);
});
if (savedHost === undefined) delete process.env["OPENRIG_HOST"];
else process.env["OPENRIG_HOST"] = savedHost;
if (savedPort === undefined) delete process.env["OPENRIG_PORT"];
else process.env["OPENRIG_PORT"] = savedPort;
expect(spawnedEnv["OPENRIG_HOST"]).toBeUndefined();
expect(spawnedEnv["OPENRIG_PORT"]).toBe("7471");
});
it("S20: env-sourced OPENRIG_HOST creates NO bind intent through up auto-start (routing never crosses)", async () => {
const { default: net } = await import("node:net");
const socket = Object.assign(new EventEmitter(), { setTimeout: vi.fn(), destroy: vi.fn(), connect() { queueMicrotask(() => socket.emit("error", Object.assign(new Error("refused"), { code: "ECONNREFUSED" }))); } });
const socketSpy = vi.spyOn(net, "Socket").mockImplementation(() => socket as unknown as import("node:net").Socket);
const savedHost = process.env["OPENRIG_HOST"];
const savedPort = process.env["OPENRIG_PORT"];
// Use a distinct value so the discriminator can't pass vacuously:
// 100.64.55.66 is a tailscale CGNAT address — operator-explicit
// bind would normally be honored as-is.
process.env["OPENRIG_HOST"] = "100.64.55.66";
process.env["OPENRIG_PORT"] = "7472";
let spawnedEnv: Record<string, string> = {};
const deps = makeAutoStartDeps((env) => { spawnedEnv = env; });
const prog = new Command();
prog.exitOverride();
prog.addCommand(upCommand({ ...deps, preflightExec: healthyPreflightExec }));
try { await captureLogs(async () => {
await prog.parseAsync(["node", "rig", "up", "/tmp/test.yaml"]);
}); } finally { socketSpy.mockRestore(); }
if (savedHost === undefined) delete process.env["OPENRIG_HOST"];
else process.env["OPENRIG_HOST"] = savedHost;
if (savedPort === undefined) delete process.env["OPENRIG_PORT"];
else process.env["OPENRIG_PORT"] = savedPort;
// S20 (r2 blocker repair): env-sourced OPENRIG_HOST is ROUTING state — the
// auto-start must create NO bind intent from it and the routing var must not
// cross into the daemon env (the incident: injected 127.0.0.1 took single-bind
// and dropped the Tailscale listener).
expect(spawnedEnv["OPENRIG_BIND_HOST"]).toBeUndefined();
expect(spawnedEnv["OPENRIG_HOST"]).toBeUndefined();
expect(spawnedEnv["OPENRIG_PORT"]).toBe("7472");
});
});
// QUARANTINED (D12 base-health) — NOT a test bug and NOT test-shimmed: this
// asserts fast-fail on an UNSTARTABLE daemon (fatal better-sqlite3 spawn log),
// but startDaemon's healthz poll is a hard-coded 80 × 250ms setTimeout (~20s,
// non-injectable, with no early-bail when the spawn log already shows a fatal
// error), so `up` hangs past the 15s budget. Routed as a product-defect finding
// (D12-F1: slow-fail auto-start + non-injectable poll delay). Skipped — with the
// named cause — until that fix lands, so the family stops polluting fold gates.
it.skip("up surfaces the real daemon auto-start failure instead of a generic hint", async () => {
const savedPort = process.env["OPENRIG_PORT"];
process.env["OPENRIG_PORT"] = "7463";
const deps: StatusDeps = {
lifecycleDeps: {
...mockLifecycleDeps(),
exists: vi.fn(() => false),
readFile: vi.fn((p: string) => {
if (p === LOG_FILE) {
return [
"Error: The module '/tmp/better_sqlite3.node'",
"was compiled against a different Node.js version using",
"NODE_MODULE_VERSION 127. This version of Node.js requires",
"NODE_MODULE_VERSION 141.",
"code: 'ERR_DLOPEN_FAILED'",
].join("\n");
}
return null;
}),
fetch: vi.fn(async () => { throw new Error("refused"); }),
},
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
};
const prog = new Command();
prog.exitOverride();
prog.addCommand(upCommand({ ...deps, preflightExec: healthyPreflightExec }));
const { logs, exitCode } = await captureLogs(async () => {
await prog.parseAsync(["node", "rig", "up", "/tmp/test.yaml"]);
});
if (savedPort !== undefined) process.env["OPENRIG_PORT"] = savedPort;
else delete process.env["OPENRIG_PORT"];
const output = logs.join("\n");
expect(output).toContain("better-sqlite3");
expect(output).toContain("Node");
expect(output).not.toContain("Failed to auto-start daemon. Start manually with: rig daemon start");
expect(exitCode).toBe(2);
}, 15000);
it("up with library name matching existing rig shows ambiguity error", async () => {
// Mock server that has both a library spec and an existing rig named "my-rig"
let startupRequests = 0;
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", (req: http.IncomingMessage, res: http.ServerResponse) => {
const url = decodeURIComponent(req.url ?? "");
if (url === "/api/up") startupRequests++;
if (url.startsWith("/api/specs/library")) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ id: "lib1", name: "alpha", sourcePath: "/specs/alpha.yaml" }]));
} else if (url === "/api/rigs/summary") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ id: "r1", name: "alpha", nodeCount: 1 }]));
} else {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({}));
}
});
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "alpha"]);
});
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
expect(logs.join("\n")).toContain("ambiguous");
expect(logs.join("\n")).toContain("existing rig restore target");
expect(logs.join("\n")).toContain("/specs/alpha.yaml");
expect(logs.join("\n")).toContain("rig up alpha --existing");
expect(logs.join("\n")).not.toContain("rename or remove");
expect(startupRequests).toBe(0);
expect(exitCode).toBe(1);
});
it.each([
["a running rig", [{ id: "r1", name: "alpha", nodeCount: 1 }], []],
["an archived rig", [], [{ id: "r0", name: "alpha" }]],
])("up <library name> --name launches the library spec when the name also matches %s", async (_label, active, archived) => {
const posts: Array<Record<string, unknown>> = [];
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
const url = decodeURIComponent(req.url ?? "");
let body = "";
for await (const chunk of req) body += chunk;
res.writeHead(url === "/api/up" ? 201 : 200, { "Content-Type": "application/json" });
if (url === "/api/up") {
posts.push(JSON.parse(body));
res.end(JSON.stringify({ status: "completed", runId: "r", rigId: "g", stages: [], errors: [] }));
} else if (url.startsWith("/api/specs/library")) {
res.end(JSON.stringify([{ id: "lib1", name: "alpha", sourcePath: "/specs/alpha.yaml" }]));
} else if (url === "/api/rigs/summary?archived=only") {
res.end(JSON.stringify(archived));
} else if (url === "/api/rigs/summary") {
res.end(JSON.stringify(active));
} else {
res.end(JSON.stringify({}));
}
});
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "alpha", "--name", "beta"]);
});
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
expect(logs.join("\n")).not.toContain("ambiguous");
expect(logs.join("\n")).not.toContain("archived");
expect(posts).toHaveLength(1);
expect(posts[0]).toMatchObject({ sourceRef: "/specs/alpha.yaml", rigName: "beta" });
expect(exitCode ?? 0).toBe(0);
});
it.each([
["fresh alias", [], [], false, 0, "first-project"],
["existing starter", [{ id: "new-rig", name: "starter" }], [], false, 1, "first-project"],
["existing old name", [{ id: "old-rig", name: "first-project" }], [], false, 1, "first-project"],
["archived starter", [], [{ id: "archived-new", name: "starter" }], false, 1, "first-project"],
["archived old name", [], [{ id: "archived-old", name: "first-project" }], false, 1, "first-project"],
["explicit old restore", [{ id: "old-rig", name: "first-project" }], [], true, 0, "first-project"],
["explicit library ID", [{ id: "new-rig", name: "starter" }], [], false, 0, "starter-spec"],
] as const)("first-project routing: %s", async (_label, active, archived, existing, exit, input) => {
const originalListeners = server.listeners("request").slice();
server.removeAllListeners("request");
const posts: Array<Record<string, unknown>> = [];
const requests: string[] = [];
server.on("request", async (req, res) => {
requests.push(req.url ?? "");
let body = "";
for await (const chunk of req) body += chunk;
res.writeHead(200, { "Content-Type": "application/json" });
if (req.url === "/api/rigs/summary") res.end(JSON.stringify(active));
else if (req.url === "/api/rigs/summary?archived=only") res.end(JSON.stringify(archived));
else if (req.url?.startsWith("/api/specs/library")) res.end(JSON.stringify([{
id: "starter-spec", name: "starter", kind: "rig", sourceType: "builtin", sourcePath: "/builtin/starter/rig.yaml",
}]));
else if (req.url === "/api/up") {
posts.push(JSON.parse(body));
res.end(JSON.stringify({ status: "planned", runId: "alias-plan", stages: [], errors: [], warnings: [] }));
} else res.end("{}");
});
try {
const result = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", input, "--plan", "--json", ...(existing ? ["--existing"] : [])]);
});
expect(result.exitCode ?? 0).toBe(exit);
if (exit) {
expect(posts).toHaveLength(0);
if (archived.length) expect(result.logs.join("\n")).toContain(`rig unarchive ${archived[0]!.id}`);
else expect(result.logs.join("\n")).toContain(`rig up ${active[0]!.name} --existing`);
} else {
expect(posts).toHaveLength(1);
expect(posts[0]!.sourceRef).toBe(existing ? "first-project" : "/builtin/starter/rig.yaml");
if (existing) expect(requests.some((url) => url.startsWith("/api/specs/library"))).toBe(false);
else expect(posts[0]!.cwdOverride).toBe(process.cwd());
expect(result.logs.filter((line) => line.startsWith("{")).map((line) => JSON.parse(line))).toHaveLength(1);
}
} finally {
server.removeAllListeners("request");
for (const listener of originalListeners) server.on("request", listener as (req: http.IncomingMessage, res: http.ServerResponse) => void);
}
});
it("up resolves a same-name rig and workflow to the rig library entry", async () => {
const origListeners = server.listeners("request");
let lastBody: Record<string, unknown> = {};
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
const url = decodeURIComponent(req.url ?? "");
if (url.startsWith("/api/specs/library?kind=rig")) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ id: "rig1", kind: "rig", name: "conveyor", sourceType: "builtin", sourcePath: "/specs/rigs/conveyor/rig.yaml" }]));
} else if (url === "/api/rigs/summary") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([]));
} else if (url === "/api/up" && req.method === "POST") {
lastBody = JSON.parse(body);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "planned", runId: "r", stages: [], errors: [] }));
} else {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: `unexpected request: ${req.method} ${url}` }));
}
});
const { exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "conveyor", "--plan"]);
});
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
expect(lastBody.sourceRef).toBe("/specs/rigs/conveyor/rig.yaml");
expect(lastBody.cwdOverride).toBe(process.cwd());
expect(exitCode).toBeUndefined();
});
it.each([false, true])("up --existing bypasses library-name ambiguity with plan=%s", async (plan) => {
const origListeners = server.listeners("request");
let lastBody: Record<string, unknown> = {};
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
if (req.url === "/api/rigs/summary" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ id: "r1", name: "alpha", nodeCount: 1, lifecycleState: "recoverable" }]));
} else if (req.url === "/api/up" && req.method === "POST") {
lastBody = JSON.parse(body);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "restored", rigId: "r1", rigName: "alpha", rigResult: "restored", nodes: [], warnings: [] }));
} else {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: `unexpected request: ${req.method} ${req.url}` }));
}
});
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "alpha", "--existing", ...(plan ? ["--plan"] : [])]);
});
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
expect(lastBody.sourceRef).toBe("alpha");
expect(lastBody.plan).toBe(plan);
expect(logs.join("\n")).toContain('Recovering rig "alpha" from latest snapshot or current DB state');
expect(exitCode).toBeUndefined();
});
// L2 wording divergence: rig name in recoverable state -> "Recovering ..."; stopped -> "Turning on ..."
it("up <rig-name> prints 'Recovering ... from latest snapshot' when lifecycleState=recoverable", async () => {
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
if (req.url === "/api/specs/library" && req.method === "GET") {
// No library match — falls through to existing-rig path
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([]));
} else if (req.url === "/api/rigs/summary" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ id: "r-rec", name: "stale-velocity", nodeCount: 4, lifecycleState: "recoverable" }]));
} else if (req.url === "/api/up" && req.method === "POST") {
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "restored", rigId: "r-rec", rigName: "stale-velocity", rigResult: "restored", nodes: [], warnings: [] }));
} else {
res.writeHead(404);
res.end();
}
});
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "stale-velocity"]);
});
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
const output = logs.join("\n");
expect(output).toContain('Recovering rig "stale-velocity" from latest snapshot or current DB state');
expect(output).not.toContain('Turning on rig "stale-velocity"');
expect(exitCode).toBeUndefined(); // 0
});
it("up <rig-name> prints 'Turning on ...' when lifecycleState=stopped (no usable snapshot)", async () => {
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
if (req.url === "/api/specs/library" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([]));
} else if (req.url === "/api/rigs/summary" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ id: "r-stop", name: "fresh-rig", nodeCount: 1, lifecycleState: "stopped" }]));
} else if (req.url === "/api/up" && req.method === "POST") {
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "completed", rigId: "r-stop", stages: [], errors: [], warnings: [] }));
} else {
res.writeHead(404);
res.end();
}
});
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "fresh-rig"]);
});
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
const output = logs.join("\n");
expect(output).toContain('Turning on rig "fresh-rig"');
expect(output).not.toContain('Recovering rig "fresh-rig"');
expect(exitCode).toBeUndefined();
});
it("up <rig-name> prints no recovery wording when lifecycleState=running (already up)", async () => {
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
if (req.url === "/api/specs/library" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([]));
} else if (req.url === "/api/rigs/summary" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ id: "r-run", name: "live-rig", nodeCount: 1, lifecycleState: "running" }]));
} else if (req.url === "/api/up" && req.method === "POST") {
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "completed", rigId: "r-run", stages: [], errors: [], warnings: [] }));
} else {
res.writeHead(404);
res.end();
}
});
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "live-rig"]);
});
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
const output = logs.join("\n");
expect(output).not.toContain("Recovering rig");
expect(output).not.toContain("Turning on rig");
expect(exitCode).toBeUndefined();
});
it("up --json suppresses the wording prefix even for recoverable rigs (machine output stays clean)", async () => {
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
if (req.url === "/api/specs/library" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([]));
} else if (req.url === "/api/rigs/summary" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ id: "r-rec", name: "json-rig", nodeCount: 1, lifecycleState: "recoverable" }]));
} else if (req.url === "/api/up" && req.method === "POST") {
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "restored", rigId: "r-rec", rigName: "json-rig", rigResult: "restored", nodes: [], warnings: [] }));
} else {
res.writeHead(404);
res.end();
}
});
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "json-rig", "--json"]);
});
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
// The body must be valid JSON (no preceding "Recovering ..." text).
const joined = logs.join("");
expect(() => JSON.parse(joined)).not.toThrow();
});
// OPR.0.3.3.19 AC-7: a name matching ONLY an archived rig is refused with an
// honest error pointing at `rig unarchive` - never a silent restore.
it("up <archived-name> refuses with an honest unarchive error and never posts /api/up", async () => {
const origListeners = server.listeners("request");
let upHit = false;
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
if (req.url === "/api/rigs/summary" && req.method === "GET") {
// No ACTIVE rig of this name (default summary excludes archived).
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([]));
} else if (req.url === "/api/rigs/summary?archived=only" && req.method === "GET") {
// The name matches an archived rig.
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ id: "r-arc", name: "tidy-me", nodeCount: 2 }]));
} else if (req.url === "/api/up" && req.method === "POST") {
upHit = true;
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "completed", rigId: "r-arc", stages: [], errors: [], warnings: [] }));
} else {
res.writeHead(404);
res.end();
}
});
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "tidy-me"]);
});
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
const output = logs.join("\n");
expect(output).toContain('Rig "tidy-me" is archived');
// Remediation must name the rig ID (rig unarchive resolves by id, not name).
expect(output).toContain("rig unarchive r-arc");
expect(output).not.toContain("rig unarchive tidy-me");
expect(upHit).toBe(false); // no silent restore
expect(exitCode).toBe(1);
});
// OPR.0.3.3.19 AC-7 (json): same refusal, machine-readable.
it("up <archived-name> --json emits a rig_archived error object and exits 1", async () => {
const origListeners = server.listeners("request");
let upHit = false;
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
if (req.url === "/api/rigs/summary" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([]));
} else if (req.url === "/api/rigs/summary?archived=only" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ id: "r-arc", name: "tidy-me", nodeCount: 2 }]));
} else if (req.url === "/api/up" && req.method === "POST") {
upHit = true;
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "completed" }));
} else {
res.writeHead(404);
res.end();
}
});
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "tidy-me", "--json"]);
});
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
const parsed = JSON.parse(logs.join(""));
expect(parsed.error).toBe("rig_archived");
// Action targets the rig ID (rig unarchive resolves by id, not name).
expect(parsed.action).toBe("rig unarchive r-arc");
expect(parsed.archivedRigIds).toEqual(["r-arc"]);
expect(upHit).toBe(false);
expect(exitCode).toBe(1);
});
// OPR.0.3.3.19 AC-7 (ambiguous): a name shared by multiple archived rigs must
// surface the id list, not guess a single (wrong) target.
it("up <archived-name> with multiple archived id matches lists each id, never posts /api/up", async () => {
const origListeners = server.listeners("request");
let upHit = false;
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
if (req.url === "/api/rigs/summary" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([]));
} else if (req.url === "/api/rigs/summary?archived=only" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([
{ id: "r-arc-1", name: "dupe", nodeCount: 1 },
{ id: "r-arc-2", name: "dupe", nodeCount: 1 },
]));
} else if (req.url === "/api/up" && req.method === "POST") {
upHit = true;
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "completed" }));
} else {
res.writeHead(404);
res.end();
}
});
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "dupe"]);
});
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
const output = logs.join("\n");
expect(output).toContain("rig unarchive r-arc-1");
expect(output).toContain("rig unarchive r-arc-2");
expect(upHit).toBe(false);
expect(exitCode).toBe(1);
});
// L3b: manual-fallback note printed when restore came from a non-auto-pre-down snapshot.
it("L3b: prints manual-fallback note when daemon restored from a manual snapshot", async () => {
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
if (req.url === "/api/specs/library" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([]));
} else if (req.url === "/api/rigs/summary" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ id: "r-l3b", name: "manual-only-rig", nodeCount: 1, lifecycleState: "recoverable" }]));
} else if (req.url === "/api/up" && req.method === "POST") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "restored", rigId: "r-l3b", rigName: "manual-only-rig", rigResult: "fully_restored", nodes: [], warnings: [], snapshotKind: "manual" }));
} else {
res.writeHead(404);
res.end();
}
});
const { logs, exitCode } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "manual-only-rig"]);
});
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
const output = logs.join("\n");
expect(output).toContain("Restoring from manual snapshot (kind=manual); no auto-pre-down snapshot available.");
expect(output).toContain('Rig "manual-only-rig" restored');
expect(exitCode).toBeUndefined();
});
it("L3b: does NOT print manual-fallback note when restore came from auto-pre-down", async () => {
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
if (req.url === "/api/specs/library" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([]));
} else if (req.url === "/api/rigs/summary" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([{ id: "r-l3b-auto", name: "auto-rig", nodeCount: 1, lifecycleState: "recoverable" }]));
} else if (req.url === "/api/up" && req.method === "POST") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "restored", rigId: "r-l3b-auto", rigName: "auto-rig", rigResult: "fully_restored", nodes: [], warnings: [], snapshotKind: "auto-pre-down" }));
} else {
res.writeHead(404);
res.end();
}
});
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", "auto-rig"]);
});
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
const output = logs.join("\n");
expect(output).not.toContain("Restoring from manual snapshot");
expect(output).toContain('Rig "auto-rig" restored');
});
// Agent Starter v1 vertical M2 R2 — CLI plan smoke proves resolved
// starter contents reach plan output (Path A from the M2 R2 dispatch
// packet). The daemon's plan-mode response carries a `resolve_starter`
// stage with the resolved starter ResolvedStartupFile shape; the CLI's
// --json mode renders the full daemon response, so the starter content
// surfaces verbatim in plan output. M2 R1 only asserted argument
// forwarding, which proved nothing about plan visibility of starter
// resolution.
it("M2 R2: rig up --plan --json against a starter_ref fixture spec surfaces resolved starter contents", async () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const fs = await import("node:fs");
const os = await import("node:os");
const path = await import("node:path");
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-up-starter-r2-"));
const specPath = path.join(tmpDir, "starter-fixture.yaml");
fs.writeFileSync(specPath, `version: "0.2"
name: starter-cli-smoke
pods:
- id: dev
label: Development
members:
- id: impl
agent_ref: local:agents/impl
profile: default
runtime: claude-code
cwd: .
starter_ref:
name: openrig-builder-base--claude-code
edges: []
edges: []
`, "utf-8");
let lastBody: Record<string, unknown> = {};
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
if (req.url === "/api/up" && req.method === "POST") {
lastBody = JSON.parse(body);
// Plan-mode response that carries the resolved starter. The CLI's
// --json branch (commands/up.ts:175-181) prints the full response
// verbatim — so the starter content reaches operator output as
// structured data they can inspect or pipe into other tools.
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
status: "planned",
runId: "starter-run",
stages: [
{ stage: "resolve_spec", status: "ok" },
{
stage: "resolve_starter",
status: "ok",
detail: {
memberId: "dev.impl",
starterRef: "openrig-builder-base--claude-code",
starterContent: [
{
path: "openrig-builder-base--claude-code.yaml",
ownerRoot: "/fixture/registry",
deliveryHint: "guidance_merge",
appliesOn: ["fresh_start"],
required: true,
},
],
},
},
],
errors: [],
warnings: [],
}));
} else {
res.writeHead(404);
res.end();
}
});
try {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", specPath, "--plan", "--json"]);
});
const output = logs.join("\n");
// The CLI's --json mode emits a single JSON.stringify(res.data) line.
// Parse it and verify the starter content reached plan output.
const parsed = JSON.parse(output) as {
status: string;
stages: Array<{ stage: string; status: string; detail?: Record<string, unknown> }>;
};
expect(parsed.status).toBe("planned");
const resolveStarter = parsed.stages.find((s) => s.stage === "resolve_starter");
expect(resolveStarter, "expected resolve_starter stage to surface in plan output").toBeDefined();
expect(resolveStarter!.status).toBe("ok");
const starterContent = (resolveStarter!.detail as { starterContent?: Array<Record<string, unknown>> })?.starterContent;
expect(starterContent, "expected resolved starter contents in plan output").toBeDefined();
expect(Array.isArray(starterContent)).toBe(true);
expect(starterContent!.length).toBeGreaterThan(0);
expect(starterContent![0]!["path"]).toBe("openrig-builder-base--claude-code.yaml");
expect(starterContent![0]!["deliveryHint"]).toBe("guidance_merge");
expect(starterContent![0]!["appliesOn"]).toEqual(["fresh_start"]);
// Belt-and-suspenders: argument forwarding still asserted so the
// smoke also catches CLI request-shape regressions.
expect(lastBody.sourceRef).toBe(specPath);
expect(lastBody.plan).toBe(true);
} finally {
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});