Files
openrig/packages/cli/test/file-transfer.test.ts
T
mvschwarz ac75777a3c Release 0.4.4
Multi-host + Living Notes.

Multi-host foundation: shareable whole-topology staged spin-up brings
a rig topology up across multiple hosts in stages; VPS product-factory
multi-host hardening ships with rig host verbs (add / list / doctor,
capped at three; one built-in product-factory-vps posture, three-
valued per item); the transport posture is documented (ssh for pane
ops, http-bearer for daemon REST, no cross-transport fallback); the
For-You feed aggregates activity across every registered host; and
rig file moves files across the topology's registered hosts.

Living Notes: durable INTENT / PLAN / DELIVERED signal layer at
mission and slice altitude with agent authorship + timestamps; cheap
composer surfaces make it the first-class place agents record
decisions, plans, and delivered work; the one-structure review
contract reads left-to-right as a single vertical stack (INTENT above,
PLAN + mockup in the middle, DELIVERED with paired proof at the
bottom); a plan change deletes the old plan and writes a new one -
never demotes or stacks multiple competing plans.

Operationalize the SDLC control plane: conventions SSOT ships in
source at docs/reference/sdlc-conventions.md (Living Notes UI section
names, proof-contract format + plannedRef mockup pairing, staged-
approval locks, C1 proof header + closed sets, three role contracts,
curation rule, elastic-middle doctrine, advisory fail-open audit
posture); rig scope slice create scaffolds the convention sections +
proof + PROOF.md + an IMPLEMENTATION-PRD.md skeleton for every
template kind; rig scope audit + rig workspace doctor gain sdlc-
convention advisories (records-and-advises, gates on HIGH only); the
mission-slice-sop skill ships in canonical source with byte-parity
guard; rig proof / rig scope slice create / rig scope slice approve
help + cli-reference teach the SDLC flow.

