Compare commits

...
Author SHA1 Message Date
v-openrig-build 166dc60f66 test: reproduce terminal send framing in an owned shell 2026-10-17 08:05:44 +00:00
@@ -0,0 +1,140 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Command } from "commander";
import { Hono } from "hono";
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { sendCommand, type SendDeps } from "../../cli/src/commands/send.js";
import { senderIdentityHeaders } from "../../cli/src/client.js";
import { TmuxAdapter } from "../src/adapters/tmux.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { SessionTransport } from "../src/domain/session-transport.js";
import { transportRoutes } from "../src/routes/transport.js";
import { createFullTestDb } from "./helpers/test-app.js";
const BODY = "printf '%s\\n' 'terminal-body-marker' 'From: user-authored' '---' '↩ Reply: user-authored'";
const OUTPUT = "terminal-body-marker\nFrom: user-authored\n---\n↩ Reply: user-authored\n";
// Commander -> actual routes/SQLite/transport/file-backed adapter. The argv seam
// models a paste and Enter; only an owned /bin/sh executes that exact stream.
// This is not a live pane, SSH connection, agent or terminal-emulator claim.
describe("terminal-runtime send framing", () => {
let db: ReturnType<typeof createFullTestDb>;
let scratch: string;
beforeEach(() => {
db = createFullTestDb();
scratch = mkdtempSync(join(tmpdir(), "terminal-framing-"));
vi.stubEnv("OPENRIG_URL", "http://local-fixture:7433");
vi.stubEnv("OPENRIG_SESSION_NAME", "operator@framing");
vi.stubEnv("OPENRIG_HOST_SELECTED", "");
});
afterEach(() => { db.close(); rmSync(scratch, { recursive: true, force: true }); vi.unstubAllEnvs(); });
function fixture(runtime: "terminal" | "claude-code" = "terminal") {
const rigRepo = new RigRepository(db);
const registry = new SessionRegistry(db);
const rig = rigRepo.createRig("framing");
const name = "shell@framing";
const node = rigRepo.addNode(rig.id, "shell", { runtime });
const session = registry.registerSession(node.id, name);
registry.updateStatus(session.id, "running");
registry.updateBinding(node.id, { tmuxSession: name });
const pasted: string[] = [];
const commands: string[][] = [];
const executions: Array<{ status: number | null; stdout: string; stderr: string }> = [];
let buffer = "";
let input = "";
const tmux = new TmuxAdapter(async () => { throw new Error("argv executor expected"); }, {
tmpName: () => join(scratch, "input"), bufferName: () => "terminal-framing",
writeFile: async (_file, text) => { buffer = text; }, unlink: async () => {},
}, async argv => {
commands.push(argv);
if (argv[1] === "paste-buffer") { pasted.push(buffer); input += buffer; }
if (argv[1] === "send-keys" && argv.includes("Enter") && runtime === "terminal") {
const result = spawnSync("/bin/sh", ["-s"], { input: input + "\n", cwd: scratch,
env: { PATH: "/usr/bin:/bin", HOME: scratch }, encoding: "utf8", timeout: 3_000 });
if (result.error) throw result.error;
executions.push({ status: result.status, stdout: result.stdout, stderr: result.stderr });
input = "";
}
return "";
});
tmux.getPanePid = async () => null;
tmux.getPaneCommand = async () => null;
tmux.capturePaneContent = async () => "idle\n❯ ";
const transport = new SessionTransport({ db, rigRepo, sessionRegistry: registry, tmuxAdapter: tmux, sleep: async () => {} });
const app = new Hono();
app.use("*", async (c, next) => { c.set("sessionTransport" as never, transport); await next(); });
app.route("/api/transport", transportRoutes());
const requests: Array<{ path: string; body: Record<string, unknown> }> = [];
const sshArgv: string[][] = [];
const deps = {
lifecycleDeps: { exists: () => false, readFile: () => null, isProcessAlive: () => false,
fetch: async () => ({ ok: true, json: async () => ({ selfHostId: "fixture-host" }) }),
spawn: () => { throw new Error("No lifecycle allowed"); }, kill: () => { throw new Error("No lifecycle allowed"); } },
clientFactory: () => {
const client = { originSelfHostId: undefined as string | undefined,
post: async (path: string, body: Record<string, unknown>) => {
requests.push({ path, body });
const res = await app.request(path, { method: "POST", headers: {
"Content-Type": "application/json", ...senderIdentityHeaders(client.originSelfHostId),
}, body: JSON.stringify(body) });
return { status: res.status, data: await res.json() };
} };
return client;
},
hostRegistryLoader: () => ({ ok: true, registry: { hosts: [
{ id: "remote-http", transport: "http", url: "http://remote-fixture:7433" },
{ id: "remote-ssh", transport: "ssh", target: "fixture.invalid" },
] } }),
crossHostRun: async (_host: unknown, argv: readonly string[]) => {
sshArgv.push([...argv]);
await new Command().addCommand(sendCommand(deps as unknown as SendDeps)).parseAsync(["node", ...argv]);
return { ok: true, exitCode: 0, stdout: "", stderr: "" };
},
};
return { name, pasted, commands, executions, requests, sshArgv, deps: deps as unknown as SendDeps };
}
async function run(f: ReturnType<typeof fixture>, args: string[]) {
const previous = process.exitCode;
process.exitCode = undefined;
const log = vi.spyOn(console, "log").mockImplementation(() => {});
const error = vi.spyOn(console, "error").mockImplementation(() => {});
try {
await new Command().addCommand(sendCommand(f.deps)).parseAsync(["node", "rig", "send", ...args, "--json"]);
return process.exitCode ?? 0;
} finally { log.mockRestore(); error.mockRestore(); process.exitCode = previous; }
}
it.each(["local", "fan-out", "http", "ssh"])("%s delivers only the intended terminal body", async route => {
const f = fixture();
const args = route === "fan-out" ? ["--to", f.name, BODY]
: [f.name, BODY, ...(route === "http" ? ["--host", "remote-http"] : route === "ssh" ? ["--host", "remote-ssh"] : [])];
expect(await run(f, args)).toBe(0);
expect(f.executions).toHaveLength(1);
expect(f.executions[0]!.stdout).toBe(OUTPUT); // Body runs even on main; envelope also runs there.
expect(f.executions[0]!.stderr).toBe("");
expect(f.executions[0]!.status).toBe(0);
expect(f.pasted).toEqual([BODY]); // No guessing at user-authored From:/---/reply bytes.
if (route === "ssh") expect(f.sshArgv[0]!.slice(0, 4)).toEqual(["rig", "send", f.name, BODY]);
});
it("keeps deliberate raw terminal input byte-exact", async () => {
const f = fixture();
expect(await run(f, [f.name, BODY, "--raw"])).toBe(0);
expect(f.pasted).toEqual([BODY]);
expect(f.executions).toEqual([{ status: 0, stdout: OUTPUT, stderr: "" }]);
});
it("keeps a non-terminal envelope with unreadable process observations", async () => {
const f = fixture("claude-code");
expect(await run(f, [f.name, BODY])).toBe(0);
expect(f.pasted).toHaveLength(1);
expect(f.pasted[0]).toContain("From: operator@framing\nTo: " + f.name);
expect(f.pasted[0]).toContain("\n---\n" + BODY + "\n---\n↩ Reply:");
expect(f.executions).toEqual([]);
});
});