mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
A person whose tmux crashes on `capture-pane -p` now learns it from `rig doctor` (a `tmux_capture` failure) and `rig setup` (its tmux step), with tmux's own error, why it matters and the fix. Before, every seat failed at launch with "server exited unexpectedly". The check runs the getting-started guide's existing command on a throwaway socket with no config, so a person's own sessions are never touched. Every command is bounded, and the throwaway server always gets a cleanup attempt. A server that won't start reads as not checked, not as a failure. There's no minimum tmux version. Refs #980.
1167 lines
56 KiB
TypeScript
1167 lines
56 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { createProgram } from "../src/index.js";
|
|
import { Command } from "commander";
|
|
import { parse as parseYaml } from "yaml";
|
|
import { setupCommand, runSetup, goldenPathNextSteps, permissionPolicyMenuLines, parseSetupProviders, routerLineLines, type SetupDeps, type SetupResult, type SetupStep } from "../src/commands/setup.js";
|
|
import { ConfigStore } from "../src/config-store.js";
|
|
import type { DoctorDeps } from "../src/commands/doctor.js";
|
|
|
|
function makeDeps(overrides?: Partial<SetupDeps>): SetupDeps {
|
|
return {
|
|
exec: (cmd: string) => {
|
|
if (cmd === "herdr --version") return "herdr 0.9.3\n";
|
|
if (cmd === "brew --version") return "Homebrew 4.0\n";
|
|
if (cmd === "tmux -V") return "tmux 3.4\n";
|
|
if (cmd === "cmux capabilities --json") return '{"capabilities":[]}\n';
|
|
if (cmd === "cmux --help") return "cmux help\n";
|
|
if (cmd === "claude --version") return "2.1.101 (Claude Code)\n";
|
|
if (cmd === "claude auth status") return "Authenticated\n";
|
|
if (cmd === "codex --version") return "codex-cli 0.118.0\n";
|
|
if (cmd === "codex login status") return "Logged in\n";
|
|
if (cmd === "jq --version") return "jq-1.7\n";
|
|
if (cmd === "gh --version") return "gh 2.0\n";
|
|
return "";
|
|
},
|
|
readFile: () => null,
|
|
writeFile: vi.fn(),
|
|
exists: () => false,
|
|
platform: "darwin",
|
|
mkdirp: vi.fn(),
|
|
// Never the person's real config: a path that doesn't exist reads as unanswered.
|
|
routerLineStore: new ConfigStore(join(tmpdir(), "setup-router-line-none", "config.json")),
|
|
recordProviders: vi.fn(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; exitCode: number | undefined }> {
|
|
const logs: string[] = [];
|
|
const orig = console.log;
|
|
let exitCode: number | undefined;
|
|
console.log = (...args: unknown[]) => logs.push(args.map(String).join(" "));
|
|
const origExitCode = process.exitCode;
|
|
return fn()
|
|
.then(() => {
|
|
exitCode = process.exitCode;
|
|
process.exitCode = origExitCode;
|
|
return { logs, exitCode };
|
|
})
|
|
.finally(() => { console.log = orig; });
|
|
}
|
|
|
|
function expectRuntimeConfigDisclosure(result: SetupResult): void {
|
|
// Setup leaves cmux configuration alone; only managed runtime disclosures remain.
|
|
expect(result.runtimeConfig).toHaveLength(9);
|
|
expect(result.runtimeConfig.find((d) => d.path === "~/.claude/settings.json")).toBeUndefined();
|
|
// The other writes are disclosed beside the router-line choice: guidance blocks, the collector, plugins, ignore entries.
|
|
expect(result.runtimeConfig.map((d) => `${d.runtime} ${d.path}`)).toEqual(expect.arrayContaining([
|
|
"claude-code CLAUDE.md (or CLAUDE.local.md when the rig chooses it)", "codex AGENTS.md", "claude-code .openrig/context-collector.cjs",
|
|
"codex .codex/plugins/<plugin>", "codex .git/info/exclude",
|
|
]));
|
|
expect(result.runtimeConfig).toEqual(expect.arrayContaining([
|
|
{
|
|
scope: "global",
|
|
runtime: "claude-code",
|
|
path: "~/.claude.json or <CLAUDE_CONFIG_DIR>/.claude.json",
|
|
purpose: "Pre-trust managed workspaces and mark Claude onboarding complete. Explicit permission modes use the launch-selected HOME/config; classic startup uses the daemon home.",
|
|
},
|
|
{
|
|
scope: "project",
|
|
runtime: "claude-code",
|
|
path: ".claude/settings.local.json",
|
|
purpose: expect.stringContaining("statusLine"),
|
|
},
|
|
{
|
|
scope: "project",
|
|
runtime: "claude-code",
|
|
path: ".mcp.json",
|
|
purpose: "Merge MCP fragments a team selects.",
|
|
},
|
|
{
|
|
scope: "global",
|
|
runtime: "codex",
|
|
path: "$CODEX_HOME/config.toml (default: ~/.codex/config.toml)",
|
|
purpose: expect.stringContaining("the openrig marketplace and openrig-core plugin"),
|
|
},
|
|
]));
|
|
}
|
|
|
|
describe("rig setup", () => {
|
|
it("advises custom tmux users without changing the inheritance setting or requiring a step", async () => {
|
|
const writes = vi.fn();
|
|
const result = await runSetup(makeDeps({
|
|
readFile: file => file.endsWith("/.tmux.conf") ? "set -g default-command custom-shell\n" : null,
|
|
writeFile: writes,
|
|
}), {});
|
|
expect(result.ready).toBe(true);
|
|
expect(result.steps.find(step => step.id === "tmux_environment")?.message).toContain("rig config set terminal.inherit_tmux_environment true");
|
|
expect(writes.mock.calls.some(([file]) => String(file).endsWith("config.json"))).toBe(false);
|
|
});
|
|
|
|
it("does not label the stock managed tmux block as a custom environment setup", async () => {
|
|
const result = await runSetup(makeDeps({ readFile: file => file.endsWith("/.tmux.conf")
|
|
? "# OpenRig managed block\nset -g mouse on\nset -g history-limit 50000\n# End # OpenRig managed block\n" : null }), {});
|
|
expect(result.steps.find(step => step.id === "tmux_environment")).toBeUndefined();
|
|
expect(result.ready).toBe(true);
|
|
});
|
|
|
|
it("wired via createProgram", async () => {
|
|
const program = createProgram();
|
|
const setupCmd = program.commands.find((c) => c.name() === "setup");
|
|
expect(setupCmd).toBeDefined();
|
|
});
|
|
|
|
it("--dry-run --json returns structured plan with core profile and expected step ids", async () => {
|
|
const deps = makeDeps();
|
|
const program = new Command();
|
|
program.addCommand(setupCommand(deps));
|
|
|
|
const { logs } = await captureLogs(() =>
|
|
program.parseAsync(["node", "rig", "setup", "--dry-run", "--json"]),
|
|
);
|
|
|
|
const result = JSON.parse(logs.join("\n")) as SetupResult;
|
|
expect(result.profile).toBe("core");
|
|
expect(result.platform).toBe("darwin");
|
|
const stepIds = result.steps.map((s) => s.id);
|
|
expect(stepIds).toContain("brew");
|
|
expect(stepIds).toContain("tmux_install");
|
|
expect(stepIds).toContain("herdr_install");
|
|
expect(stepIds).toContain("claude_install");
|
|
expect(stepIds).toContain("claude_auth");
|
|
expect(stepIds).toContain("codex_install");
|
|
expect(stepIds).toContain("codex_auth");
|
|
expect(stepIds).toContain("tmux_config");
|
|
expect(stepIds).toContain("verify");
|
|
// No full-profile extras
|
|
expect(stepIds).not.toContain("jq_install");
|
|
expect(stepIds).not.toContain("gh_install");
|
|
// All steps should be skipped in dry run
|
|
expect(result.steps.every((s) => s.status === "skipped")).toBe(true);
|
|
expectRuntimeConfigDisclosure(result);
|
|
});
|
|
|
|
it("--dry-run --full --json includes full profile with core + extra step ids", async () => {
|
|
const deps = makeDeps();
|
|
const program = new Command();
|
|
program.addCommand(setupCommand(deps));
|
|
|
|
const { logs } = await captureLogs(() =>
|
|
program.parseAsync(["node", "rig", "setup", "--dry-run", "--full", "--json"]),
|
|
);
|
|
|
|
const result = JSON.parse(logs.join("\n")) as SetupResult;
|
|
expect(result.profile).toBe("full");
|
|
const stepIds = result.steps.map((s) => s.id);
|
|
// Core steps still present
|
|
expect(stepIds).toContain("brew");
|
|
expect(stepIds).toContain("tmux_install");
|
|
expect(stepIds).toContain("herdr_install");
|
|
expect(stepIds).toContain("claude_install");
|
|
expect(stepIds).toContain("claude_auth");
|
|
expect(stepIds).toContain("codex_install");
|
|
expect(stepIds).toContain("codex_auth");
|
|
expect(stepIds).toContain("tmux_config");
|
|
expect(stepIds).toContain("verify");
|
|
// Full extras added
|
|
expect(stepIds).toContain("jq_install");
|
|
expect(stepIds).toContain("gh_install");
|
|
});
|
|
|
|
it("--dry-run --json exits 0 (plan-only, not a failure)", async () => {
|
|
const deps = makeDeps();
|
|
const program = new Command();
|
|
program.addCommand(setupCommand(deps));
|
|
|
|
const { exitCode } = await captureLogs(() =>
|
|
program.parseAsync(["node", "rig", "setup", "--dry-run", "--json"]),
|
|
);
|
|
|
|
expect(exitCode).toBeUndefined(); // undefined means 0
|
|
});
|
|
|
|
it("--dry-run does not make mutating exec calls", async () => {
|
|
const execSpy = vi.fn(() => "");
|
|
const deps = makeDeps({ exec: execSpy });
|
|
await runSetup(deps, { dryRun: true });
|
|
expect(execSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("--dry-run on linux reports the macOS-only steps as skipped with the real run's reasons", async () => {
|
|
const dry = await runSetup(makeDeps({ platform: "linux" }), { dryRun: true });
|
|
const real = await runSetup(makeDeps({ platform: "linux", exec: (cmd: string) => {
|
|
if (cmd === "tmux -V") return "tmux 3.4\n";
|
|
throw new Error(`not found: ${cmd}`);
|
|
} }), {});
|
|
|
|
for (const id of ["brew"]) {
|
|
const dryStep = dry.steps.find((s) => s.id === id);
|
|
expect(dryStep?.status).toBe("skipped");
|
|
expect(dryStep?.message).toBe(real.steps.find((s) => s.id === id)?.message);
|
|
}
|
|
expect(dry.steps.find((s) => s.id === "brew")?.message).toBe("Skipped: Homebrew setup path is only used on macOS.");
|
|
expect(dry.steps.find((s) => s.id === "tmux_install")?.message).toBe("Dry run: tmux_install would be attempted.");
|
|
});
|
|
|
|
it("--dry-run on darwin still reports brew and herdr_install as would be attempted", async () => {
|
|
const dry = await runSetup(makeDeps({ platform: "darwin" }), { dryRun: true });
|
|
expect(dry.steps.find((s) => s.id === "brew")).toEqual({ id: "brew", status: "skipped", message: "Dry run: brew would be attempted." });
|
|
expect(dry.steps.find((s) => s.id === "herdr_install")).toEqual({ id: "herdr_install", status: "skipped", message: "Dry run: herdr_install would be attempted." });
|
|
});
|
|
|
|
it("core profile execution with all tools present returns pass/applied steps and ready=true", async () => {
|
|
const writeSpy = vi.fn();
|
|
const deps = makeDeps({ writeFile: writeSpy });
|
|
const result = await runSetup(deps, {});
|
|
|
|
expect(result.profile).toBe("core");
|
|
expect(result.ready).toBe(true);
|
|
|
|
const brew = result.steps.find((s) => s.id === "brew");
|
|
expect(brew?.status).toBe("pass");
|
|
|
|
const tmux = result.steps.find((s) => s.id === "tmux_install");
|
|
expect(tmux?.status).toBe("pass");
|
|
|
|
const herdr = result.steps.find((s) => s.id === "herdr_install");
|
|
expect(herdr?.status).toBe("pass");
|
|
|
|
const claudeInstall = result.steps.find((s) => s.id === "claude_install");
|
|
expect(claudeInstall?.status).toBe("pass");
|
|
|
|
const claudeAuth = result.steps.find((s) => s.id === "claude_auth");
|
|
expect(claudeAuth?.status).toBe("pass");
|
|
|
|
const codexInstall = result.steps.find((s) => s.id === "codex_install");
|
|
expect(codexInstall?.status).toBe("pass");
|
|
|
|
const codexAuth = result.steps.find((s) => s.id === "codex_auth");
|
|
expect(codexAuth?.status).toBe("pass");
|
|
|
|
const tmuxConfig = result.steps.find((s) => s.id === "tmux_config");
|
|
expect(tmuxConfig?.status).toBe("applied");
|
|
|
|
const verify = result.steps.find((s) => s.id === "verify");
|
|
expect(verify?.status).toBe("pass");
|
|
});
|
|
|
|
describe("terminal.history_limit in the managed tmux block", () => {
|
|
const BLOCK = (limit: number) => `# OpenRig managed block\nset -g mouse on\nset -g history-limit ${limit}\n# End # OpenRig managed block`;
|
|
let dir: string;
|
|
let envLimit: string | undefined;
|
|
beforeEach(() => {
|
|
dir = mkdtempSync(join(tmpdir(), "setup-history-limit-"));
|
|
envLimit = process.env["OPENRIG_TERMINAL_HISTORY_LIMIT"];
|
|
delete process.env["OPENRIG_TERMINAL_HISTORY_LIMIT"];
|
|
});
|
|
afterEach(() => {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
if (envLimit === undefined) delete process.env["OPENRIG_TERMINAL_HISTORY_LIMIT"];
|
|
else process.env["OPENRIG_TERMINAL_HISTORY_LIMIT"] = envLimit;
|
|
});
|
|
|
|
async function setupConf(store: ConfigStore, existing: string | null): Promise<{ conf: string; step: SetupStep | undefined }> {
|
|
const writeSpy = vi.fn();
|
|
const result = await runSetup(makeDeps({ readFile: (p) => (p.endsWith("/.tmux.conf") ? existing : null), writeFile: writeSpy, configStore: store }), {});
|
|
const [, conf] = writeSpy.mock.calls.find(([p]) => String(p).endsWith("/.tmux.conf"))!;
|
|
return { conf: String(conf), step: result.steps.find((s) => s.id === "tmux_config") };
|
|
}
|
|
|
|
it("keeps the default above the transcript window and small enough for a long-lived fleet", async () => {
|
|
const { conf } = await setupConf(new ConfigStore(join(dir, "config.json")), null);
|
|
const limit = Number(/^set -g history-limit (\d+)$/m.exec(conf)?.[1]);
|
|
// Rotation captures the last 1000 rows (transcripts.lines default): history must hold them with margin.
|
|
expect(limit).toBeGreaterThanOrEqual(2000);
|
|
// tmux 3.4 at 90 panes x 50000 rows: 12.8 GB server heap and 250 ms/s of server CPU under the daemon's captures.
|
|
expect(limit).toBeLessThanOrEqual(10000);
|
|
});
|
|
|
|
it("writes 10000 rows by default", async () => {
|
|
const store = new ConfigStore(join(dir, "config.json"));
|
|
expect(store.resolveWithSource("terminal.history_limit")).toMatchObject({ value: 10000, source: "default", defaultValue: 10000 });
|
|
const { conf, step } = await setupConf(store, null);
|
|
expect(conf).toBe(BLOCK(10000) + "\n");
|
|
expect(step).toMatchObject({ status: "applied", message: expect.stringContaining("history-limit 10000") });
|
|
});
|
|
|
|
it("writes the configured value, from the config file or the environment", async () => {
|
|
const store = new ConfigStore(join(dir, "config.json"));
|
|
store.set("terminal.history_limit", "50000");
|
|
expect((await setupConf(store, null)).conf).toBe(BLOCK(50000) + "\n");
|
|
process.env["OPENRIG_TERMINAL_HISTORY_LIMIT"] = "20000";
|
|
try {
|
|
expect((await setupConf(store, null)).conf).toBe(BLOCK(20000) + "\n");
|
|
} finally { delete process.env["OPENRIG_TERMINAL_HISTORY_LIMIT"]; }
|
|
});
|
|
|
|
it("rejects values that are not an integer in [2000, 1000000]", () => {
|
|
const store = new ConfigStore(join(dir, "config.json"));
|
|
for (const raw of ["0", "-5000", "1999", "1000001", "2.5", "10000rows", "many", ""]) {
|
|
expect(() => store.set("terminal.history_limit", raw)).toThrow(/terminal\.history_limit/);
|
|
}
|
|
store.set("terminal.history_limit", "2000");
|
|
store.set("terminal.history_limit", "1000000");
|
|
expect(store.get("terminal.history_limit")).toBe(1000000);
|
|
});
|
|
|
|
it("rewrites the block in place on a rerun and leaves the user's override after it", async () => {
|
|
const store = new ConfigStore(join(dir, "config.json"));
|
|
const user = "set -g prefix C-a\n\n";
|
|
const override = "\nset -g history-limit 75000\n";
|
|
const first = (await setupConf(store, user + BLOCK(50000) + override)).conf;
|
|
expect(first).toBe(user + BLOCK(10000) + override);
|
|
store.set("terminal.history_limit", "30000");
|
|
const { conf, step } = await setupConf(store, first);
|
|
expect(conf).toBe(user + BLOCK(30000) + override);
|
|
expect(conf.match(/^# OpenRig managed block$/gm)).toHaveLength(1);
|
|
expect(step?.message).toBe("Updated OpenRig managed tmux config block (history-limit 30000).");
|
|
// tmux applies the last `set -g history-limit`: the user's line after the block.
|
|
expect([...conf.matchAll(/^set -g history-limit (\d+)$/gm)].at(-1)?.[1]).toBe("75000");
|
|
});
|
|
});
|
|
|
|
// OPR.0.3.3.04.2 (AC-1): the ONE canonical ordered golden path over EXISTING
|
|
// verbs (no mega-command), in sequence, with the durable doc reference.
|
|
it("AC-1: goldenPathNextSteps is the ordered sequence over existing verbs, with the durable doc", () => {
|
|
const out = goldenPathNextSteps().join("\n");
|
|
const upIdx = out.indexOf("rig up");
|
|
const loginIdx = out.indexOf("claude auth status");
|
|
const daemonIdx = out.indexOf("rig daemon start");
|
|
const statusIdx = out.indexOf("rig status");
|
|
const offerIdx = out.indexOf("Show the person OpenRig");
|
|
const viewIdx = out.indexOf("rig terminal open saved:kernel");
|
|
const wsIdx = out.indexOf("rig workspace doctor");
|
|
const wfIdx = out.indexOf("rig workflow specs");
|
|
const scopeIdx = out.indexOf("rig scope");
|
|
expect(upIdx).toBeGreaterThan(-1);
|
|
expect(loginIdx).toBeGreaterThan(-1);
|
|
expect(daemonIdx).toBeGreaterThan(loginIdx);
|
|
expect(statusIdx).toBeGreaterThan(daemonIdx);
|
|
expect(offerIdx).toBeGreaterThan(statusIdx);
|
|
expect(viewIdx).toBeGreaterThan(offerIdx);
|
|
expect(upIdx).toBeGreaterThan(viewIdx);
|
|
expect(wsIdx).toBeGreaterThan(statusIdx);
|
|
expect(wfIdx).toBeGreaterThan(wsIdx);
|
|
expect(scopeIdx).toBeGreaterThan(wsIdx);
|
|
expect(out).toContain("rig up <team> --cwd . --plan");
|
|
for (const name of ["starter", "workshop", "factory"]) expect(out).toContain(name);
|
|
expect(out).not.toContain("first-project-claude");
|
|
expect(out).not.toContain("first-project-mixed");
|
|
expect(out).toContain("Check only selected logins");
|
|
expect(out).toContain("Started is not ready");
|
|
expect(out).toContain("If they said not now: give the command to open it later");
|
|
expect(out).toContain("Over headless SSH: ask the person to open a new terminal/tab and give the exact connection/attach command");
|
|
expect(out).toContain("On a desktop, no copying or typing for the person; check the result notes, not just CLI success");
|
|
expect(out).toContain("rig send dev-build@starter");
|
|
expect(out).toContain("rig queue list --destination dev-build@starter");
|
|
expect(out).not.toContain("rig queue list --rig");
|
|
expect(out).toContain("rig tui --shared");
|
|
expect(out).toContain("rig context get reference/getting-started.md");
|
|
// no magic mega-command - the path is existing verbs only
|
|
expect(out).not.toMatch(/rig (journey|onboarding)\b/);
|
|
// built-in discovery surface is `rig workflow specs` (lists registered specs,
|
|
// built-ins tagged "(built-in)"), NOT `rig workflow list` (lists instances) -
|
|
// the path must point a fresh operator at the right discovery command.
|
|
expect(out).toContain("rig workflow specs");
|
|
expect(out).not.toContain("rig workflow list");
|
|
});
|
|
|
|
it("fails setup honestly when tmux is installed but the default control socket is unhealthy", async () => {
|
|
const deps = makeDeps({
|
|
exec: (cmd: string) => {
|
|
if (cmd === "herdr --version") return "herdr 0.9.3\n";
|
|
if (cmd === "brew --version") return "Homebrew 4.0\n";
|
|
if (cmd === "tmux -V") return "tmux 3.4\n";
|
|
if (cmd === "tmux list-sessions") throw new Error("server exited unexpectedly");
|
|
if (cmd === "cmux capabilities --json") return '{"capabilities":[]}\n';
|
|
if (cmd === "cmux --help") return "cmux help\n";
|
|
if (cmd === "claude --version") return "2.1.101 (Claude Code)\n";
|
|
if (cmd === "claude auth status") return "Authenticated\n";
|
|
if (cmd === "codex --version") return "codex-cli 0.118.0\n";
|
|
if (cmd === "codex login status") return "Logged in\n";
|
|
return "";
|
|
},
|
|
});
|
|
|
|
const result = await runSetup(deps, {});
|
|
|
|
const tmux = result.steps.find((s) => s.id === "tmux_install");
|
|
expect(tmux?.status).toBe("fail");
|
|
expect(tmux?.message).toContain("control socket");
|
|
expect(tmux?.reason).toContain("server exited unexpectedly");
|
|
expect(tmux?.fixHint).toContain("restart the default tmux server");
|
|
expect(result.ready).toBe(false);
|
|
});
|
|
|
|
it("#980: the tmux step fails with the fix when tmux's server exits on capture-pane -p", async () => {
|
|
const base = makeDeps();
|
|
const result = await runSetup(makeDeps({ exec: (cmd: string) => {
|
|
if (cmd.includes("capture-pane -p")) throw new Error("server exited unexpectedly");
|
|
if (cmd === "tmux -V") return "tmux next-3.4\n";
|
|
return base.exec(cmd);
|
|
} }), {});
|
|
const tmux = result.steps.find((s) => s.id === "tmux_install");
|
|
expect(tmux).toMatchObject({ status: "fail", message: expect.stringContaining("can't capture a pane") });
|
|
expect(tmux?.reason).toContain("#980");
|
|
expect(tmux?.fixHint).toMatch(/first on your PATH/);
|
|
expect(result.ready).toBe(false);
|
|
});
|
|
|
|
it("full profile extends core with jq_install and gh_install", async () => {
|
|
const deps = makeDeps();
|
|
const result = await runSetup(deps, { full: true });
|
|
|
|
expect(result.profile).toBe("full");
|
|
const stepIds = result.steps.map((s) => s.id);
|
|
// Core steps present
|
|
expect(stepIds).toContain("brew");
|
|
expect(stepIds).toContain("tmux_install");
|
|
// Full extras present
|
|
expect(stepIds).toContain("jq_install");
|
|
expect(stepIds).toContain("gh_install");
|
|
|
|
const jq = result.steps.find((s) => s.id === "jq_install");
|
|
expect(jq?.status).toBe("pass");
|
|
const gh = result.steps.find((s) => s.id === "gh_install");
|
|
expect(gh?.status).toBe("pass");
|
|
});
|
|
|
|
it("installs missing Claude Code with npm and verifies auth", async () => {
|
|
let claudeInstalled = false;
|
|
const deps = makeDeps({
|
|
exec: (cmd: string) => {
|
|
if (cmd === "herdr --version") return "herdr 0.9.3\n";
|
|
if (cmd === "brew --version") return "Homebrew 4.0\n";
|
|
if (cmd === "tmux -V") return "tmux 3.4\n";
|
|
if (cmd === "cmux capabilities --json") return '{"capabilities":[]}\n';
|
|
if (cmd === "claude --version") {
|
|
if (claudeInstalled) return "2.1.101 (Claude Code)\n";
|
|
throw new Error("command not found: claude");
|
|
}
|
|
if (cmd === "npm install -g @anthropic-ai/claude-code") {
|
|
claudeInstalled = true;
|
|
return "installed claude\n";
|
|
}
|
|
if (cmd === "claude auth status") return "Authenticated\n";
|
|
if (cmd === "codex --version") return "codex-cli 0.118.0\n";
|
|
if (cmd === "codex login status") return "Logged in\n";
|
|
return "";
|
|
},
|
|
});
|
|
|
|
const result = await runSetup(deps, {});
|
|
|
|
const claudeInstall = result.steps.find((s) => s.id === "claude_install");
|
|
expect(claudeInstall?.status).toBe("applied");
|
|
|
|
const claudeAuth = result.steps.find((s) => s.id === "claude_auth");
|
|
expect(claudeAuth?.status).toBe("pass");
|
|
});
|
|
|
|
it("fails setup honestly when Codex is installed but not logged in", async () => {
|
|
const deps = makeDeps({
|
|
exec: (cmd: string) => {
|
|
if (cmd === "herdr --version") return "herdr 0.9.3\n";
|
|
if (cmd === "brew --version") return "Homebrew 4.0\n";
|
|
if (cmd === "tmux -V") return "tmux 3.4\n";
|
|
if (cmd === "cmux capabilities --json") return '{"capabilities":[]}\n';
|
|
if (cmd === "claude --version") return "2.1.101 (Claude Code)\n";
|
|
if (cmd === "claude auth status") return "Authenticated\n";
|
|
if (cmd === "codex --version") return "codex-cli 0.118.0\n";
|
|
if (cmd === "codex login status") throw new Error("not logged in");
|
|
return "";
|
|
},
|
|
});
|
|
|
|
const result = await runSetup(deps, {});
|
|
|
|
const codexAuth = result.steps.find((s) => s.id === "codex_auth");
|
|
expect(codexAuth?.status).toBe("fail");
|
|
expect(result.ready).toBe(false);
|
|
});
|
|
|
|
it("does not fail Linux setup just because Homebrew is unavailable", async () => {
|
|
const deps = makeDeps({
|
|
platform: "linux",
|
|
exec: (cmd: string) => {
|
|
if (cmd === "herdr --version") return "herdr 0.9.3\n";
|
|
if (cmd === "tmux -V") return "tmux 3.4\n";
|
|
if (cmd === "tmux list-sessions") return "";
|
|
if (cmd === "cmux capabilities --json") throw new Error("not found");
|
|
if (cmd === "cmux --help") throw new Error("not found");
|
|
if (cmd === "claude --version") return "2.1.101 (Claude Code)\n";
|
|
if (cmd === "claude auth status") return "Authenticated\n";
|
|
if (cmd === "codex --version") return "codex-cli 0.118.0\n";
|
|
if (cmd === "codex login status") return "Logged in\n";
|
|
throw new Error(`unexpected: ${cmd}`);
|
|
},
|
|
});
|
|
|
|
const result = await runSetup(deps, {});
|
|
|
|
const brew = result.steps.find((s) => s.id === "brew");
|
|
expect(brew?.status).toBe("skipped");
|
|
expect(result.ready).toBe(true);
|
|
});
|
|
|
|
it("brew failure does not crash — later brew-dependent steps are skipped honestly", async () => {
|
|
const deps = makeDeps({
|
|
exec: (cmd: string) => {
|
|
if (cmd === "herdr --version") return "herdr 0.9.3\n";
|
|
if (cmd === "brew --version") throw new Error("command not found: brew");
|
|
if (cmd === "tmux -V") throw new Error("command not found: tmux");
|
|
if (cmd === "tmux list-sessions") throw new Error("command not found: tmux");
|
|
if (cmd === "cmux capabilities --json") throw new Error("not found");
|
|
if (cmd === "cmux --help") throw new Error("not found");
|
|
throw new Error(`unexpected: ${cmd}`);
|
|
},
|
|
});
|
|
const result = await runSetup(deps, {});
|
|
|
|
expect(result.ready).toBe(false);
|
|
|
|
const brew = result.steps.find((s) => s.id === "brew");
|
|
expect(brew?.status).toBe("fail");
|
|
|
|
const tmux = result.steps.find((s) => s.id === "tmux_install");
|
|
expect(tmux?.status).toBe("skipped");
|
|
|
|
const herdr = result.steps.find((s) => s.id === "herdr_install");
|
|
expect(herdr?.status).toBe("pass");
|
|
|
|
// verify step should reflect the failures
|
|
const verify = result.steps.find((s) => s.id === "verify");
|
|
expect(verify?.status).toBe("warn");
|
|
});
|
|
|
|
it("result includes verification section with real doctor check names including async cmux_daemon", async () => {
|
|
const deps = makeDeps();
|
|
const doctorDeps: DoctorDeps = {
|
|
exists: () => true,
|
|
baseDir: "/install/cli/dist",
|
|
exec: (cmd: string) => {
|
|
if (cmd === "tmux -V") return "tmux 3.4\n";
|
|
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
|
|
if (cmd === "cmux --help") return "cmux help\n";
|
|
return "";
|
|
},
|
|
checkPort: async () => true,
|
|
configStore: { resolve: () => ({ daemon: { port: 7433, host: "127.0.0.1" }, db: { path: "/tmp/openrig/openrig.sqlite" }, transcripts: { enabled: true, path: "/tmp/openrig/transcripts" } }) },
|
|
platform: "darwin",
|
|
readFile: () => null,
|
|
mkdirp: () => {},
|
|
checkWritable: () => {},
|
|
fetch: async () => { throw new Error("ECONNREFUSED"); },
|
|
};
|
|
|
|
const result = await runSetup(deps, { doctorDeps });
|
|
|
|
// verification section must exist
|
|
expect(result.verification).toBeDefined();
|
|
expect(Array.isArray(result.verification!.checks)).toBe(true);
|
|
|
|
const checkNames = result.verification!.checks.map((c) => c.name);
|
|
// Must include real doctor check names
|
|
expect(checkNames).toContain("node_version");
|
|
expect(checkNames).toContain("tmux");
|
|
expect(checkNames).toContain("cmux_shell");
|
|
// Must include async doctor check (cmux_daemon resolved)
|
|
expect(checkNames).toContain("cmux_daemon");
|
|
|
|
// cmux_daemon should be skipped (daemon not reachable)
|
|
const cmuxDaemon = result.verification!.checks.find((c) => c.name === "cmux_daemon");
|
|
expect(cmuxDaemon?.status).toBe("skipped");
|
|
|
|
// Doctor statuses used as-is, not renamed
|
|
const nodeCheck = result.verification!.checks.find((c) => c.name === "node_version");
|
|
expect(["pass", "warn", "fail", "skipped"]).toContain(nodeCheck?.status);
|
|
});
|
|
|
|
it("non-dry-run results include the same structured runtime config disclosure", async () => {
|
|
const deps = makeDeps();
|
|
|
|
const result = await runSetup(deps, {});
|
|
|
|
expectRuntimeConfigDisclosure(result);
|
|
});
|
|
|
|
it("ready is false only when setup steps or verification checks have fail status", async () => {
|
|
// All tools present, all doctor checks pass
|
|
const deps = makeDeps();
|
|
const doctorDeps: DoctorDeps = {
|
|
exists: () => true,
|
|
baseDir: "/install/cli/dist",
|
|
exec: (cmd: string) => {
|
|
if (cmd === "tmux -V") return "tmux 3.4\n";
|
|
if (cmd === "cmux capabilities --json") return '{"capabilities":["surface.focus"]}\n';
|
|
if (cmd === "cmux --help") return "cmux help\n";
|
|
return "";
|
|
},
|
|
checkPort: async () => true,
|
|
configStore: { resolve: () => ({ daemon: { port: 7433, host: "127.0.0.1" }, db: { path: "/tmp/openrig/openrig.sqlite" }, transcripts: { enabled: true, path: "/tmp/openrig/transcripts" } }) },
|
|
platform: "darwin",
|
|
readFile: () => null,
|
|
mkdirp: () => {},
|
|
checkWritable: () => {},
|
|
fetch: async () => { throw new Error("ECONNREFUSED"); },
|
|
};
|
|
|
|
const result = await runSetup(deps, { doctorDeps });
|
|
|
|
// No fail statuses in steps or verification -> ready=true
|
|
expect(result.ready).toBe(true);
|
|
|
|
// warn/skipped alone do not flip ready to false
|
|
const hasWarnOrSkipped = [
|
|
...result.steps,
|
|
...(result.verification?.checks ?? []),
|
|
].some((c) => c.status === "warn" || c.status === "skipped");
|
|
// cmux_daemon is skipped, so this should be true
|
|
expect(hasWarnOrSkipped).toBe(true);
|
|
// But ready is still true
|
|
expect(result.ready).toBe(true);
|
|
});
|
|
|
|
it("tmux install failure returns structured fail, does not crash setup", async () => {
|
|
const deps = makeDeps({
|
|
exec: (cmd: string) => {
|
|
if (cmd === "herdr --version") return "herdr 0.9.3\n";
|
|
if (cmd === "brew --version") return "Homebrew 4.0\n";
|
|
if (cmd === "tmux -V") throw new Error("not found");
|
|
if (cmd === "brew install tmux") throw new Error("brew install failed");
|
|
if (cmd === "cmux capabilities --json") return '{"capabilities":[]}\n';
|
|
return "";
|
|
},
|
|
});
|
|
const result = await runSetup(deps, {});
|
|
|
|
const tmux = result.steps.find((s) => s.id === "tmux_install");
|
|
expect(tmux?.status).toBe("fail");
|
|
expect(result.ready).toBe(false);
|
|
|
|
// Other steps still attempted
|
|
const herdr = result.steps.find((s) => s.id === "herdr_install");
|
|
expect(herdr).toBeDefined();
|
|
expect(herdr?.status).toBe("pass");
|
|
});
|
|
|
|
it("uses the extended timeout budget for npm runtime installs", async () => {
|
|
const seenTimeouts = new Map<string, number | undefined>();
|
|
let claudeInstalled = false;
|
|
let codexInstalled = false;
|
|
const deps = makeDeps({
|
|
exec: (cmd: string, opts?: { timeoutMs?: number }) => {
|
|
if (cmd === "herdr --version") return "herdr 0.9.3\n";
|
|
if (cmd === "brew --version") return "Homebrew 4.0\n";
|
|
if (cmd === "tmux -V") return "tmux 3.4\n";
|
|
if (cmd === "tmux list-sessions") return "";
|
|
if (cmd === "cmux capabilities --json") return '{"capabilities":[]}\n';
|
|
if (cmd === "claude --version") {
|
|
if (claudeInstalled) return "2.1.101 (Claude Code)\n";
|
|
throw new Error("command not found: claude");
|
|
}
|
|
if (cmd === "npm install -g @anthropic-ai/claude-code") {
|
|
seenTimeouts.set(cmd, opts?.timeoutMs);
|
|
claudeInstalled = true;
|
|
return "installed claude\n";
|
|
}
|
|
if (cmd === "claude auth status") return "Authenticated\n";
|
|
if (cmd === "codex --version") {
|
|
if (codexInstalled) return "codex-cli 0.118.0\n";
|
|
throw new Error("command not found: codex");
|
|
}
|
|
if (cmd === "npm install -g @openai/codex") {
|
|
seenTimeouts.set(cmd, opts?.timeoutMs);
|
|
codexInstalled = true;
|
|
return "installed codex\n";
|
|
}
|
|
if (cmd === "codex login status") return "Logged in\n";
|
|
throw new Error(`unexpected: ${cmd}`);
|
|
},
|
|
});
|
|
|
|
const result = await runSetup(deps, {});
|
|
|
|
expect(result.ready).toBe(true);
|
|
expect(seenTimeouts.get("npm install -g @anthropic-ai/claude-code")).toBe(300000);
|
|
expect(seenTimeouts.get("npm install -g @openai/codex")).toBe(300000);
|
|
});
|
|
});
|
|
|
|
// Slice-03 Lane B (0.4.8) onboarding RECORD path. RULING-C b4913ed4: the v1 menu EDITS/RECORDS into
|
|
// an EXISTING spec only (chosen -> permission_policy: builtin:<name>; deliberate-none ->
|
|
// permission_policy: none); NEW INSTALL = no spec = NOTHING WRITTEN = floor by absence. Persistence =
|
|
// the RigSpec permission_policy ONLY (P6 fence). Invariants: P3 write-on-explicit-selection-ONLY,
|
|
// P1 no absent->deliberate_none upgrade, P2 honest render.
|
|
describe("rig setup --policy (onboarding record)", () => {
|
|
const SPEC = "/tmp/onboard-demo/rig.yaml";
|
|
const BASE_SPEC = 'version: "1"\nname: demo-rig\npods: []\nedges: []\n';
|
|
|
|
function specDeps(initial: string | null, sink: Record<string, string>): SetupDeps {
|
|
return makeDeps({
|
|
readFile: (p: string) => (p === SPEC ? (p in sink ? sink[p] : initial) : null),
|
|
exists: (p: string) => p === SPEC && (initial !== null || p in sink),
|
|
writeFile: (p: string, c: string) => { sink[p] = c; },
|
|
});
|
|
}
|
|
|
|
it("--policy standard records permission_policy: builtin:standard into the existing spec (chosen builtin), least-destructively", async () => {
|
|
const sink: Record<string, string> = {};
|
|
const deps = specDeps(BASE_SPEC, sink);
|
|
|
|
const result = await runSetup(deps, { policy: "standard", specPath: SPEC });
|
|
|
|
const step = result.steps.find((s) => s.id === "policy_record");
|
|
expect(step?.status).toBe("applied");
|
|
expect(sink[SPEC]).toBeDefined();
|
|
const parsed = parseYaml(sink[SPEC]!) as Record<string, unknown>;
|
|
expect(parsed["permission_policy"]).toBe("builtin:standard");
|
|
// least-destructive: all other keys survive the record.
|
|
expect(parsed["name"]).toBe("demo-rig");
|
|
expect(parsed["version"]).toBe("1");
|
|
expect(parsed["pods"]).toEqual([]);
|
|
});
|
|
|
|
it("least-destructive record: preserves comment text/order/quoting/structure, appending ONLY the permission_policy line", async () => {
|
|
const sink: Record<string, string> = {};
|
|
// DISCRIMINATING fixture — a commented, hand-authored-style spec. A parse+serialize round-trip
|
|
// (the pre-revision impl) DROPS every comment here, so this pin is RED against that impl BY
|
|
// CONSTRUCTION; a comment-preserving edit reproduces the file byte-for-byte plus the one new line.
|
|
// SCOPE (honest API limit, probed at the lib): parseDocument preserves comment TEXT, key ORDER,
|
|
// QUOTING, and STRUCTURE — but pre-`#` padding may NORMALIZE (e.g. multiple spaces -> one). This
|
|
// fixture uses single-space padding (parseDocument's fixed point) so the byte-equality is exact;
|
|
// the claim is scoped accordingly, not "every byte of arbitrary formatting survives".
|
|
const commented =
|
|
"# hand-authored: do not clobber\n" +
|
|
'version: "1"\n' +
|
|
"name: demo-rig # the rig name\n" +
|
|
"pods: [] # no pods yet\n" +
|
|
"edges: []\n";
|
|
const deps = specDeps(commented, sink);
|
|
|
|
await runSetup(deps, { policy: "standard", specPath: SPEC });
|
|
|
|
const written = sink[SPEC]!;
|
|
// Everything survives byte-for-byte; the ONLY delta is the appended permission_policy line.
|
|
expect(written).toBe(commented + "permission_policy: builtin:standard\n");
|
|
});
|
|
|
|
it("maps each chosen built-in name to its builtin:<name> ref", async () => {
|
|
for (const name of ["locked", "standard", "open", "yolo"] as const) {
|
|
const sink: Record<string, string> = {};
|
|
const deps = specDeps(BASE_SPEC, sink);
|
|
await runSetup(deps, { policy: name, specPath: SPEC });
|
|
const parsed = parseYaml(sink[SPEC]!) as Record<string, unknown>;
|
|
expect(parsed["permission_policy"]).toBe(`builtin:${name}`);
|
|
}
|
|
});
|
|
|
|
it("--policy none records the deliberate-none value permission_policy: none (the WRITE is base-testable; the PARSE round-trip is cross-lane)", async () => {
|
|
const sink: Record<string, string> = {};
|
|
const deps = specDeps(BASE_SPEC, sink);
|
|
|
|
const result = await runSetup(deps, { policy: "none", specPath: SPEC });
|
|
|
|
const step = result.steps.find((s) => s.id === "policy_record");
|
|
expect(step?.status).toBe("applied");
|
|
const parsed = parseYaml(sink[SPEC]!) as Record<string, unknown>;
|
|
// Deliberate-none is the EXPLICIT recorded value (origin deliberate_none), NOT absence.
|
|
expect(parsed["permission_policy"]).toBe("none");
|
|
});
|
|
|
|
it("P3 + anchor 1: NO --policy => NO policy_record step, NO spec write (bare setup byte-unchanged)", async () => {
|
|
const sink: Record<string, string> = {};
|
|
const deps = specDeps(BASE_SPEC, sink);
|
|
|
|
const result = await runSetup(deps, {});
|
|
|
|
expect(result.steps.find((s) => s.id === "policy_record")).toBeUndefined();
|
|
// Nothing recorded into the spec on the skip/no-selection path.
|
|
expect(sink[SPEC]).toBeUndefined();
|
|
});
|
|
|
|
it("P1 + RULING-C: --policy with NO resolvable existing spec records NOTHING (fail step, no phantom write)", async () => {
|
|
const sink: Record<string, string> = {};
|
|
// No spec present anywhere: readFile null, exists false.
|
|
const deps = specDeps(null, sink);
|
|
|
|
const result = await runSetup(deps, { policy: "standard", specPath: SPEC });
|
|
|
|
const step = result.steps.find((s) => s.id === "policy_record");
|
|
expect(step?.status).toBe("fail");
|
|
expect(sink[SPEC]).toBeUndefined();
|
|
// A record failure must not silently pass as ready.
|
|
expect(result.ready).toBe(false);
|
|
});
|
|
|
|
it("rejects an unknown --policy name, records NOTHING", async () => {
|
|
const sink: Record<string, string> = {};
|
|
const deps = specDeps(BASE_SPEC, sink);
|
|
|
|
const result = await runSetup(deps, { policy: "bananas" as never, specPath: SPEC });
|
|
|
|
const step = result.steps.find((s) => s.id === "policy_record");
|
|
expect(step?.status).toBe("fail");
|
|
// The rejection surfaces the valid set to the operator (message + reason are what they see).
|
|
expect(`${step?.message ?? ""} ${step?.reason ?? ""}`).toMatch(/locked, standard, open, yolo, auto, none/);
|
|
expect(sink[SPEC]).toBeUndefined();
|
|
});
|
|
|
|
it("re-recording overwrites a prior permission_policy in place (no duplication)", async () => {
|
|
const sink: Record<string, string> = {};
|
|
const deps = specDeps('version: "1"\nname: demo-rig\npermission_policy: builtin:locked\npods: []\nedges: []\n', sink);
|
|
|
|
await runSetup(deps, { policy: "open", specPath: SPEC });
|
|
|
|
const written = sink[SPEC]!;
|
|
const parsed = parseYaml(written) as Record<string, unknown>;
|
|
expect(parsed["permission_policy"]).toBe("builtin:open");
|
|
// exactly one permission_policy key in the serialized form.
|
|
expect(written.match(/permission_policy:/g)?.length).toBe(1);
|
|
});
|
|
|
|
it("wires --policy and --spec options on the setup command", () => {
|
|
const cmd = setupCommand(makeDeps());
|
|
const longs = cmd.options.map((o) => o.long);
|
|
expect(longs).toContain("--policy");
|
|
expect(longs).toContain("--spec");
|
|
});
|
|
|
|
// CONVERGED @tip 909c33e2 — Lane A's amendment (05931d33, ruled form 5f37e40f) activated
|
|
// `permission_policy: none` as the recorded deliberate_none choice. The cross-lane round-trip is
|
|
// realized at the SANCTIONED ALTITUDE SPLIT (pre-approved by acting-orch), keeping the P6 fence the
|
|
// QA verified — the CLI never imports the daemon:
|
|
// - CLI WRITE half (asserted here): `--policy none` emits the ruled deliberate_none token
|
|
// `permission_policy: none` (not a builtin: ref, not an absent field).
|
|
// - daemon PARSE half: that exact form resolves to origin=deliberate_none, floor==absent, and
|
|
// NEVER reads a file — proven in packages/daemon/test/deliberate-none-amendment.test.ts.
|
|
// Forcing the parse assertion into this CLI file would require a daemon import — exactly the P6
|
|
// breach the split avoids.
|
|
it("cross-lane round-trip WRITE half: --policy none emits the ruled deliberate_none token (parse half proven daemon-side)", async () => {
|
|
const sink: Record<string, string> = {};
|
|
const deps = specDeps(BASE_SPEC, sink);
|
|
|
|
await runSetup(deps, { policy: "none", specPath: SPEC });
|
|
|
|
expect(sink[SPEC]).toContain("permission_policy: none\n");
|
|
const parsed = parseYaml(sink[SPEC]!) as Record<string, unknown>;
|
|
expect(parsed["permission_policy"]).toBe("none");
|
|
});
|
|
});
|
|
|
|
// Slice-03 Lane B onboarding MENU COPY. The 0.4.8 lineage has NO TUI, so the "menu" is calm-register
|
|
// narrative text presenting the choice. Copy is FROZEN (MENU-COPY-FROZEN-2026-08-04, founder-picked):
|
|
// verbatim labels, no "Operator" in copy, exact deliberate-none + skip-line phrasing, NO pre-selected
|
|
// default, Standard carries the ⭐ recommendation marker. P2: the render is honest (three-way).
|
|
describe("rig setup permission-policy menu copy (frozen)", () => {
|
|
it("renders the frozen top-level labels verbatim", () => {
|
|
const out = permissionPolicyMenuLines().join("\n");
|
|
expect(out).toContain("Policy Mode");
|
|
expect(out).toContain("YOLO Mode");
|
|
});
|
|
|
|
it("renders the deliberate-none and skip-line copy with the team default", () => {
|
|
const out = permissionPolicyMenuLines().join("\n");
|
|
expect(out).toContain("No policy — deliberate choice (recorded): the floor, without the team allowances");
|
|
expect(out).toContain("If you skip: nothing is recorded; team seats launch with the team default unless a seat choice or named Codex profile applies");
|
|
expect(out).toContain("Yes / No — keep the team default.");
|
|
expect(out).not.toContain("keep prompts");
|
|
});
|
|
|
|
it("marks Standard with the ⭐ recommendation marker and pre-selects nothing", () => {
|
|
const lines = permissionPolicyMenuLines();
|
|
const standardLine = lines.find((l) => l.includes("Standard"));
|
|
expect(standardLine).toBeDefined();
|
|
expect(standardLine!).toContain("⭐");
|
|
// NO pre-selected default (the ⭐ is a recommendation, not a selection).
|
|
expect(permissionPolicyMenuLines().join("\n")).not.toMatch(/\(default\)|\[selected\]|pre-selected/i);
|
|
});
|
|
|
|
it("never names 'Operator' in the copy (YOLO Mode is the user-facing label)", () => {
|
|
expect(permissionPolicyMenuLines().join("\n")).not.toContain("Operator");
|
|
});
|
|
|
|
it("holds the calm register — no editorializing / founder-internal wording", () => {
|
|
const out = permissionPolicyMenuLines().join("\n").toLowerCase();
|
|
for (const banned of ["treacherous", "dangerous", "yolo mode is reckless", "footgun"]) {
|
|
expect(out).not.toContain(banned);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Issue #194 — a Codex provider that does not use an OpenAI login.
|
|
describe("codex_auth — provider-aware Codex readiness (#194)", () => {
|
|
const CODEX_HOME = "/synthetic/codex-cli-home";
|
|
const BEDROCK_CONFIG = [
|
|
'model_provider = "bedrock-runtime-us"',
|
|
"",
|
|
"[model_providers.bedrock-runtime-us]",
|
|
'name = "Amazon Bedrock Runtime"',
|
|
'env_key = "AWS_BEARER_TOKEN_BEDROCK"',
|
|
'wire_api = "responses"',
|
|
"requires_openai_auth = false",
|
|
"",
|
|
].join("\n");
|
|
const TOKEN = "synthetic-bedrock-token-value";
|
|
|
|
function providerDeps(opts: {
|
|
config?: string | null;
|
|
env?: NodeJS.ProcessEnv;
|
|
loggedIn?: boolean;
|
|
codexInstalled?: boolean;
|
|
}) {
|
|
const commands: string[] = [];
|
|
const reads: string[] = [];
|
|
const base = makeDeps();
|
|
const deps = makeDeps({
|
|
env: opts.env ?? { HOME: "/synthetic/home", CODEX_HOME },
|
|
readFile: (p: string) => {
|
|
reads.push(p);
|
|
return p === `${CODEX_HOME}/config.toml` ? (opts.config ?? null) : null;
|
|
},
|
|
exec: (cmd: string) => {
|
|
commands.push(cmd);
|
|
if (cmd === "codex --version" && opts.codexInstalled === false) throw new Error("codex: command not found");
|
|
if (cmd === "npm install -g @openai/codex" && opts.codexInstalled === false) throw new Error("install refused in test");
|
|
if (cmd === "codex login status" && !opts.loggedIn) throw new Error("Not logged in");
|
|
return base.exec(cmd);
|
|
},
|
|
});
|
|
return { deps, commands, reads };
|
|
}
|
|
|
|
const codexAuthStep = (result: SetupResult) => result.steps.find((s) => s.id === "codex_auth");
|
|
|
|
it("passes the explicit Bedrock provider from the selected CODEX_HOME without an OpenAI login", async () => {
|
|
const { deps, commands, reads } = providerDeps({
|
|
config: BEDROCK_CONFIG,
|
|
env: { HOME: "/synthetic/home", CODEX_HOME, AWS_BEARER_TOKEN_BEDROCK: TOKEN },
|
|
loggedIn: false,
|
|
});
|
|
const result = await runSetup(deps, {});
|
|
const step = codexAuthStep(result);
|
|
expect(step?.status).toBe("pass");
|
|
expect(step?.message).toContain("AWS_BEARER_TOKEN_BEDROCK");
|
|
expect(step?.message).toContain("not that the provider accepts it");
|
|
expect(JSON.stringify(result)).not.toContain(TOKEN);
|
|
expect(commands).not.toContain("codex login status");
|
|
expect(reads).toContain(`${CODEX_HOME}/config.toml`);
|
|
expect(reads).not.toContain("/synthetic/home/.codex/config.toml");
|
|
});
|
|
|
|
it("fails with the provider's variable named, never its value, when the token is missing or blank", async () => {
|
|
for (const env of [
|
|
{ HOME: "/synthetic/home", CODEX_HOME },
|
|
{ HOME: "/synthetic/home", CODEX_HOME, AWS_BEARER_TOKEN_BEDROCK: " " },
|
|
]) {
|
|
const { deps, commands } = providerDeps({ config: BEDROCK_CONFIG, env, loggedIn: true });
|
|
const result = await runSetup(deps, {});
|
|
const step = codexAuthStep(result);
|
|
expect(step?.status).toBe("fail");
|
|
expect(step?.message).toContain("AWS_BEARER_TOKEN_BEDROCK");
|
|
expect(step?.fixHint).toContain("Export AWS_BEARER_TOKEN_BEDROCK");
|
|
expect(step?.fixHint).not.toContain("codex login");
|
|
expect(result.ready).toBe(false);
|
|
expect(commands).not.toContain("codex login status");
|
|
}
|
|
});
|
|
|
|
it("keeps the OpenAI login check for the default provider and for providers that require it", async () => {
|
|
const requiresLogin = BEDROCK_CONFIG.replace("requires_openai_auth = false", "requires_openai_auth = true");
|
|
for (const config of [null, 'model_provider = "openai"\n', requiresLogin]) {
|
|
const env = { HOME: "/synthetic/home", CODEX_HOME, AWS_BEARER_TOKEN_BEDROCK: TOKEN };
|
|
const ok = providerDeps({ config, env, loggedIn: true });
|
|
expect(codexAuthStep(await runSetup(ok.deps, {}))?.status).toBe("pass");
|
|
expect(ok.commands).toContain("codex login status");
|
|
|
|
const notLoggedIn = providerDeps({ config, env, loggedIn: false });
|
|
const step = codexAuthStep(await runSetup(notLoggedIn.deps, {}));
|
|
expect(step?.status).toBe("fail");
|
|
expect(step?.fixHint).toContain("codex login");
|
|
}
|
|
});
|
|
|
|
it("reports a malformed-config explanation when the fallback OpenAI login fails", async () => {
|
|
const env = { HOME: "/synthetic/home", CODEX_HOME, AWS_BEARER_TOKEN_BEDROCK: TOKEN };
|
|
const { deps } = providerDeps({ config: "model_provider = [broken", env, loggedIn: false });
|
|
const step = codexAuthStep(await runSetup(deps, {}));
|
|
expect(step?.status).toBe("fail");
|
|
expect(step?.message).toContain("config.toml could not be parsed");
|
|
});
|
|
|
|
it("skips the provider check when the Codex executable is missing", async () => {
|
|
const { deps } = providerDeps({
|
|
config: BEDROCK_CONFIG,
|
|
env: { HOME: "/synthetic/home", CODEX_HOME, AWS_BEARER_TOKEN_BEDROCK: TOKEN },
|
|
codexInstalled: false,
|
|
});
|
|
const result = await runSetup(deps, {});
|
|
expect(result.steps.find((s) => s.id === "codex_install")?.status).toBe("fail");
|
|
expect(codexAuthStep(result)?.status).toBe("skipped");
|
|
});
|
|
});
|
|
|
|
describe("--providers: a provider the person didn't choose reads not selected, never failed (0.6.9 F3)", () => {
|
|
const base = makeDeps().exec;
|
|
/** A machine with Claude Code signed in and Codex neither installed nor installable. */
|
|
function claudeOnlyMachine(execs: string[], claudeAuth = true): SetupDeps {
|
|
return makeDeps({
|
|
exec: (cmd: string) => {
|
|
execs.push(cmd);
|
|
if (cmd.startsWith("codex") || cmd.includes("@openai/codex")) throw new Error("codex unavailable");
|
|
if (cmd === "claude auth status" && !claudeAuth) throw new Error("not logged in");
|
|
return base(cmd);
|
|
},
|
|
});
|
|
}
|
|
|
|
it("leaves Codex out when the person chose Claude: not installed, not checked, and setup is ready", async () => {
|
|
const execs: string[] = [];
|
|
const result = await runSetup(claudeOnlyMachine(execs), { providers: ["claude"] });
|
|
expect(result.steps.find((s) => s.id === "codex_install")).toMatchObject({ status: "skipped", message: expect.stringContaining("Not selected") });
|
|
expect(result.steps.find((s) => s.id === "codex_auth")).toMatchObject({ status: "skipped", message: expect.stringContaining("Not selected") });
|
|
expect(execs.filter((cmd) => cmd.includes("codex"))).toEqual([]);
|
|
expect(result.ready).toBe(true);
|
|
});
|
|
|
|
it("without --providers still installs and checks both, so the same machine reads failed for Codex", async () => {
|
|
const result = await runSetup(claudeOnlyMachine([]), {});
|
|
expect(result.steps.find((s) => s.id === "codex_install")?.status).toBe("fail");
|
|
expect(result.ready).toBe(false);
|
|
});
|
|
|
|
it("still reads failed for a provider the person chose", async () => {
|
|
const result = await runSetup(claudeOnlyMachine([], false), { providers: ["claude"] });
|
|
expect(result.steps.find((s) => s.id === "claude_auth")?.status).toBe("fail");
|
|
expect(result.ready).toBe(false);
|
|
});
|
|
|
|
it("names a provider left out in the dry-run plan", async () => {
|
|
const result = await runSetup(makeDeps(), { dryRun: true, providers: ["codex"] });
|
|
expect(result.steps.find((s) => s.id === "claude_install")?.message).toContain("Not selected");
|
|
expect(result.steps.find((s) => s.id === "codex_install")?.message).toContain("would be attempted");
|
|
});
|
|
|
|
it("records the choice as provider.selected, in a fixed order, so kernel boot can honour it", async () => {
|
|
const recordProviders = vi.fn();
|
|
const result = await runSetup(makeDeps({ recordProviders }), { providers: ["codex", "claude"] });
|
|
expect(recordProviders.mock.calls).toEqual([["claude,codex"]]);
|
|
expect(result.steps.find((s) => s.id === "providers_record")).toMatchObject({ status: "applied", message: expect.stringContaining("provider.selected=claude,codex") });
|
|
expect(result.steps.find((s) => s.id === "providers_record")?.message).toContain("a kernel that already exists keeps its seats");
|
|
const overridden = await runSetup(makeDeps({ recordProviders, env: { ...process.env, OPENRIG_PROVIDER_SELECTED: "codex" } }), { providers: ["claude"] });
|
|
expect(overridden.steps.find((s) => s.id === "providers_record")?.message).toContain("OPENRIG_PROVIDER_SELECTED=codex in this environment takes precedence");
|
|
});
|
|
|
|
it("records nothing without --providers or on a dry run", async () => {
|
|
const recordProviders = vi.fn();
|
|
const bare = await runSetup(makeDeps({ recordProviders }), {});
|
|
expect(bare.steps.find((s) => s.id === "providers_record")).toBeUndefined();
|
|
const plan = await runSetup(makeDeps({ recordProviders }), { dryRun: true, providers: ["codex"] });
|
|
expect(plan.steps.find((s) => s.id === "providers_record")).toMatchObject({ status: "skipped", message: "Dry run: would record provider.selected=codex." });
|
|
expect(recordProviders).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("a choice that can't be saved is a warning with the command to save it, and setup stays ready", async () => {
|
|
const deps = { ...claudeOnlyMachine([]), recordProviders: () => { throw new Error("config is read-only"); } };
|
|
const result = await runSetup(deps, { providers: ["claude"] });
|
|
expect(result.steps.find((s) => s.id === "providers_record")).toMatchObject({
|
|
status: "warn",
|
|
message: expect.stringContaining("config is read-only"),
|
|
fixHint: "Run rig config set provider.selected claude.",
|
|
});
|
|
expect(result.ready).toBe(true);
|
|
});
|
|
|
|
it("parses the person's choice and rejects an unknown provider", async () => {
|
|
expect(parseSetupProviders("claude")).toEqual(["claude"]);
|
|
expect(parseSetupProviders("both")).toEqual(["claude", "codex"]);
|
|
expect(parseSetupProviders(" Codex , claude ")).toEqual(["codex", "claude"]);
|
|
expect(parseSetupProviders("gemini")).toEqual({ error: expect.stringContaining("Unknown provider 'gemini'") });
|
|
expect(parseSetupProviders(",")).toEqual({ error: expect.stringContaining("needs claude, codex, or both") });
|
|
const errors: string[] = [];
|
|
vi.spyOn(console, "error").mockImplementation((...args) => { errors.push(args.map(String).join(" ")); });
|
|
process.exitCode = undefined;
|
|
await new Command().addCommand(setupCommand(makeDeps())).parseAsync(["node", "rig", "setup", "--providers", "gemini"]);
|
|
expect(process.exitCode).toBe(1);
|
|
expect(errors.join("\n")).toContain("Unknown provider 'gemini'");
|
|
process.exitCode = undefined;
|
|
vi.restoreAllMocks();
|
|
});
|
|
});
|
|
|
|
describe("rig setup: the router-line question", () => {
|
|
let dir: string;
|
|
let store: ConfigStore;
|
|
beforeEach(() => {
|
|
vi.stubEnv("OPENRIG_SKILLS_ROUTER_LINE_ENABLED", "");
|
|
delete process.env["OPENRIG_SKILLS_ROUTER_LINE_ENABLED"];
|
|
dir = mkdtempSync(join(tmpdir(), "setup-router-line-"));
|
|
store = new ConfigStore(join(dir, "config.json"));
|
|
});
|
|
afterEach(() => { vi.unstubAllEnvs(); rmSync(dir, { recursive: true, force: true }); });
|
|
|
|
const routerStep = (result: SetupResult) => result.steps.find((s) => s.id === "router_line");
|
|
|
|
it("shows the exact line and files, and an unanswered question isn't a yes: nothing is saved", async () => {
|
|
const set = vi.spyOn(store, "set");
|
|
const result = await runSetup(makeDeps({ routerLineStore: store }), {});
|
|
expect(routerStep(result)).toMatchObject({ status: "skipped", message: expect.stringContaining("it isn't a yes"), fixHint: "rig setup --router-line yes, or rig setup --router-line no" });
|
|
expect(result.routerLine).toMatchObject({ answer: "unasked", files: expect.stringContaining("AGENTS.md"), limit: expect.stringContaining("doesn't remove a line already written") });
|
|
expect(result.routerLine.line).toContain("rig skill loadout --seat self");
|
|
expect(result.routerLine.ifNo).toContain("Nothing is written into these files");
|
|
expect(set).not.toHaveBeenCalled();
|
|
expect(store.resolveWithSource("skills.router_line.enabled").source).toBe("default");
|
|
});
|
|
|
|
it("shows the exact text written, with OpenRig's markers, and prints it as written", async () => {
|
|
const result = await runSetup(makeDeps({ routerLineStore: store }), {});
|
|
const { written, line } = result.routerLine;
|
|
expect(written).toBe(`<!-- BEGIN OpenRig MANAGED BLOCK: openrig-seat-skills.md -->\n${line}\n\n<!-- END OpenRig MANAGED BLOCK: openrig-seat-skills.md -->`);
|
|
expect(result.routerLine.placement).toContain("after two newlines");
|
|
const printed = routerLineLines(result.routerLine);
|
|
expect(printed[1]).toContain("plus OpenRig's markers so it can be removed cleanly");
|
|
const from = printed.indexOf(" Written exactly:") + 1;
|
|
expect(printed.slice(from, from + written.split("\n").length).map((row) => row.slice(4)).join("\n")).toBe(written);
|
|
});
|
|
|
|
it.each([["yes", true], ["no", false]] as const)("saves --router-line %s and reads it back as the person's answer", async (choice, value) => {
|
|
const result = await runSetup(makeDeps({ routerLineStore: store }), { routerLine: choice });
|
|
expect(store.resolveWithSource("skills.router_line.enabled")).toMatchObject({ value, source: "file" });
|
|
expect(routerStep(result)).toMatchObject({ status: "applied", message: expect.stringContaining(`Saved: ${choice}.`) });
|
|
expect(result.routerLine).toMatchObject({ answer: choice, by: "saved" });
|
|
// A later bare run reports the saved answer without asking again.
|
|
const again = await runSetup(makeDeps({ routerLineStore: store }), {});
|
|
expect(routerStep(again)).toMatchObject({ status: "pass", message: `Saved answer: ${choice}.` });
|
|
});
|
|
|
|
it("a dry run saves nothing, and reports a saved answer as skipped like every dry-run step", async () => {
|
|
const result = await runSetup(makeDeps({ routerLineStore: store }), { dryRun: true, routerLine: "yes" });
|
|
expect(routerStep(result)).toMatchObject({ status: "skipped", message: "Dry run: would save your router-line answer: yes." });
|
|
expect(store.resolveWithSource("skills.router_line.enabled").source).toBe("default");
|
|
store.set("skills.router_line.enabled", "true");
|
|
const saved = await runSetup(makeDeps({ routerLineStore: store }), { dryRun: true });
|
|
expect(routerStep(saved)).toMatchObject({ status: "skipped", message: "Dry run: Saved answer: yes." });
|
|
});
|
|
|
|
it("says when the environment overrides the saved answer", async () => {
|
|
process.env["OPENRIG_SKILLS_ROUTER_LINE_ENABLED"] = "false";
|
|
const result = await runSetup(makeDeps({ routerLineStore: store }), { routerLine: "yes" });
|
|
expect(routerStep(result)).toMatchObject({ status: "warn", message: expect.stringContaining("overrides it (no)") });
|
|
expect(result.routerLine).toMatchObject({ answer: "no", by: "environment" });
|
|
});
|
|
|
|
it("refuses an answer other than yes or no before running setup", async () => {
|
|
const errors: string[] = [];
|
|
vi.spyOn(console, "error").mockImplementation((...args) => { errors.push(args.map(String).join(" ")); });
|
|
process.exitCode = undefined;
|
|
await new Command().addCommand(setupCommand(makeDeps({ routerLineStore: store }))).parseAsync(["node", "rig", "setup", "--router-line", "maybe"]);
|
|
expect(process.exitCode).toBe(1);
|
|
expect(errors.join("\n")).toContain('--router-line takes yes or no, got "maybe".');
|
|
process.exitCode = undefined;
|
|
vi.restoreAllMocks();
|
|
});
|
|
});
|