feat(cli): cross-host rig send/capture via --host (CLI-side ssh shell-out)

Add a v0 narrow MVP for the Cross-Host Rig Commands primitive: declare
remote hosts in `~/.openrig/hosts.yaml` and route `rig send` or
`rig capture` to a declared host via single-hop ssh. CLI-side shell-out
only — the local daemon is not involved in routing, and there is no
daemon-to-daemon networking.

Load-bearing identity rule: SSH success is NOT verify success. The
remote rig is authoritative on `--verify`; its `Verified: yes/no` line
is surfaced verbatim and is never collapsed into the SSH exit code.

New modules (CLI):

- `host-registry.ts` — read-only loader/validator for `hosts.yaml`.
  Schema: `hosts: [{ id, transport, target, user?, notes? }]`. v0
  validates required fields, unique ids, and refuses non-`ssh`
  transport with a v0-scope message. Missing-file error names the
  canonical path.
- `cross-host-executor.ts` — `runCrossHostCommand(host, argv, opts)`
  with pluggable spawn for testing. Returns a structured
  `CrossHostResult` whose `failedStep` enum maps each dossier failure
  mode to a distinct value:
  * `ssh-unreachable` — ssh exit 255 / spawn error with no auth signal.
  * `permission-gate` — ssh exit 255 with Permission/Keychain/host-key
    pattern; includes a hint to the keychain-over-SSH field note.
  * `remote-daemon-unreachable` — ssh succeeded; remote rig stderr
    matched "Daemon not running" / "Failed to fetch ... from daemon".
  * `remote-command-failed` — ssh succeeded; remote rig exited
    non-zero for any other reason.
- `cross-host-cli-helpers.ts` — shared `emitCrossHostError` /
  `emitCrossHostFailure` so `send` and `capture` produce identical
  operator-actionable error formats.

Modified commands:

- `send.ts` — adds `--host <id>` option. When set, short-circuits
  before the daemon path: loads registry, resolves id, reconstructs
  argv (`["rig","send",session,text,...flags]`), runs via the
  executor. Annotates output with `[via host=<id> (<target>)]`; JSON
  output wraps `{ cross_host: {host, target}, result }`. SendDeps adds
  optional `hostRegistryLoader` and `crossHostRun` injection points.
