mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 22:58:42 -10:00
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:
1 parent
c7b6df192f
commit
cdce3a6864
10 files changed
+1372
-14
No files matched your search
@@ -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
|
||||
|
||||
@@ -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]`
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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, `'\\''`)}'`;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user