BREAKING: rig ps default flips to consolidated all-active-rigs
compact projection (the v0.4.0 current-rig-only default is retired -
it hid running rigs from the operator's field of view). --json is
scope-not-shape (still a bare array with existing per-entry keys;
scope widens to all non-archived rigs including stopped ones; one-line
migration for the old fleet firehose: rig ps --nodes -A --full).
-A / --all-rigs keeps exactly ONE meaning (the --nodes fleet widener;
bare -A is a structured teaching error). --nodes names its scope
everywhere (session default local-only; --host <id> --nodes requires
--rig or -A; multi-host fan-out is rollup-only by default; the
explicit ladder --all-hosts --nodes -A --full fans out per-node with
hostId-stamped projected rows). --all-hosts / --hosts --json emits the
shared AggregatedPayload (items + hosts with closed-enum statuses:
ok | unreachable | unsupported-transport | auth-failed).

Operator UX: agent altitude coordination panel scopes multi-agent
state to the right altitude (workspace / mission / slice) so the
coordination surface reads without per-seat drowning; composes with
the workspace observability tabs from 0.4.1. Docs closeout catches
docs/as-built/architecture.md, docs/as-built/cli-reference.md, and
the codemaps up to shipped state.

Migrations: additive only. Existing v0.4.3 databases upgrade by
running rig daemon start.

Behavior change (rig ps default view): the compact all-active-rigs
projection replaces the current-rig-only default. If your call sites
relied on the v0.4.3 default shape, add --full for the rich per-node
projection or --rig <name> for the prior scope.

Public-safety scrub applied inline to packages/ui/twin/corrective/
fixtures-corrective.ts (one demo-fixture role identifier neutralized
to the demo-namespace baseline established at 0.4.1).

See CHANGELOG.md and docs/releases/v0.4.4.md.
2026-07-06 01:20:09 -07:00

303 lines
14 KiB
TypeScript

// OPR.0.4.4.18 — the security cell, test-asserted (plan §3/§7a/§7a-2,
// guard-cleared over three rounds). The property tests ARE the contract:
// deletion-class flags unreachable, -s/--protect-args ABSENT (the G18-P3
// uniformity pin), '--' always pinned before operands (G18-P1), the remote
// charset shell-inert, every rejection before any spawn.
import { describe, it, expect } from "vitest";
import os from "node:os";
import path from "node:path";
import {
parseFilePathArg,
checkLocalPath,
checkRemotePath,
planFileCopy,
buildRsyncArgv,
classifyRsyncResult,
runFileCopy,
DENIED_SEGMENTS,
type CopyPlan,
type CopySide,
} from "../src/lib/file-transfer.js";
import type { SshHostEntry } from "../src/host-registry.js";
const VPS: SshHostEntry = { id: "vps-a", transport: "ssh", target: "vps-a.tail.net", user: "openrig" };
const REGISTRY_OK = () => ({ ok: true as const, registry: { hosts: [VPS, { id: "http-1", transport: "http" as const, url: "http://h:7433", bearer_env: "T" }] } });
function local(p: string): CopySide {
return { kind: "local", path: p };
}
function remote(p: string): CopySide {
return { kind: "remote", path: p, host: VPS };
}
// ── grammar (FR-1/FR-2/N18-1/G18-P1) ────────────────────────────────────────
describe("parseFilePathArg — the explicit-or-local grammar", () => {
it("local-prefix escapes are ALWAYS local, colon or not", () => {
for (const p of ["/abs/file.md", "./notes:v2.md", "../up.md", "~/docs/x.md", "~"]) {
expect(parseFilePathArg(p)).toEqual({ ok: true, arg: { kind: "local", path: p } });
}
});
it("id-shaped colon prefix parses as a HOST qualifier (resolve-or-fail-loud happens at planning)", () => {
expect(parseFilePathArg("vps-a:/srv/x.md")).toEqual({ ok: true, arg: { kind: "remote", hostId: "vps-a", path: "/srv/x.md" } });
expect(parseFilePathArg("mac_mini2:/tmp/y")).toEqual({ ok: true, arg: { kind: "remote", hostId: "mac_mini2", path: "/tmp/y" } });
});
it("N18-1: a bare colon-named file parses as an (unknown) host — fail-closed at planning, never silent-local", () => {
expect(parseFilePathArg("notes:v2.md")).toEqual({ ok: true, arg: { kind: "remote", hostId: "notes", path: "v2.md" } });
});
it("non-id-shaped colon prefixes are local (scp's lived behavior)", () => {
expect(parseFilePathArg("dir/sub:file.md")).toEqual({ ok: true, arg: { kind: "local", path: "dir/sub:file.md" } });
});
it("G18-P1: leading-dash operands are REFUSED with the ./ escape taught", () => {
const res = parseFilePathArg("--delete-after");
expect(res.ok).toBe(false);
if (!res.ok) expect(res.error).toContain("./--delete-after");
});
it("empty remote path after the qualifier rejects", () => {
const res = parseFilePathArg("vps-a:");
expect(res.ok).toBe(false);
});
});
// ── the deny wall (FR-4) ────────────────────────────────────────────────────
describe("checkLocalPath — resolve-then-recheck deny wall", () => {
it("denies resolved paths under each short-closed-list root, incl. via traversal and ~", () => {
const home = os.homedir();
for (const seg of DENIED_SEGMENTS) {
const direct = checkLocalPath(path.join(home, seg, "x"));
expect(direct.ok).toBe(false);
const viaTilde = checkLocalPath(`~/${seg}/hosts.yaml`);
expect(viaTilde.ok).toBe(false);
// Traversal resolving INTO a denied root is caught on the RESOLVED form.
const viaTraversal = checkLocalPath(path.join(home, "safe", "..", seg, "x"));
expect(viaTraversal.ok).toBe(false);
}
});
it("denies the active hosts registry and custom OPENRIG_HOME state", () => {
const saved = process.env["OPENRIG_HOME"];
const customHome = path.join(os.tmpdir(), `openrig-s18-active-${Date.now()}`);
try {
process.env["OPENRIG_HOME"] = customHome;
const registry = checkLocalPath(path.join(customHome, "hosts.yaml"));
expect(registry.ok).toBe(false);
if (!registry.ok) expect(registry.error).toContain("active hosts registry");
const state = checkLocalPath(path.join(customHome, "workspace", "artifact.md"));
expect(state.ok).toBe(false);
if (!state.ok) expect(state.error).toContain("active OPENRIG_HOME");
} finally {
if (saved === undefined) delete process.env["OPENRIG_HOME"];
else process.env["OPENRIG_HOME"] = saved;
}
});
it("allows ordinary paths and returns the RESOLVED form (what rsync receives)", () => {
const res = checkLocalPath("./some/dir/../file.md");
expect(res.ok).toBe(true);
if (res.ok) {
expect(path.isAbsolute(res.normalizedPath)).toBe(true);
expect(res.normalizedPath.includes("..")).toBe(false);
}
});
});
describe("checkRemotePath — the shell-inert charset wall (G18-P3) + absolute-only + traversal + segments", () => {
it("rejects every shell-interpretable character class, TEACHING the offending char", () => {
const cases: Array<[string, string]> = [
["/srv/a b.md", "a space"],
["/srv/$HOME/x", "'$'"],
["/srv/`id`.md", "'`'"],
["/srv/x;rm", "';'"],
["/srv/x|y", "'|'"],
["/srv/x&y", "'&'"],
["/srv/'q'.md", `"'"`.slice(1, 2) === "'" ? "'''" : "quote"],
["/srv/x>y", "'>'"],
["/srv/(x)", "'('"],
["/srv/{x}", "'{'"],
["/srv/x\\y", "'\\'"],
["/srv/x\ny", "\n"],
["/srv/*.md", "'*'"],
["/srv/x?.md", "'?'"],
["~/x.md", "'~'"],
];
for (const [p] of cases) {
const res = checkRemotePath(p, "vps-a");
expect(res.ok, `should reject: ${JSON.stringify(p)}`).toBe(false);
}
// The teaching shape, spot-checked on the space case (arch note 1).
const space = checkRemotePath("/srv/a b.md", "vps-a");
if (!space.ok) {
expect(space.error).toContain("a space");
expect(space.error).toContain("space-free staging path");
}
});
it("rejects relative remote paths (absolute-only, arch Q5)", () => {
const res = checkRemotePath("srv/x.md", "vps-a");
expect(res.ok).toBe(false);
if (!res.ok) expect(res.error).toContain("ABSOLUTE-only");
});
it("G18-C1: ANY raw '..' segment rejects BEFORE normalization (normalize COLLAPSES '..' in absolute paths — a post-normalize check is dead code)", () => {
expect(checkRemotePath("/srv/../../etc/passwd", "vps-a").ok).toBe(false); // the root-climb guard caught escaping
expect(checkRemotePath("/srv/sub/../x.md", "vps-a").ok).toBe(false); // 'benign' collapsible '..' rejects too — never necessary in an absolute path
const res = checkRemotePath("/srv/..hidden/x.md", "vps-a"); // '..' as a NAME PREFIX is not a traversal segment
expect(res.ok).toBe(true);
// Normalization still collapses '.' and '//' — the normalized form is what ships.
const collapsed = checkRemotePath("/srv//sub/./x.md", "vps-a");
expect(collapsed.ok).toBe(true);
if (collapsed.ok) expect(collapsed.normalizedPath).toBe("/srv/sub/x.md");
});
it("rejects denied dot-directory segments ANYWHERE in the path (conservative-over-broad)", () => {
for (const seg of DENIED_SEGMENTS) {
expect(checkRemotePath(`/home/u/${seg}/f`, "vps-a").ok).toBe(false);
expect(checkRemotePath(`/srv/backup/${seg}/f`, "vps-a").ok).toBe(false);
}
// Also via traversal that normalizes INTO a denied segment.
expect(checkRemotePath("/srv/x/../.ssh/id_ed25519", "vps-a").ok).toBe(false);
});
});
// ── planning: the fail-closed set (all BEFORE any spawn) ────────────────────
describe("planFileCopy — fail-closed set", () => {
const deps = { registryLoader: REGISTRY_OK as never };
it("remote:remote rejects with the pull-then-push message", () => {
const res = planFileCopy("vps-a:/a.md", "vps-a:/b.md", deps);
expect(res).toMatchObject({ ok: false, code: "remote_to_remote" });
});
it("unknown host fails loudly (the N18-1 colon-file case lands here)", () => {
const res = planFileCopy("notes:v2.md", "./out.md", deps);
expect(res).toMatchObject({ ok: false, code: "unknown_host" });
if (!res.ok) expect(res.error).toContain("unknown host id 'notes'");
});
it("http-transport host rejects (ssh/rsync only in v0)", () => {
const res = planFileCopy("http-1:/x.md", "./out.md", deps);
expect(res).toMatchObject({ ok: false, code: "unsupported_transport" });
});
it("two bare paths = a local-only plan (explicit-or-local; no inference)", () => {
const res = planFileCopy("./a.md", "b.md", deps);
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.plan.src.kind).toBe("local");
expect(res.plan.dst.kind).toBe("local");
}
});
it("denied locations reject at planning on either side", () => {
expect(planFileCopy("~/.ssh/id_ed25519", "vps-a:/tmp/key", deps)).toMatchObject({ ok: false, code: "denied_path" });
expect(planFileCopy("./ok.md", "vps-a:/home/u/.openrig/db.sqlite", deps)).toMatchObject({ ok: false, code: "denied_path" });
});
});
// ── the CLOSED argv builder: property tests ─────────────────────────────────
const ADVERSARIAL_LOCALS = ["/tmp/--delete-after", "/tmp/--remove-source-files", "/tmp/-e", "/tmp/--rsh=x", "/tmp/-- evil"];
function allPlans(): CopyPlan[] {
const plans: CopyPlan[] = [];
for (const dryRun of [false, true]) {
plans.push({ src: local("/tmp/a.md"), dst: remote("/srv/a.md"), dryRun });
plans.push({ src: remote("/srv/a.md"), dst: local("/tmp/a.md"), dryRun });
plans.push({ src: local("/tmp/a.md"), dst: local("/tmp/b.md"), dryRun });
for (const adv of ADVERSARIAL_LOCALS) {
plans.push({ src: local(adv), dst: remote("/srv/x"), dryRun });
plans.push({ src: local(adv), dst: local("/tmp/y"), dryRun });
}
}
return plans;
}
describe("buildRsyncArgv — closed-builder properties (every permutation)", () => {
it("NO deletion-class flag, NO -s/--protect-args (G18-P3 absence pin), '--' ALWAYS pinned before the two operands, flag-shaped operands only in post-'--' positions (G18-P1)", () => {
for (const plan of allPlans()) {
const argv = buildRsyncArgv(plan);
const sep = argv.indexOf("--");
expect(sep, JSON.stringify(argv)).toBeGreaterThan(-1);
expect(sep).toBe(argv.length - 3); // exactly two operands after the pin
const options = argv.slice(0, sep);
for (const opt of options) {
expect(opt.startsWith("--delete")).toBe(false);
expect(opt.startsWith("--remove")).toBe(false);
expect(opt.startsWith("--force")).toBe(false);
expect(opt).not.toBe("-s");
expect(opt).not.toBe("--protect-args");
}
// Adversarial flag-shaped paths appear ONLY as operands (index > sep).
for (const adv of ADVERSARIAL_LOCALS) {
for (let i = 0; i < argv.length; i++) {
if (argv[i] === adv) expect(i).toBeGreaterThan(sep);
}
}
// Dry-run is rsync's NATIVE flag, present iff requested.
expect(options.includes("--dry-run")).toBe(plan.dryRun);
}
});
it("the -e ssh command is built ONLY from registry fields; local-local plans carry no -e at all", () => {
const withRemote = buildRsyncArgv({ src: local("/tmp/a"), dst: remote("/srv/b"), dryRun: false });
const e = withRemote[withRemote.indexOf("-e") + 1]!;
expect(e).toBe("ssh -o ConnectTimeout=10 -l openrig");
const localOnly = buildRsyncArgv({ src: local("/tmp/a"), dst: local("/tmp/b"), dryRun: false });
expect(localOnly.includes("-e")).toBe(false);
});
it("remote operands render as target:path from the registry target", () => {
const argv = buildRsyncArgv({ src: local("/tmp/a.md"), dst: remote("/srv/briefs/a.md"), dryRun: false });
expect(argv[argv.length - 1]).toBe("vps-a.tail.net:/srv/briefs/a.md");
});
});
// ── classification (executor taxonomy minus the daemon class) ───────────────
describe("classifyRsyncResult / runFileCopy", () => {
it("exit 0 → ok with --stats parsed", () => {
const res = classifyRsyncResult(0, "Number of regular files transferred: 1\nTotal transferred file size: 2,048 bytes\n", "");
expect(res).toMatchObject({ ok: true, failedStep: "none", bytesTransferred: 2048, filesTransferred: 1 });
});
it("permission-gate stderr signature → permission-gate (single-source via the exported executor matcher)", () => {
expect(classifyRsyncResult(255, "", "user@host: Permission denied (publickey).").failedStep).toBe("permission-gate");
});
it("exit 255 / connection-class stderr → ssh-unreachable; other non-zero → remote-command-failed; NO daemon class exists", () => {
expect(classifyRsyncResult(255, "", "kex_exchange: Connection refused").failedStep).toBe("ssh-unreachable");
expect(classifyRsyncResult(12, "", "rsync: connection unexpectedly closed").failedStep).toBe("ssh-unreachable");
expect(classifyRsyncResult(23, "", "rsync: some files could not be transferred").failedStep).toBe("remote-command-failed");
});
it("missing local rsync (spawn ENOENT) → rsync-missing with the install hint (arch Q6)", async () => {
const fakeSpawn = (() => {
const handlers: Record<string, (arg?: unknown) => void> = {};
return {
stdout: { on: () => {} },
stderr: { on: () => {} },
on: (evt: string, cb: (arg?: unknown) => void) => {
handlers[evt] = cb;
if (evt === "close") {
const err = new Error("spawn rsync ENOENT") as NodeJS.ErrnoException;
err.code = "ENOENT";
handlers["error"]?.(err);
}
},
};
}) as never;
const res = await runFileCopy({ src: local("/tmp/a"), dst: local("/tmp/b"), dryRun: true }, { spawn: fakeSpawn });
expect(res.failedStep).toBe("rsync-missing");
expect(res.hint).toContain("brew install rsync");
});
});