- `capture.ts` — same shape with capture's argv (`["rig","capture",
  session?,...flags]` forwarding `--rig`, `--pod`, `--lines`,
  `--json`).

Out of scope for v0 (per slice IMPL): non-SSH transports; `--host` on
`rig ps` / `rig whoami` / other commands; connection pooling;
multi-hop SSH; cross-host queue routing; cross-host seat handover; any
`rig host` sub-namespace; auto-generated or auto-modified `hosts.yaml`.
The daemon is untouched — `packages/daemon/` has no changes in this
slice.

Tests (Tier 1, in-package mocks only — no real ssh, no real network,
no `~/.ssh/` reads, no `hosts.yaml` outside `mktempSync` temp paths):

- `host-registry.test.ts` — 19 cases: accept/reject schema rules,
  duplicate-id detection, transport refusal, malformed YAML, unknown
  id discoverability, empty-registry honesty.
- `cross-host-executor.test.ts` — 18 cases: `shellQuote`; full
  `classifyResult` exit-code → failedStep table; argv construction
  (`-l <user>`, `ConnectTimeout=N`); happy path; **--verify honest
  pass-through**; permission-gate detection; transport defense-in-
  depth; stdin forwarding; custom connect timeout.
- `cross-host-commands.test.ts` — 20 cases: send + capture short-
  circuit, argv reconstruction, **--verify honesty in command path**,
  flag forwarding, JSON envelope shape, each of the 4 failure modes
  surfacing operator-actionable errors with field-note hints, compat
  regression (no `--host` ⇒ neither registry loader nor executor is
  called).

Doc patches: `cli-reference.md` documents `--host` on send + capture
plus a new "Cross-host execution (`--host <id>`)" subsection (registry
shape, validation rules, four `failedStep` values, v0 out-of-scope
list). `architecture.md` adds a one-line note that `--host` is CLI-
side shell-out.

Tier 1.5 (ssh localhost loopback) skipped — not part of standard dev-
host config; recorded as a deferred follow-up. Tier 2 (real disposable
Tart VM proof against an isolated remote rig) deferred per planner
recommendation with explicit `human-gate: tart-vm-cross-host-proof`
annotation.

Slice: openrig-work/missions/recursive-self-improvement-v2/primitive-hardening/slices/cross-host-rig-commands/
Driver checkpoint: shared-docs rigs/openrig-velocity-claude/state/orch/velocity-cross-host-rig-commands-driver-checkpoint-20260430.md
Guard accept:      shared-docs rigs/openrig-velocity-claude/state/orch/velocity-cross-host-rig-commands-guard-checkpoint-approve-20260430.md

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-04-29 22:31:40 -07:00
1 parent c7b6df192f
commit cdce3a6864
10 files changed
+1372 -14

No files matched your search

+2 -2
View File
@@ -146,8 +146,8 @@ Important route behaviors:
- `openrig-user` now teaches the shipped managed-app model (`software + specialist`) and cross-rig specialist interaction
**Communication and history commands:**
- `rig send <session> "message" [--verify] [--force]` — send to agent terminal
- `rig capture <session> [--lines N] [--json]` — capture agent pane content
- `rig send <session> "message" [--verify] [--force] [--host <id>]` — send to agent terminal; `--host` routes to a host declared in `~/.openrig/hosts.yaml` via single-hop ssh (CLI-side shell-out; daemon untouched). SSH success is NOT verify success — the remote rig's verify result is surfaced verbatim.
- `rig capture <session> [--lines N] [--json] [--host <id>]` — capture agent pane content; `--host` same shape as `rig send --host`.
- `rig broadcast --rig <name> "message"` — multi-agent broadcast
- `rig transcript <session> --tail N / --grep "pattern" [--json]` — transcript access
- `rig config [get <key> / set <key> <value> / reset <key>] [--json]` — configuration surface
+49 -4
View File
@@ -493,23 +493,68 @@ Notes:
### `rig send`
Usage: `rig send <session> <text> [--verify] [--force] [--json]`
Usage: `rig send <session> <text> [--verify] [--force] [--wait-for-idle <s>] [--host <id>] [--json]`
Notes:
- Uses the two-step send pattern automatically: paste text, wait, submit Enter.
- `--verify` requests delivery verification.
- `--force` overrides mid-task safety checks.
- `--host <id>` routes the same command to a remote host declared in `~/.openrig/hosts.yaml` via single-hop ssh; see "Cross-host execution" below. SSH success is NOT verify success — the remote rig's `Verified: yes/no` is what counts and is surfaced verbatim.
### `rig capture`
Usage:
- `rig capture <session> [--lines <n>] [--json]`
- `rig capture --rig <name> [--lines <n>] [--json]`
- `rig capture --pod <name> --rig <name> [--lines <n>] [--json]`
- `rig capture <session> [--lines <n>] [--host <id>] [--json]`
- `rig capture --rig <name> [--lines <n>] [--host <id>] [--json]`
- `rig capture --pod <name> --rig <name> [--lines <n>] [--host <id>] [--json]`
Default:
- `--lines 20`
Notes:
- `--host <id>` routes the same command to a remote host declared in `~/.openrig/hosts.yaml` via single-hop ssh; see "Cross-host execution" below.
### Cross-host execution (`--host <id>`)
`rig send` and `rig capture` accept a `--host <id>` flag that routes the
command to a remote host over single-hop SSH. v0 is CLI-side shell-out
only — the local daemon is not involved in routing, and there is no
daemon-to-daemon networking. The remote host is expected to have its own
managed `rig` available on `$PATH`.
Hosts are declared by the operator in `~/.openrig/hosts.yaml`:
```yaml
hosts:
- id: vm-claude-test
transport: ssh
target: vm-claude-test.local
user: wrandom # optional
notes: "Tart VM" # optional
- id: laptop-b
transport: ssh
target: laptop-b.tail-scale-net
user: wrandom
```
Validation rules:
- `hosts` is required and must be a non-null array.
- Each entry: `id` required (non-empty, unique), `transport` required (v0 supports `ssh` only), `target` required (non-empty — DNS name, SSH config alias, or IP).
- `user` and `notes` are optional.
- The file is operator-managed; v0 does NOT include any sub-command to add, remove, or list hosts (operators edit the YAML directly).
- A missing or invalid file returns a clear error pointing at the canonical path.
The CLI distinguishes four structured failure modes (operators get an
actionable error per mode; JSON output preserves the `failedStep` enum):
- `ssh-unreachable` — SSH itself failed (connection refused, host key mismatch, etc.). Verify SSH access and the registry entry.
- `permission-gate` — SSH hit an auth/permission gate (Permission denied, Keychain). The error includes a hint to the keychain-over-SSH field note.
- `remote-daemon-unreachable` — SSH succeeded but the remote `rig` reported the remote daemon was not reachable. Start it with `ssh <target> rig daemon start`.
- `remote-command-failed` — SSH succeeded but the remote `rig` exited non-zero for some other reason; the remote stderr is surfaced.
Out of scope for v0: non-SSH transports; `--host` on `rig ps` / `rig whoami` / other commands; connection pooling; multi-hop SSH; cross-host queue routing; cross-host seat handover.
### `rig broadcast`
Usage: `rig broadcast <text> [--rig <name>] [--pod <name>] [--force] [--json]`
+77 -4
View File
@@ -3,10 +3,22 @@ import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { loadHostRegistry, resolveHost } from "../host-registry.js";
import { runCrossHostCommand, type RunCrossHostCommandOpts } from "../cross-host-executor.js";
import { emitCrossHostError, emitCrossHostFailure } from "../cross-host-cli-helpers.js";
export function captureCommand(depsOverride?: StatusDeps): Command {
export interface CaptureDeps extends StatusDeps {
hostRegistryLoader?: () => ReturnType<typeof loadHostRegistry>;
crossHostRun?: (
host: Parameters<typeof runCrossHostCommand>[0],
argv: readonly string[],
opts?: RunCrossHostCommandOpts,
) => ReturnType<typeof runCrossHostCommand>;
}
export function captureCommand(depsOverride?: CaptureDeps): Command {
const cmd = new Command("capture").description("Capture terminal output from agent sessions");
const getDeps = (): StatusDeps => depsOverride ?? {
const getDeps = (): CaptureDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
@@ -16,6 +28,7 @@ export function captureCommand(depsOverride?: StatusDeps): Command {
.option("--rig <name>", "Capture all sessions in a rig")
.option("--pod <name>", "Capture all sessions in a pod")
.option("--lines <n>", "Number of lines to capture (default: 20)", "20")
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml (CLI-side ssh shell-out)")
.option("--json", "JSON output for agents")
.addHelpText("after", `
Examples:
@@ -24,12 +37,22 @@ Examples:
rig capture --rig my-rig
rig capture --pod dev --rig my-rig
rig capture --rig my-rig --json
rig capture --host vm-claude-test dev-impl@my-rig --lines 50
Supported notes:
- Multi-target capture reports unsupported external_cli nodes as explicit per-target failures.
- For outbound-only external_cli nodes, use rig whoami/rig ps instead of rig capture.`)
.action(async (session: string | undefined, opts: { rig?: string; pod?: string; lines?: string; json?: boolean }) => {
- For outbound-only external_cli nodes, use rig whoami/rig ps instead of rig capture.
- --host runs the same command on a remote host declared in ~/.openrig/hosts.yaml
via single-hop ssh. The remote rig is authoritative on what it can capture.`)
.action(async (session: string | undefined, opts: { rig?: string; pod?: string; lines?: string; host?: string; json?: boolean }) => {
const deps = getDeps();
// --- Cross-host short-circuit (CLI-side ssh shell-out; daemon untouched) ---
if (opts.host) {
await runCrossHostCapture(opts.host, session, opts, deps);
return;
}
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
@@ -84,3 +107,53 @@ Supported notes:
return cmd;
}
async function runCrossHostCapture(
hostId: string,
session: string | undefined,
opts: { rig?: string; pod?: string; lines?: string; json?: boolean },
deps: CaptureDeps,
): Promise<void> {
const loader = deps.hostRegistryLoader ?? loadHostRegistry;
const runner = deps.crossHostRun ?? runCrossHostCommand;
const registry = loader();
if (!registry.ok) {
emitCrossHostError(hostId, "registry-load-failed", registry.error, opts.json);
return;
}
const resolved = resolveHost(registry.registry, hostId);
if (!resolved.ok) {
emitCrossHostError(hostId, "unknown-host", resolved.error, opts.json);
return;
}
const host = resolved.host;
// Reconstruct argv. Order: positional first, then flags.
const argv: string[] = ["rig", "capture"];
if (session) argv.push(session);
if (opts.rig) argv.push("--rig", opts.rig);
if (opts.pod) argv.push("--pod", opts.pod);
if (opts.lines !== undefined) argv.push("--lines", opts.lines);
if (opts.json) argv.push("--json");
const result = await runner(host, argv);
if (opts.json) {
console.log(JSON.stringify({
cross_host: { host: host.id, target: host.target },
result,
}));
if (!result.ok) process.exitCode = 1;
return;
}
console.log(`[via host=${host.id} (${host.target})]`);
if (result.ok) {
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
return;
}
emitCrossHostFailure(host.id, host.target, result, opts.json);
}
+84 -4
View File
@@ -3,12 +3,29 @@ import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { loadHostRegistry, resolveHost } from "../host-registry.js";
import { runCrossHostCommand, type RunCrossHostCommandOpts } from "../cross-host-executor.js";
import { emitCrossHostError, emitCrossHostFailure } from "../cross-host-cli-helpers.js";
const WAIT_FOR_IDLE_REQUEST_OVERHEAD_MS = 5_000;
export function sendCommand(depsOverride?: StatusDeps): Command {
export interface SendDeps extends StatusDeps {
/**
* Cross-host hooks. Both default to the production loaders/executors; tests
* inject in-package mocks so no real ssh / no real ~/.ssh / no real network
* is touched.
*/
hostRegistryLoader?: () => ReturnType<typeof loadHostRegistry>;
crossHostRun?: (
host: Parameters<typeof runCrossHostCommand>[0],
argv: readonly string[],
opts?: RunCrossHostCommandOpts,
) => ReturnType<typeof runCrossHostCommand>;
}
export function sendCommand(depsOverride?: SendDeps): Command {
const cmd = new Command("send").description("Send a message to an agent's terminal");
const getDeps = (): StatusDeps => depsOverride ?? {
const getDeps = (): SendDeps => depsOverride ?? {
lifecycleDeps: realDeps(),
clientFactory: (url: string) => new DaemonClient(url),
};
@@ -19,6 +36,7 @@ export function sendCommand(depsOverride?: StatusDeps): Command {
.option("--verify", "Verify pane only delivery by checking content after send")
.option("--force", "Send even if target pane appears mid-task")
.option("--wait-for-idle <seconds>", "Wait until the target is explicitly idle before sending")
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml (CLI-side ssh shell-out)")
.option("--json", "JSON output for agents")
.addHelpText("after", `
Examples:
@@ -27,13 +45,18 @@ Examples:
rig send dev-impl@my-rig "safe proof prompt" --wait-for-idle 30 --verify
rig send dev-impl@my-rig "Stop and read the spec." --force
rig send dev-impl@my-rig "message" --json
rig send --host vm-claude-test dev-impl@my-rig "remote message" --verify
The two-step send pattern (paste text, wait, submit Enter) is handled
automatically. Use --wait-for-idle to send only after explicit idle evidence;
it fails closed on attention prompts, unknown activity, or timeout. Use --verify
to confirm the message appeared in the pane only; it is not agent acknowledgement.
Use --force to override mid-task safety checks without wait mode.`)
.action(async (session: string, text: string, opts: { verify?: boolean; force?: boolean; waitForIdle?: string; json?: boolean }) => {
Use --force to override mid-task safety checks without wait mode.
--host runs the same command on a remote host declared in ~/.openrig/hosts.yaml
via single-hop ssh. SSH success is NOT verify success: the remote rig's
'Verified: yes/no' line is what counts and is surfaced verbatim.`)
.action(async (session: string, text: string, opts: { verify?: boolean; force?: boolean; waitForIdle?: string; host?: string; json?: boolean }) => {
const waitForIdleMs = parseWaitForIdleMs(opts.waitForIdle);
if (opts.force && waitForIdleMs !== undefined) {
console.error("--wait-for-idle cannot be combined with --force");
@@ -47,6 +70,13 @@ Use --force to override mid-task safety checks without wait mode.`)
}
const deps = getDeps();
// --- Cross-host short-circuit (CLI-side ssh shell-out; daemon untouched) ---
if (opts.host) {
await runCrossHostSend(opts.host, session, text, opts, deps);
return;
}
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
@@ -83,6 +113,56 @@ Use --force to override mid-task safety checks without wait mode.`)
return cmd;
}
async function runCrossHostSend(
hostId: string,
session: string,
text: string,
opts: { verify?: boolean; force?: boolean; waitForIdle?: string; json?: boolean },
deps: SendDeps,
): Promise<void> {
const loader = deps.hostRegistryLoader ?? loadHostRegistry;
const runner = deps.crossHostRun ?? runCrossHostCommand;
const registry = loader();
if (!registry.ok) {
emitCrossHostError(hostId, "registry-load-failed", registry.error, opts.json);
return;
}
const resolved = resolveHost(registry.registry, hostId);
if (!resolved.ok) {
emitCrossHostError(hostId, "unknown-host", resolved.error, opts.json);
return;
}
const host = resolved.host;
// Reconstruct argv for the remote `rig send` invocation. Order is positional
// first so the remote Commander parses it the same way local does.
const argv: string[] = ["rig", "send", session, text];
if (opts.verify) argv.push("--verify");
if (opts.force) argv.push("--force");
if (opts.waitForIdle !== undefined) argv.push("--wait-for-idle", opts.waitForIdle);
if (opts.json) argv.push("--json");
const result = await runner(host, argv);
if (opts.json) {
console.log(JSON.stringify({
cross_host: { host: host.id, target: host.target },
result,
}));
if (!result.ok) process.exitCode = 1;
return;
}
console.log(`[via host=${host.id} (${host.target})]`);
if (result.ok) {
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
return;
}
emitCrossHostFailure(host.id, host.target, result, opts.json);
}
function parseWaitForIdleMs(value: string | undefined): number | undefined | null {
if (value === undefined) return undefined;
const seconds = Number(value);
@@ -0,0 +1,59 @@
import type { CrossHostResult } from "./cross-host-executor.js";
/**
* Shared CLI-side helpers for commands that gate on `--host`. Centralizes
* the structured failure formatting + JSON envelope so `send.ts` and
* `capture.ts` (and any future v1 cross-host command) format identically.
*/
export function emitCrossHostError(hostId: string, code: string, message: string, json?: boolean): void {
if (json) {
console.log(JSON.stringify({ ok: false, cross_host: { host: hostId }, failedStep: code, error: message }));
} else {
console.error(`cross-host (host=${hostId}): ${message}`);
}
process.exitCode = 1;
}
export function emitCrossHostFailure(
hostId: string,
target: string,
result: CrossHostResult,
json?: boolean,
): void {
if (result.ok) return;
const message = formatCrossHostFailure(hostId, target, result);
if (json) {
console.log(JSON.stringify({
ok: false,
cross_host: { host: hostId, target },
failedStep: result.failedStep,
error: message,
...(result.failedStep === "permission-gate" && result.hint ? { hint: result.hint } : {}),
}));
} else {
console.error(message);
}
process.exitCode = 1;
}
export function formatCrossHostFailure(
hostId: string,
target: string,
result: Extract<CrossHostResult, { ok: false }>,
): string {
switch (result.failedStep) {
case "ssh-unreachable":
return `ssh to host=${hostId} (target=${target}) failed: ${oneLine(result.sshStderr)}. Verify SSH access and host registry config.`;
case "permission-gate":
return `ssh to host=${hostId} (target=${target}) hit a permission/auth gate: ${oneLine(result.sshStderr)}. ${result.hint ?? ""}`.trim();
case "remote-daemon-unreachable":
return `remote rig command on host=${hostId} could not reach the remote daemon (exit=${result.remoteExitCode}): ${oneLine(result.stderr || result.stdout)}. Start the daemon on the remote with 'ssh ${target} rig daemon start'.`;
case "remote-command-failed":
return `remote rig command on host=${hostId} failed (exit=${result.remoteExitCode}): ${oneLine(result.stderr || result.stdout)}`;
}
}
function oneLine(s: string): string {
return s.split("\n").map((l) => l.trim()).filter(Boolean).join(" | ").slice(0, 400);
}
+190
View File
@@ -0,0 +1,190 @@
import { spawn as nodeSpawn, type ChildProcess } from "node:child_process";
import type { HostEntry } from "./host-registry.js";
/**
* Structured result of a cross-host command. Distinguishes SSH-layer failures
* (couldn't reach the remote shell) from remote-command failures (reached the
* remote shell, but the command itself returned non-zero). This distinction
* is load-bearing: collapsing both into "failed" hides the operational
* difference between "fix your SSH" and "fix your remote rig daemon."
*/
export type CrossHostResult =
| { ok: true; failedStep: "none"; stdout: string; stderr: string; remoteExitCode: 0 }
| { ok: false; failedStep: "ssh-unreachable"; sshStderr: string }
| { ok: false; failedStep: "permission-gate"; sshStderr: string; hint?: string }
| { ok: false; failedStep: "remote-daemon-unreachable"; stdout: string; stderr: string; remoteExitCode: number }
| { ok: false; failedStep: "remote-command-failed"; stdout: string; stderr: string; remoteExitCode: number };
export type FailedStep = CrossHostResult["failedStep"];
/**
* Pluggable spawn function for testing. The default uses Node's
* `child_process.spawn` directly. Tests inject a mock that returns a
* controllable process-like object.
*/
export type SpawnFn = (
command: string,
args: readonly string[],
) => ChildProcess;
export interface RunCrossHostCommandOpts {
/** Optional stdin to write to the spawned ssh process (forwarded to remote stdin). */
stdin?: string;
/** Inject a non-default spawn function (used in tests). */
spawn?: SpawnFn;
/** Connect timeout in seconds for the underlying ssh invocation. Default: 10. */
connectTimeoutSeconds?: number;
}
/**
* Run a remote rig command via single-hop ssh. Returns a structured result
* that maps each failure mode named in the dossier to a distinct
* `failedStep` value.
*
* Important: this function does NOT collapse remote `--verify` semantics into
* the SSH exit code. Callers that pass `--verify` get the remote command's
* stdout (containing `Verified: yes`/`no`) verbatim; the remote rig is
* authoritative on verification. SSH success (exit 0) just means "we reached
* the remote shell and it ran the command", not "the verify passed."
*/
export async function runCrossHostCommand(
host: HostEntry,
argv: readonly string[],
opts: RunCrossHostCommandOpts = {},
): Promise<CrossHostResult> {
if (host.transport !== "ssh") {
return {
ok: false,
failedStep: "ssh-unreachable",
sshStderr: `host '${host.id}': transport '${host.transport}' is not supported (v0 supports ssh only)`,
};
}
if (argv.length === 0) {
return {
ok: false,
failedStep: "ssh-unreachable",
sshStderr: "internal: empty argv passed to runCrossHostCommand",
};
}
const sshOpts: string[] = [];
if (host.user) {
sshOpts.push("-l", host.user);
}
const connectTimeout = opts.connectTimeoutSeconds ?? 10;
sshOpts.push("-o", `ConnectTimeout=${connectTimeout}`);
// No BatchMode — operators may legitimately use password / interactive auth.
// Stay deferential to ~/.ssh/config defaults.
const remoteCommandLine = argv.map(shellQuote).join(" ");
const fullArgs = [...sshOpts, host.target, remoteCommandLine];
const spawn = opts.spawn ?? (nodeSpawn as unknown as SpawnFn);
const child = spawn("ssh", fullArgs);
if (opts.stdin !== undefined && child.stdin) {
child.stdin.write(opts.stdin);
child.stdin.end();
} else if (child.stdin) {
child.stdin.end();
}
let stdout = "";
let stderr = "";
if (child.stdout) {
child.stdout.on("data", (chunk: Buffer | string) => {
stdout += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
});
}
if (child.stderr) {
child.stderr.on("data", (chunk: Buffer | string) => {
stderr += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
});
}
const exitCode: number = await new Promise((resolve) => {
child.on("close", (code: number | null) => resolve(code ?? -1));
child.on("error", (err: Error) => {
stderr += `\n[spawn error] ${err.message}`;
resolve(-1);
});
});
return classifyResult(exitCode, stdout, stderr);
}
/**
* Map the spawned ssh exit code + stderr signature to a structured result.
*
* - Exit 0 → success.
* - Exit 255 → ssh-layer failure. If stderr matches a known permission/auth
* gate (Permission denied, Keychain, host key verification), classify as
* `permission-gate` with a hint pointing at the field-note diagnostic.
* Otherwise classify as `ssh-unreachable`.
* - Any other non-zero → ssh succeeded but the remote rig command failed.
* If the remote stderr matches the daemon-not-running signature, classify
* as `remote-daemon-unreachable`; otherwise `remote-command-failed`.
*/
export function classifyResult(exitCode: number, stdout: string, stderr: string): CrossHostResult {
if (exitCode === 0) {
return { ok: true, failedStep: "none", stdout, stderr, remoteExitCode: 0 };
}
if (exitCode === 255 || exitCode === -1) {
if (looksLikePermissionGate(stderr)) {
return {
ok: false,
failedStep: "permission-gate",
sshStderr: stderr,
hint: "See openrig-work/field-notes/2026-04-29-l4-3-d6-claude-keychain-over-ssh-diagnostic.md for guidance on Keychain-over-SSH issues.",
};
}
return { ok: false, failedStep: "ssh-unreachable", sshStderr: stderr };
}
if (looksLikeDaemonUnreachable(stderr) || looksLikeDaemonUnreachable(stdout)) {
return {
ok: false,
failedStep: "remote-daemon-unreachable",
stdout,
stderr,
remoteExitCode: exitCode,
};
}
return {
ok: false,
failedStep: "remote-command-failed",
stdout,
stderr,
remoteExitCode: exitCode,
};
}
const PERMISSION_GATE_PATTERNS = [
/Permission denied/i,
/Host key verification failed/i,
/Keychain/i,
/Could not request authentication agent/i,
/no mutual signature algorithm/i,
];
function looksLikePermissionGate(stderr: string): boolean {
return PERMISSION_GATE_PATTERNS.some((re) => re.test(stderr));
}
const DAEMON_UNREACHABLE_PATTERNS = [
/Daemon not running/i,
/Failed to fetch .* from daemon/i,
/ECONNREFUSED.*localhost/i,
];
function looksLikeDaemonUnreachable(text: string): boolean {
return DAEMON_UNREACHABLE_PATTERNS.some((re) => re.test(text));
}
/**
* Single-quote shell escaping. Wraps the input in single quotes, escaping
* any embedded single quotes via `'\''`. Safe for passing as a single arg
* to a remote POSIX shell.
*/
export function shellQuote(s: string): string {
return `'${s.replace(/'/g, `'\\''`)}'`;
}
+142
View File
@@ -0,0 +1,142 @@
import { existsSync, readFileSync } from "node:fs";
import { parse as parseYaml } from "yaml";
import { getDefaultOpenRigPath } from "./openrig-compat.js";
/**
* A single declared host in `~/.openrig/hosts.yaml`. v0 supports `transport: ssh`
* only. `target` is whatever ssh resolves (DNS, SSH config alias, IP, etc.).
*/
export interface HostEntry {
id: string;
transport: "ssh";
target: string;
user?: string;
notes?: string;
}
export interface HostRegistry {
hosts: HostEntry[];
}
export type HostRegistryLoadResult =
| { ok: true; registry: HostRegistry }
| { ok: false; error: string };
export type HostResolution =
| { ok: true; host: HostEntry }
| { ok: false; error: string };
const KNOWN_TRANSPORTS = new Set(["ssh"]);
export function defaultHostRegistryPath(): string {
return getDefaultOpenRigPath("hosts.yaml");
}
/**
* Load and validate the host registry from disk. v0 file shape:
*
* hosts:
* - id: vm-claude-test
* transport: ssh
* target: vm-claude-test.local
* user: wrandom # optional
* notes: "Tart VM" # optional
*
* Operator-managed; v0 does NOT auto-write or auto-modify this file. A missing
* file returns a clear error pointing at the canonical path.
*/
export function loadHostRegistry(path: string = defaultHostRegistryPath()): HostRegistryLoadResult {
if (!existsSync(path)) {
return {
ok: false,
error: `host registry not found at ${path}. Create it with a 'hosts:' array of { id, transport, target, [user] } entries; v0 supports transport: ssh only.`,
};
}
let raw: string;
try {
raw = readFileSync(path, "utf-8");
} catch (err) {
return { ok: false, error: `failed to read host registry at ${path}: ${(err as Error).message}` };
}
let parsed: unknown;
try {
parsed = parseYaml(raw);
} catch (err) {
return { ok: false, error: `failed to parse host registry YAML at ${path}: ${(err as Error).message}` };
}
return validateHostRegistry(parsed, path);
}
export function validateHostRegistry(parsed: unknown, sourcePath: string): HostRegistryLoadResult {
if (parsed === null || typeof parsed !== "object") {
return { ok: false, error: `host registry at ${sourcePath} must be a YAML object with a 'hosts' array` };
}
const obj = parsed as Record<string, unknown>;
const hosts = obj["hosts"];
if (!Array.isArray(hosts)) {
return { ok: false, error: `host registry at ${sourcePath}: 'hosts' must be an array` };
}
const seenIds = new Set<string>();
const validated: HostEntry[] = [];
for (let i = 0; i < hosts.length; i++) {
const raw = hosts[i];
const prefix = `host registry at ${sourcePath}: hosts[${i}]`;
if (raw === null || typeof raw !== "object") {
return { ok: false, error: `${prefix}: must be an object with id/transport/target` };
}
const entry = raw as Record<string, unknown>;
const id = entry["id"];
if (typeof id !== "string" || id.trim() === "") {
return { ok: false, error: `${prefix}.id: required non-empty string` };
}
if (seenIds.has(id)) {
return { ok: false, error: `${prefix}.id: duplicate host id '${id}' (each host id must be unique within the registry)` };
}
seenIds.add(id);
const transport = entry["transport"];
if (transport !== "ssh") {
return {
ok: false,
error: `${prefix}.transport: v0 supports 'ssh' only (got ${JSON.stringify(transport)}); declared known transports: ${[...KNOWN_TRANSPORTS].sort().join(", ")}`,
};
}
const target = entry["target"];
if (typeof target !== "string" || target.trim() === "") {
return { ok: false, error: `${prefix}.target: required non-empty string (an ssh target — DNS name, SSH config alias, or IP)` };
}
const user = entry["user"];
if (user !== undefined && (typeof user !== "string" || user.trim() === "")) {
return { ok: false, error: `${prefix}.user: optional, but if present must be a non-empty string` };
}
const notes = entry["notes"];
if (notes !== undefined && typeof notes !== "string") {
return { ok: false, error: `${prefix}.notes: optional, but if present must be a string` };
}
validated.push({
id,
transport: "ssh",
target,
...(user !== undefined ? { user: user as string } : {}),
...(notes !== undefined ? { notes: notes as string } : {}),
});
}
return { ok: true, registry: { hosts: validated } };
}
/**
* Resolve a host id against a loaded registry. Unknown id returns an error
* naming the requested id and listing up to 10 known ids for discoverability.
*/
export function resolveHost(registry: HostRegistry, id: string): HostResolution {
const match = registry.hosts.find((h) => h.id === id);
if (match) return { ok: true, host: match };
const knownIds = registry.hosts.map((h) => h.id).slice(0, 10);
const idsHint = knownIds.length > 0
? ` Known host ids: ${knownIds.join(", ")}${registry.hosts.length > knownIds.length ? ` (+${registry.hosts.length - knownIds.length} more)` : ""}.`
: " (registry is empty)";
return {
ok: false,
error: `unknown host id '${id}'.${idsHint}`,
};
}
@@ -0,0 +1,303 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { sendCommand, type SendDeps } from "../src/commands/send.js";
import { captureCommand, type CaptureDeps } from "../src/commands/capture.js";
import type { CrossHostResult } from "../src/cross-host-executor.js";
import type { HostRegistryLoadResult } from "../src/host-registry.js";
// Capture stdout / stderr / exitCode for command actions.
interface CapturedOutput {
stdoutLines: string[];
stderrLines: string[];
stdoutWrites: string[];
stderrWrites: string[];
exitCode: number | undefined;
}
let captured: CapturedOutput;
let originalLog: typeof console.log;
let originalError: typeof console.error;
let originalStdoutWrite: typeof process.stdout.write;
let originalStderrWrite: typeof process.stderr.write;
beforeEach(() => {
captured = { stdoutLines: [], stderrLines: [], stdoutWrites: [], stderrWrites: [], exitCode: undefined };
originalLog = console.log;
originalError = console.error;
originalStdoutWrite = process.stdout.write.bind(process.stdout);
originalStderrWrite = process.stderr.write.bind(process.stderr);
console.log = (...args: unknown[]) => { captured.stdoutLines.push(args.map(String).join(" ")); };
console.error = (...args: unknown[]) => { captured.stderrLines.push(args.map(String).join(" ")); };
process.stdout.write = ((s: string | Uint8Array) => { captured.stdoutWrites.push(typeof s === "string" ? s : Buffer.from(s).toString("utf-8")); return true; }) as typeof process.stdout.write;
process.stderr.write = ((s: string | Uint8Array) => { captured.stderrWrites.push(typeof s === "string" ? s : Buffer.from(s).toString("utf-8")); return true; }) as typeof process.stderr.write;
process.exitCode = undefined;
});
afterEach(() => {
console.log = originalLog;
console.error = originalError;
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
captured.exitCode = process.exitCode;
process.exitCode = undefined;
});
const KNOWN_REGISTRY: HostRegistryLoadResult = {
ok: true,
registry: {
hosts: [{ id: "vm-a", transport: "ssh", target: "vm-a.local" }],
},
};
function deps(overrides: { run?: (h: never, argv: readonly string[]) => Promise<CrossHostResult>; registry?: HostRegistryLoadResult } = {}): SendDeps & CaptureDeps {
const lifecycleDeps = {} as never;
const clientFactory = (() => ({}) as never) as never;
return {
lifecycleDeps,
clientFactory,
hostRegistryLoader: () => overrides.registry ?? KNOWN_REGISTRY,
crossHostRun: overrides.run ?? (async () => ({ ok: true, failedStep: "none", stdout: "", stderr: "", remoteExitCode: 0 })),
};
}
// ---------------------------------------------------------------------------
// `rig send --host <id>`
// ---------------------------------------------------------------------------
describe("send --host (cross-host short-circuit)", () => {
it("happy path: forwards reconstructed argv to the executor and surfaces remote stdout verbatim", async () => {
const captureCalls: { argv?: readonly string[] } = {};
const cmd = sendCommand(deps({
run: async (_h, argv) => {
captureCalls.argv = argv;
return { ok: true, failedStep: "none", stdout: "Sent to dev-impl@my-rig\nVerified: yes\n", stderr: "", remoteExitCode: 0 };
},
}));
await cmd.parseAsync(["--host", "vm-a", "dev-impl@my-rig", "hello world", "--verify"], { from: "user" });
expect(captureCalls.argv).toEqual(["rig", "send", "dev-impl@my-rig", "hello world", "--verify"]);
expect(captured.stdoutLines[0]).toBe("[via host=vm-a (vm-a.local)]");
const stdoutText = captured.stdoutWrites.join("");
expect(stdoutText).toContain("Verified: yes");
expect(process.exitCode).toBeUndefined();
});
it("--verify honesty: SSH success + remote 'Verified: no' surfaces in output, NOT collapsed into success+silence", async () => {
const cmd = sendCommand(deps({
run: async () => ({ ok: true, failedStep: "none", stdout: "Sent to dev-impl@my-rig\nVerified: no\n", stderr: "", remoteExitCode: 0 }),
}));
await cmd.parseAsync(["--host", "vm-a", "dev-impl@my-rig", "hi", "--verify"], { from: "user" });
const stdoutText = captured.stdoutWrites.join("");
expect(stdoutText).toContain("Verified: no"); // remote authority surfaced
// ssh layer succeeded; CLI does NOT inject Verified: yes.
expect(stdoutText).not.toMatch(/^Verified: yes$/m);
});
it("forwards --force, --wait-for-idle, --json flags into the remote argv", async () => {
const captureCalls: { argv?: readonly string[] } = {};
const cmd = sendCommand(deps({
run: async (_h, argv) => {
captureCalls.argv = argv;
return { ok: true, failedStep: "none", stdout: "{}", stderr: "", remoteExitCode: 0 };
},
}));
await cmd.parseAsync(["--host", "vm-a", "s@r", "msg", "--force", "--json"], { from: "user" });
expect(captureCalls.argv).toContain("--force");
expect(captureCalls.argv).toContain("--json");
});
it("JSON output wraps the executor result in a cross_host envelope", async () => {
const cmd = sendCommand(deps({
run: async () => ({ ok: true, failedStep: "none", stdout: "{}", stderr: "", remoteExitCode: 0 }),
}));
await cmd.parseAsync(["--host", "vm-a", "s@r", "msg", "--json"], { from: "user" });
const out = JSON.parse(captured.stdoutLines[0]!);
expect(out.cross_host).toEqual({ host: "vm-a", target: "vm-a.local" });
expect(out.result.ok).toBe(true);
});
it("unknown host id surfaces a friendly error with discoverability hint", async () => {
const cmd = sendCommand(deps());
await cmd.parseAsync(["--host", "vm-unknown", "s@r", "msg"], { from: "user" });
expect(captured.stderrLines.join("\n")).toContain("unknown host id 'vm-unknown'");
expect(captured.stderrLines.join("\n")).toContain("vm-a"); // known-id hint
expect(process.exitCode).toBe(1);
});
it("registry load failure surfaces the error", async () => {
const cmd = sendCommand(deps({
registry: { ok: false, error: "host registry not found at /nope" },
}));
await cmd.parseAsync(["--host", "vm-a", "s@r", "msg"], { from: "user" });
expect(captured.stderrLines.join("\n")).toContain("host registry not found");
expect(process.exitCode).toBe(1);
});
it("ssh-unreachable failure → exit 1 with operator-actionable message", async () => {
const cmd = sendCommand(deps({
run: async () => ({ ok: false, failedStep: "ssh-unreachable", sshStderr: "ssh: connect to host: Connection refused" }),
}));
await cmd.parseAsync(["--host", "vm-a", "s@r", "msg"], { from: "user" });
expect(captured.stderrLines.join("\n")).toContain("ssh to host=vm-a");
expect(captured.stderrLines.join("\n")).toContain("Verify SSH access");
expect(process.exitCode).toBe(1);
});
it("permission-gate failure → exit 1 with field-note hint", async () => {
const cmd = sendCommand(deps({
run: async () => ({ ok: false, failedStep: "permission-gate", sshStderr: "Permission denied", hint: "See keychain doc" }),
}));
await cmd.parseAsync(["--host", "vm-a", "s@r", "msg"], { from: "user" });
expect(captured.stderrLines.join("\n")).toContain("permission/auth gate");
expect(captured.stderrLines.join("\n")).toContain("See keychain doc");
expect(process.exitCode).toBe(1);
});
it("remote-daemon-unreachable → exit 1 with daemon-start hint", async () => {
const cmd = sendCommand(deps({
run: async () => ({ ok: false, failedStep: "remote-daemon-unreachable", stdout: "", stderr: "Daemon not running", remoteExitCode: 1 }),
}));
await cmd.parseAsync(["--host", "vm-a", "s@r", "msg"], { from: "user" });
expect(captured.stderrLines.join("\n")).toContain("could not reach the remote daemon");
expect(captured.stderrLines.join("\n")).toContain("rig daemon start");
expect(process.exitCode).toBe(1);
});
it("remote-command-failed → exit 1 with remote stderr surfaced", async () => {
const cmd = sendCommand(deps({
run: async () => ({ ok: false, failedStep: "remote-command-failed", stdout: "", stderr: "session not found", remoteExitCode: 3 }),
}));
await cmd.parseAsync(["--host", "vm-a", "s@r", "msg"], { from: "user" });
expect(captured.stderrLines.join("\n")).toContain("remote rig command on host=vm-a failed");
expect(captured.stderrLines.join("\n")).toContain("session not found");
expect(process.exitCode).toBe(1);
});
it("JSON output for failure surfaces failedStep + permission-gate hint under result", async () => {
const cmd = sendCommand(deps({
run: async () => ({ ok: false, failedStep: "permission-gate", sshStderr: "Permission denied", hint: "See keychain doc" }),
}));
await cmd.parseAsync(["--host", "vm-a", "s@r", "msg", "--json"], { from: "user" });
const out = JSON.parse(captured.stdoutLines[0]!);
expect(out.cross_host).toEqual({ host: "vm-a", target: "vm-a.local" });
expect(out.result.ok).toBe(false);
expect(out.result.failedStep).toBe("permission-gate");
expect(out.result.hint).toBe("See keychain doc");
expect(process.exitCode).toBe(1);
});
});
// ---------------------------------------------------------------------------
// `rig capture --host <id>`
// ---------------------------------------------------------------------------
describe("capture --host (cross-host short-circuit)", () => {
it("happy path: forwards reconstructed argv with session + lines", async () => {
const captureCalls: { argv?: readonly string[] } = {};
const cmd = captureCommand(deps({
run: async (_h, argv) => {
captureCalls.argv = argv;
return { ok: true, failedStep: "none", stdout: "pane content here\n", stderr: "", remoteExitCode: 0 };
},
}));
await cmd.parseAsync(["--host", "vm-a", "dev-impl@my-rig", "--lines", "50"], { from: "user" });
expect(captureCalls.argv).toEqual(["rig", "capture", "dev-impl@my-rig", "--lines", "50"]);
expect(captured.stdoutLines[0]).toBe("[via host=vm-a (vm-a.local)]");
expect(captured.stdoutWrites.join("")).toContain("pane content here");
});
it("multi-target form: forwards --rig and --pod when no session is supplied", async () => {
const captureCalls: { argv?: readonly string[] } = {};
const cmd = captureCommand(deps({
run: async (_h, argv) => {
captureCalls.argv = argv;
return { ok: true, failedStep: "none", stdout: "{}", stderr: "", remoteExitCode: 0 };
},
}));
await cmd.parseAsync(["--host", "vm-a", "--rig", "my-rig", "--pod", "dev", "--json"], { from: "user" });
expect(captureCalls.argv).toEqual(["rig", "capture", "--rig", "my-rig", "--pod", "dev", "--lines", "20", "--json"]);
});
it("unknown host id surfaces a friendly error", async () => {
const cmd = captureCommand(deps());
await cmd.parseAsync(["--host", "vm-bogus", "s@r"], { from: "user" });
expect(captured.stderrLines.join("\n")).toContain("unknown host id 'vm-bogus'");
expect(process.exitCode).toBe(1);
});
it("ssh-unreachable failure surfaces actionable error", async () => {
const cmd = captureCommand(deps({
run: async () => ({ ok: false, failedStep: "ssh-unreachable", sshStderr: "Connection refused" }),
}));
await cmd.parseAsync(["--host", "vm-a", "s@r"], { from: "user" });
expect(captured.stderrLines.join("\n")).toContain("ssh to host=vm-a");
expect(process.exitCode).toBe(1);
});
it("JSON output wraps result in cross_host envelope", async () => {
const cmd = captureCommand(deps({
run: async () => ({ ok: true, failedStep: "none", stdout: "{}", stderr: "", remoteExitCode: 0 }),
}));
await cmd.parseAsync(["--host", "vm-a", "s@r", "--json"], { from: "user" });
const out = JSON.parse(captured.stdoutLines[0]!);
expect(out.cross_host).toEqual({ host: "vm-a", target: "vm-a.local" });
expect(out.result.ok).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Compat regression: `rig send` and `rig capture` WITHOUT --host MUST NOT add
// the cross-host annotation and MUST NOT call the executor at all.
// ---------------------------------------------------------------------------
describe("compat regression: no --host means no cross-host annotation", () => {
it("send help text mentions --host (so operators discover it)", () => {
const cmd = sendCommand(deps());
expect(cmd.helpInformation()).toContain("--host");
});
it("capture help text mentions --host", () => {
const cmd = captureCommand(deps());
expect(cmd.helpInformation()).toContain("--host");
});
it("send without --host does NOT call the cross-host executor (registry loader untouched, executor untouched)", async () => {
const loaderSpy = vi.fn();
const runSpy = vi.fn();
const cmd = sendCommand({
lifecycleDeps: makeFailingLifecycleDeps(),
clientFactory: () => ({} as never),
hostRegistryLoader: loaderSpy as never,
crossHostRun: runSpy as never,
});
await cmd.parseAsync(["s@r", "msg"], { from: "user" });
// local path short-circuits at "Daemon not running" without consulting cross-host machinery.
expect(loaderSpy).not.toHaveBeenCalled();
expect(runSpy).not.toHaveBeenCalled();
});
it("capture without --host does NOT call the cross-host executor", async () => {
const loaderSpy = vi.fn();
const runSpy = vi.fn();
const cmd = captureCommand({
lifecycleDeps: makeFailingLifecycleDeps(),
clientFactory: () => ({} as never),
hostRegistryLoader: loaderSpy as never,
crossHostRun: runSpy as never,
});
await cmd.parseAsync(["s@r"], { from: "user" });
expect(loaderSpy).not.toHaveBeenCalled();
expect(runSpy).not.toHaveBeenCalled();
});
});
function makeFailingLifecycleDeps(): never {
// Stub LifecycleDeps that responds with "no daemon" on every probe — the
// local-path branch short-circuits with "Daemon not running" before doing
// anything cross-host.
return {
exists: (_p: string) => false,
readFileText: (_p: string) => "",
spawnDetached: () => ({ pid: 0 }),
isPidAlive: (_p: number) => false,
fetchHealthDetail: async () => ({ ok: false, code: "unreachable" }),
} as never;
}
@@ -0,0 +1,258 @@
import { describe, it, expect, vi } from "vitest";
import { EventEmitter } from "node:events";
import {
runCrossHostCommand,
classifyResult,
shellQuote,
type SpawnFn,
} from "../src/cross-host-executor.js";
import type { HostEntry } from "../src/host-registry.js";
// ---------------------------------------------------------------------------
// Mock spawn helpers
// ---------------------------------------------------------------------------
interface MockChild extends EventEmitter {
stdin: { write: (s: string) => void; end: () => void };
stdout: EventEmitter;
stderr: EventEmitter;
}
function makeMockChild(): MockChild {
const child = new EventEmitter() as MockChild;
child.stdin = { write: vi.fn(), end: vi.fn() };
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
return child;
}
function mockSpawnFor(opts: {
exitCode: number;
stdout?: string;
stderr?: string;
capture?: { command?: string; args?: readonly string[] };
}): SpawnFn {
return ((command: string, args: readonly string[]) => {
if (opts.capture) {
opts.capture.command = command;
opts.capture.args = args;
}
const child = makeMockChild();
queueMicrotask(() => {
if (opts.stdout) child.stdout.emit("data", Buffer.from(opts.stdout, "utf-8"));
if (opts.stderr) child.stderr.emit("data", Buffer.from(opts.stderr, "utf-8"));
child.emit("close", opts.exitCode);
});
return child as never;
}) as SpawnFn;
}
const HOST: HostEntry = { id: "vm-test", transport: "ssh", target: "vm-test.local" };
const HOST_WITH_USER: HostEntry = { id: "vm-test", transport: "ssh", target: "vm-test.local", user: "ops" };
// ---------------------------------------------------------------------------
// shellQuote unit
// ---------------------------------------------------------------------------
describe("shellQuote", () => {
it("wraps simple strings in single quotes", () => {
expect(shellQuote("hello")).toBe("'hello'");
});
it("escapes embedded single quotes via the standard '\\'' trick", () => {
expect(shellQuote("it's fine")).toBe("'it'\\''s fine'");
});
it("preserves spaces, equals signs, and special chars literally inside the quotes", () => {
expect(shellQuote("a b=c d?e")).toBe("'a b=c d?e'");
});
});
// ---------------------------------------------------------------------------
// classifyResult — unit table
// ---------------------------------------------------------------------------
describe("classifyResult", () => {
it("exit 0 → ok / failedStep=none", () => {
const r = classifyResult(0, "Sent to dev-impl@rig\nVerified: yes\n", "");
expect(r).toEqual({
ok: true,
failedStep: "none",
stdout: "Sent to dev-impl@rig\nVerified: yes\n",
stderr: "",
remoteExitCode: 0,
});
});
it("exit 255 with Permission denied → permission-gate (with field-note hint)", () => {
const r = classifyResult(255, "", "ssh: Permission denied (publickey).");
expect(r.ok).toBe(false);
expect(r.failedStep).toBe("permission-gate");
if (r.failedStep === "permission-gate") {
expect(r.sshStderr).toContain("Permission denied");
expect(r.hint).toContain("Keychain-over-SSH");
}
});
it("exit 255 with Keychain pattern → permission-gate", () => {
const r = classifyResult(255, "", "Could not query Keychain entry for ...");
expect(r.failedStep).toBe("permission-gate");
});
it("exit 255 with unknown stderr → ssh-unreachable", () => {
const r = classifyResult(255, "", "ssh: connect to host vm-test.local port 22: Connection refused");
expect(r.failedStep).toBe("ssh-unreachable");
if (r.failedStep === "ssh-unreachable") {
expect(r.sshStderr).toContain("Connection refused");
}
});
it("exit 1 with Daemon-not-running stderr → remote-daemon-unreachable", () => {
const r = classifyResult(1, "", "Daemon not running. Start it with: rig daemon start\n");
expect(r.failedStep).toBe("remote-daemon-unreachable");
if (r.failedStep === "remote-daemon-unreachable") {
expect(r.remoteExitCode).toBe(1);
}
});
it("exit 2 with daemon-fetch-failure stderr → remote-daemon-unreachable", () => {
const r = classifyResult(2, "", "Failed to fetch rig list from daemon (HTTP 500). Check daemon status with: rig status\n");
expect(r.failedStep).toBe("remote-daemon-unreachable");
});
it("exit non-zero without daemon signal → remote-command-failed", () => {
const r = classifyResult(1, "", "Send failed: session not found\n");
expect(r.failedStep).toBe("remote-command-failed");
if (r.failedStep === "remote-command-failed") {
expect(r.remoteExitCode).toBe(1);
expect(r.stderr).toContain("session not found");
}
});
it("exit -1 (spawn error) → ssh-unreachable", () => {
const r = classifyResult(-1, "", "[spawn error] ENOENT");
expect(r.failedStep).toBe("ssh-unreachable");
});
});
// ---------------------------------------------------------------------------
// runCrossHostCommand — integration with mocked spawn
// ---------------------------------------------------------------------------
describe("runCrossHostCommand", () => {
it("constructs the ssh argv with ConnectTimeout and quoted remote command line", async () => {
const capture: { command?: string; args?: readonly string[] } = {};
const spawn = mockSpawnFor({ exitCode: 0, stdout: "ok\n", capture });
await runCrossHostCommand(HOST, ["rig", "send", "dev-impl@rig", "hello world", "--verify"], { spawn });
expect(capture.command).toBe("ssh");
expect(capture.args).toEqual([
"-o", "ConnectTimeout=10",
"vm-test.local",
"'rig' 'send' 'dev-impl@rig' 'hello world' '--verify'",
]);
});
it("includes -l <user> when host.user is set", async () => {
const capture: { command?: string; args?: readonly string[] } = {};
const spawn = mockSpawnFor({ exitCode: 0, capture });
await runCrossHostCommand(HOST_WITH_USER, ["rig", "ps"], { spawn });
expect(capture.args?.[0]).toBe("-l");
expect(capture.args?.[1]).toBe("ops");
});
it("happy path returns stdout/stderr verbatim", async () => {
const spawn = mockSpawnFor({
exitCode: 0,
stdout: "Sent to dev-impl@rig\nVerified: yes\n",
stderr: "",
});
const result = await runCrossHostCommand(HOST, ["rig", "send", "dev-impl@rig", "hello"], { spawn });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.stdout).toContain("Verified: yes");
expect(result.remoteExitCode).toBe(0);
}
});
it("--verify honest pass-through: ssh exit 0 + remote 'Verified: no' surfaces in stdout (NOT collapsed)", async () => {
// This is the slice's load-bearing rule: ssh success is NOT verify success.
const spawn = mockSpawnFor({
exitCode: 0,
stdout: "Sent to dev-impl@rig\nVerified: no\n",
});
const result = await runCrossHostCommand(HOST, ["rig", "send", "dev-impl@rig", "hi", "--verify"], { spawn });
expect(result.ok).toBe(true); // ssh layer succeeded
if (result.ok) {
// remote verify result is in stdout, not synthesized into ok=false.
// Callers (send command) are responsible for surfacing 'Verified: no' to the user.
expect(result.stdout).toContain("Verified: no");
}
});
it("classifies 255 + Permission denied as permission-gate", async () => {
const spawn = mockSpawnFor({ exitCode: 255, stderr: "ssh: Permission denied (publickey)." });
const result = await runCrossHostCommand(HOST, ["rig", "ps"], { spawn });
expect(result.failedStep).toBe("permission-gate");
});
it("classifies 255 + connection refused as ssh-unreachable", async () => {
const spawn = mockSpawnFor({
exitCode: 255,
stderr: "ssh: connect to host vm-test.local port 22: Connection refused",
});
const result = await runCrossHostCommand(HOST, ["rig", "ps"], { spawn });
expect(result.failedStep).toBe("ssh-unreachable");
});
it("classifies remote daemon-not-running as remote-daemon-unreachable", async () => {
const spawn = mockSpawnFor({
exitCode: 1,
stderr: "Daemon not running. Start it with: rig daemon start\n",
});
const result = await runCrossHostCommand(HOST, ["rig", "send", "x", "y"], { spawn });
expect(result.failedStep).toBe("remote-daemon-unreachable");
});
it("classifies other remote non-zero as remote-command-failed", async () => {
const spawn = mockSpawnFor({ exitCode: 3, stderr: "Some other rig error\n" });
const result = await runCrossHostCommand(HOST, ["rig", "send", "x", "y"], { spawn });
expect(result.failedStep).toBe("remote-command-failed");
if (result.failedStep === "remote-command-failed") {
expect(result.remoteExitCode).toBe(3);
}
});
it("rejects non-ssh transport (defense-in-depth on top of registry validation)", async () => {
const result = await runCrossHostCommand(
{ id: "vm-x", transport: "tailscale" as never, target: "vm-x.local" } as HostEntry,
["rig", "ps"],
{ spawn: mockSpawnFor({ exitCode: 0 }) },
);
expect(result.ok).toBe(false);
if (!result.ok && result.failedStep === "ssh-unreachable") {
expect(result.sshStderr).toContain("not supported");
}
});
it("forwards stdin to the spawned process when provided", async () => {
const writes: string[] = [];
let stdinEnded = false;
const spawn: SpawnFn = ((command: string, _args: readonly string[]) => {
const child = makeMockChild();
child.stdin.write = (s: string) => writes.push(s);
child.stdin.end = () => { stdinEnded = true; };
queueMicrotask(() => child.emit("close", 0));
return child as never;
}) as SpawnFn;
await runCrossHostCommand(HOST, ["cat"], { spawn, stdin: "from-stdin\n" });
expect(writes).toEqual(["from-stdin\n"]);
expect(stdinEnded).toBe(true);
});
it("respects custom connect timeout", async () => {
const capture: { args?: readonly string[] } = {};
await runCrossHostCommand(HOST, ["rig", "ps"], {
spawn: mockSpawnFor({ exitCode: 0, capture }),
connectTimeoutSeconds: 30,
});
expect(capture.args).toContain("ConnectTimeout=30");
});
});
+208
View File
@@ -0,0 +1,208 @@
import { describe, it, expect } from "vitest";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
loadHostRegistry,
resolveHost,
validateHostRegistry,
defaultHostRegistryPath,
} from "../src/host-registry.js";
function withTempFile(name: string, contents: string, fn: (path: string) => void) {
const dir = mkdtempSync(join(tmpdir(), "openrig-host-registry-"));
const path = join(dir, name);
writeFileSync(path, contents, "utf-8");
try {
fn(path);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
describe("host registry — defaultHostRegistryPath", () => {
it("returns ~/.openrig/hosts.yaml under the canonical OpenRig home", () => {
const path = defaultHostRegistryPath();
expect(path.endsWith("/hosts.yaml")).toBe(true);
expect(path.includes(".openrig")).toBe(true);
});
});
describe("host registry — loadHostRegistry", () => {
it("loads a valid single-host registry", () => {
withTempFile("hosts.yaml", `
hosts:
- id: vm-claude-test
transport: ssh
target: vm-claude-test.local
user: wrandom
notes: "Tart VM"
`, (path) => {
const r = loadHostRegistry(path);
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.registry.hosts).toHaveLength(1);
expect(r.registry.hosts[0]).toEqual({
id: "vm-claude-test",
transport: "ssh",
target: "vm-claude-test.local",
user: "wrandom",
notes: "Tart VM",
});
}
});
});
it("loads a valid multi-host registry with optional fields omitted", () => {
withTempFile("hosts.yaml", `
hosts:
- id: vm-a
transport: ssh
target: vm-a.local
- id: laptop-b
transport: ssh
target: laptop-b.tail-scale-net
user: wrandom
`, (path) => {
const r = loadHostRegistry(path);
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.registry.hosts.map((h) => h.id)).toEqual(["vm-a", "laptop-b"]);
expect(r.registry.hosts[0]?.user).toBeUndefined();
expect(r.registry.hosts[1]?.user).toBe("wrandom");
}
});
});
it("returns a clear error when the registry file is missing", () => {
const r = loadHostRegistry("/tmp/openrig-non-existent-hosts.yaml");
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toContain("host registry not found");
expect(r.error).toContain("transport: ssh");
}
});
it("rejects non-array hosts field", () => {
withTempFile("hosts.yaml", `hosts: "not-an-array"\n`, (path) => {
const r = loadHostRegistry(path);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain("'hosts' must be an array");
});
});
it("rejects entry missing required id", () => {
withTempFile("hosts.yaml", `
hosts:
- transport: ssh
target: x.local
`, (path) => {
const r = loadHostRegistry(path);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain("id: required non-empty string");
});
});
it("rejects entry missing required target", () => {
withTempFile("hosts.yaml", `
hosts:
- id: vm-x
transport: ssh
`, (path) => {
const r = loadHostRegistry(path);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain("target: required non-empty string");
});
});
it("rejects non-ssh transport with v0-scope message", () => {
withTempFile("hosts.yaml", `
hosts:
- id: vm-x
transport: tailscale
target: x.local
`, (path) => {
const r = loadHostRegistry(path);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toContain("transport: v0 supports 'ssh' only");
expect(r.error).toContain('"tailscale"');
}
});
});
it("rejects duplicate host ids", () => {
withTempFile("hosts.yaml", `
hosts:
- id: vm-x
transport: ssh
target: x.local
- id: vm-x
transport: ssh
target: y.local
`, (path) => {
const r = loadHostRegistry(path);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain("duplicate host id 'vm-x'");
});
});
it("rejects empty user field", () => {
withTempFile("hosts.yaml", `
hosts:
- id: vm-x
transport: ssh
target: x.local
user: ""
`, (path) => {
const r = loadHostRegistry(path);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain("user: optional, but if present must be a non-empty string");
});
});
it("rejects malformed YAML with a clear error", () => {
withTempFile("hosts.yaml", `hosts:\n - id: vm-x\n transport: ssh\n target: x.local\nnot-valid: [`, (path) => {
const r = loadHostRegistry(path);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain("failed to parse host registry YAML");
});
});
it("rejects top-level non-object YAML", () => {
const result = validateHostRegistry("just-a-string", "/x");
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain("must be a YAML object with a 'hosts' array");
});
});
describe("host registry — resolveHost", () => {
const registry = {
hosts: [
{ id: "vm-a", transport: "ssh" as const, target: "vm-a.local" },
{ id: "vm-b", transport: "ssh" as const, target: "vm-b.local", user: "ops" },
],
};
it("resolves a known id", () => {
const r = resolveHost(registry, "vm-b");
expect(r.ok).toBe(true);
if (r.ok) expect(r.host.target).toBe("vm-b.local");
});
it("rejects an unknown id with the supported-list hint", () => {
const r = resolveHost(registry, "vm-unknown");
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toContain("unknown host id 'vm-unknown'");
expect(r.error).toContain("vm-a");
expect(r.error).toContain("vm-b");
}
});
it("indicates an empty registry honestly", () => {
const r = resolveHost({ hosts: [] }, "vm-x");
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain("registry is empty");
});
});