diff --git a/docs/cli.md b/docs/cli.md index 53da155..af18ced 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -414,16 +414,15 @@ Per-command flags apply to the most recent `--cmd`: ### Socket Mode (Advanced) -If you already have a running sandbox server and a virtio control socket path, -you can send exec requests without creating a VM: +For a running Gondolin session, use the session IPC socket path recorded for +that session to send exec requests without creating a VM: ```bash -gondolin exec --sock /path/to/virtio.sock -- COMMAND [ARGS...] +gondolin exec --sock /path/to/session.sock -- COMMAND [ARGS...] ``` -This is primarily useful when you manage the VM lifecycle yourself (for example -via the programmatic `SandboxServer`/`VM` APIs) and want a separate process to -issue exec requests. +This is primarily useful when a separate process needs to issue exec requests +against an existing Gondolin session. ### `gondolin build` diff --git a/host/bin/gondolin.ts b/host/bin/gondolin.ts index 9b3d6ae..203bf0d 100644 --- a/host/bin/gondolin.ts +++ b/host/bin/gondolin.ts @@ -21,13 +21,6 @@ import { ensureTrufflehogBinary, getTrufflehogStatus, } from "../src/build/trufflehog.ts"; -import { - FrameReader, - buildExecRequest, - decodeMessage, - encodeFrame, - type IncomingMessage, -} from "../src/sandbox/virtio-protocol.ts"; import { attachTty } from "../src/utils/tty-attach.ts"; import { getDefaultBuildConfig, @@ -215,7 +208,7 @@ function usage() { console.log("Usage: gondolin [options]"); console.log("Commands:"); console.log( - " exec Run a command via the virtio socket or in-process VM", + " exec Run a command via a session IPC socket or in-process VM", ); console.log( " bash Start an interactive shell session in the VM (bash -> sh fallback)", @@ -403,6 +396,7 @@ function execUsage() { " gondolin exec [options] -- CMD [ARGS...] (in-process VM mode, no --sock)", ); console.log(); + console.log(" --sock PATH uses a Gondolin session IPC socket."); console.log("Use -- to pass a command and its arguments directly."); console.log("Arguments apply to the most recent --cmd."); console.log(); @@ -1443,82 +1437,104 @@ async function runExecVm(args: ExecArgs) { process.exit(exitCode); } +const EXEC_OUTPUT_WINDOW_BYTES = 1024 * 1024; + function runExecSocket(args: ExecArgs) { - const socket = net.createConnection({ path: args.sock! }); - const reader = new FrameReader(); + // The session socket uses the 5-byte framed JSON/binary IPC protocol, not virtio CBOR. let currentIndex = 0; let inflightId: number | null = null; let exitCode = 0; let closing = false; - const sendNext = () => { - const command = args.commands[currentIndex]; - inflightId = command.id; - const payload = buildCommandPayload(command); - const message = buildExecRequest(command.id, payload); - socket.write(encodeFrame(message)); - }; - const finish = (code?: number) => { if (code !== undefined && exitCode === 0) exitCode = code; if (closing) return; closing = true; - socket.end(); + process.exitCode = exitCode; + client.close(); }; - socket.on("connect", () => { - console.log(`connected to ${args.sock}`); - sendNext(); - }); + const sendNext = () => { + if (currentIndex >= args.commands.length) { + finish(); + return; + } + const command = args.commands[currentIndex]!; + inflightId = command.id; + const payload = buildCommandPayload(command); + client.send({ + type: "exec", + id: command.id, + cmd: payload.cmd, + ...(payload.argv ? { argv: payload.argv } : {}), + ...(payload.env ? { env: payload.env } : {}), + ...(payload.cwd ? { cwd: payload.cwd } : {}), + stdout_window: EXEC_OUTPUT_WINDOW_BYTES, + stderr_window: EXEC_OUTPUT_WINDOW_BYTES, + }); + }; - socket.on("data", (chunk) => { - reader.push(chunk, (frame) => { - const message = decodeMessage(frame) as IncomingMessage; - if (message.t === "exec_output") { - const data = message.p.data; - if (message.p.stream === "stdout") { - process.stdout.write(data); - } else { - process.stderr.write(data); - } - } else if (message.t === "exec_response") { - if (inflightId !== null && message.id !== inflightId) { - console.error( - `unexpected response id ${message.id} (expected ${inflightId})`, - ); - finish(1); - return; - } - const code = message.p.exit_code ?? 1; - const signal = message.p.signal; + const client = connectToSession(args.sock!, { + onConnect() { + console.log(`connected to ${args.sock}`); + sendNext(); + }, + onJson(message: ServerMessage) { + if (closing) return; + if (message.type === "status") return; + if (message.type === "exec_response") { + if (inflightId === null || message.id !== inflightId) return; + const code = message.exit_code ?? 1; + const signal = message.signal; if (signal !== undefined) { console.error(`process exited due to signal ${signal}`); } if (code !== 0 && exitCode === 0) exitCode = code; currentIndex += 1; + inflightId = null; if (currentIndex < args.commands.length) { sendNext(); } else { finish(); } - } else if (message.t === "error") { - console.error(`error ${message.p.code}: ${message.p.message}`); + return; + } + if (message.type === "error") { + if (message.id !== undefined && message.id !== inflightId) return; + console.error(`error ${message.code}: ${message.message}`); finish(1); + return; } - }); - }); - - socket.on("error", (err) => { - console.error(`socket error: ${err.message}`); - finish(1); - }); - - socket.on("end", () => { - if (!closing && exitCode === 0) exitCode = 1; - }); - - socket.on("close", () => { - process.exit(exitCode); + }, + onBinary(frame: Buffer) { + if (closing) return; + const decoded = decodeOutputFrame(frame); + if (inflightId === null || decoded.id !== inflightId) return; + if (decoded.stream === "stdout") { + process.stdout.write(decoded.data); + client.send({ + type: "exec_window", + id: decoded.id, + stdout: decoded.data.length, + }); + } else { + process.stderr.write(decoded.data); + client.send({ + type: "exec_window", + id: decoded.id, + stderr: decoded.data.length, + }); + } + }, + onClose(err?: Error) { + if (closing) return; + if (err) { + console.error(`socket error: ${err.message}`); + finish(1); + return; + } + finish(1); + }, }); } @@ -1531,7 +1547,7 @@ async function runExec(argv: string[] = process.argv.slice(2)) { } if (args.sock) { - // Socket mode (direct virtio connection) + // Socket mode (session IPC) runExecSocket(args); } else { args.common.secrets = await resolveSecretHosts(args.common.secrets); diff --git a/host/src/session-registry.ts b/host/src/session-registry.ts index c62096d..9e72911 100644 --- a/host/src/session-registry.ts +++ b/host/src/session-registry.ts @@ -724,6 +724,8 @@ export class SessionIpcServer { } export type IpcClientCallbacks = { + /** called after the external session IPC socket connects */ + onConnect?: () => void; /** called with JSON server messages */ onJson: (message: ServerMessage) => void; /** called with binary output frames */ @@ -748,6 +750,10 @@ export function connectToSession( let expectedLength: number | null = null; let frameType: number | null = null; + socket.on("connect", () => { + callbacks.onConnect?.(); + }); + socket.on("data", (chunk: Buffer) => { readBuffer = Buffer.concat([readBuffer, chunk]); diff --git a/host/test/cli-exec-socket.test.ts b/host/test/cli-exec-socket.test.ts new file mode 100644 index 0000000..158ad5b --- /dev/null +++ b/host/test/cli-exec-socket.test.ts @@ -0,0 +1,454 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + encodeOutputFrame, + type ClientMessage, + type ServerMessage, +} from "../src/sandbox/control-protocol.ts"; + +const hostDir = path.join(import.meta.dirname, ".."); + +function runCli(args: string[]): Promise<{ + status: number | null; + stdout: string; + stderr: string; +}> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, ["bin/gondolin.ts", ...args], { + cwd: hostDir, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8").on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.setEncoding("utf8").on("data", (chunk) => { + stderr += chunk; + }); + const timer = setTimeout(() => child.kill("SIGKILL"), 15000); + child.on("error", reject); + child.on("close", (status) => { + clearTimeout(timer); + resolve({ status, stdout, stderr }); + }); + }); +} + +function encodeServerFrame(type: 0 | 1, payload: Buffer): Buffer { + const header = Buffer.alloc(5); + header.writeUInt8(type, 0); + header.writeUInt32BE(payload.length, 1); + return Buffer.concat([header, payload]); +} + +function sendJson(socket: net.Socket, message: ServerMessage): void { + socket.write( + encodeServerFrame(0, Buffer.from(JSON.stringify(message), "utf8")), + ); +} + +function sendOutput( + socket: net.Socket, + id: number, + stream: "stdout" | "stderr", + data: Buffer, +): void { + socket.write(encodeServerFrame(1, encodeOutputFrame(id, stream, data))); +} + +function readClientMessages( + socket: net.Socket, + onMessage: (message: ClientMessage) => void, +): void { + let buffer = Buffer.alloc(0); + socket.on("data", (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + while (buffer.length >= 4) { + const length = buffer.readUInt32BE(0); + if (buffer.length < 4 + length) return; + const payload = buffer.subarray(4, 4 + length); + buffer = buffer.subarray(4 + length); + onMessage(JSON.parse(payload.toString("utf8")) as ClientMessage); + } + }); +} + +test("cli exec help describes a session IPC socket", async () => { + const result = await runCli(["exec", "--help"]); + assert.equal(result.status, 0); + assert.match(result.stdout, /session IPC socket/i); + assert.doesNotMatch(result.stdout, /via the virtio socket/i); +}); + +test("cli exec --sock does not report connected when connect fails", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-cli-exec-")); + const socketPath = path.join(dir, "missing.sock"); + try { + const result = await runCli(["exec", "--sock", socketPath, "--", "true"]); + assert.equal(result.status, 1); + assert.doesNotMatch(result.stdout, /connected to/); + assert.match(result.stderr, /socket error: connect ENOENT/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("cli exec --sock fails when the session closes during an exec", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-cli-exec-")); + const socketPath = path.join(dir, "session.sock"); + const server = net.createServer((socket) => { + readClientMessages(socket, (message) => { + if (message.type === "exec") socket.end(); + }); + }); + + await new Promise((resolve) => server.listen(socketPath, resolve)); + try { + const result = await runCli(["exec", "--sock", socketPath, "--", "true"]); + assert.equal(result.status, 1); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("cli exec --sock replenishes credits for large stdout and stderr", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-cli-exec-")); + const socketPath = path.join(dir, "session.sock"); + const stdoutOutput = Buffer.alloc(1280 * 1024, "x"); + const stderrOutput = Buffer.alloc(1280 * 1024, "y"); + const server = net.createServer((socket) => { + let stdoutOffset = 0; + let stderrOffset = 0; + let stdoutCredit = 0; + let stderrCredit = 0; + let requestId = 0; + let responded = false; + let pumping = false; + let pumpRequested = false; + const expectedWindows: Array<{ + id: number; + stream: "stdout" | "stderr"; + length: number; + }> = []; + + const sendTrackedOutput = ( + id: number, + stream: "stdout" | "stderr", + data: Buffer, + ) => { + expectedWindows.push({ id, stream, length: data.length }); + sendOutput(socket, id, stream, data); + }; + + const pump = () => { + if (pumping) { + pumpRequested = true; + return; + } + + pumping = true; + do { + pumpRequested = false; + while (stdoutCredit > 0 && stdoutOffset < stdoutOutput.length) { + const length = Math.min( + 8192, + stdoutCredit, + stdoutOutput.length - stdoutOffset, + ); + const output = stdoutOutput.subarray( + stdoutOffset, + stdoutOffset + length, + ); + stdoutOffset += length; + stdoutCredit -= length; + sendTrackedOutput(requestId, "stdout", output); + } + while (stderrCredit > 0 && stderrOffset < stderrOutput.length) { + const length = Math.min( + 8192, + stderrCredit, + stderrOutput.length - stderrOffset, + ); + const output = stderrOutput.subarray( + stderrOffset, + stderrOffset + length, + ); + stderrOffset += length; + stderrCredit -= length; + sendTrackedOutput(requestId, "stderr", output); + } + if ( + !responded && + stdoutOffset === stdoutOutput.length && + stderrOffset === stderrOutput.length && + expectedWindows.length === 0 + ) { + assert.equal(expectedWindows.length, 0); + responded = true; + sendJson(socket, { + type: "exec_response", + id: requestId, + exit_code: 0, + }); + } + } while (pumpRequested); + pumping = false; + }; + + readClientMessages(socket, (message) => { + if (message.type === "exec") { + requestId = message.id; + assert.equal(message.stdout_window, 1024 * 1024); + assert.equal(message.stderr_window, 1024 * 1024); + stdoutCredit = message.stdout_window ?? 256 * 1024; + stderrCredit = message.stderr_window ?? 256 * 1024; + pump(); + } else if (message.type === "exec_window") { + const expected = expectedWindows.shift(); + assert.ok(expected, "received unexpected exec_window"); + assert.equal(message.id, requestId); + if (expected.stream === "stdout") { + assert.deepEqual(message, { + type: "exec_window", + id: expected.id, + stdout: expected.length, + }); + } else { + assert.deepEqual(message, { + type: "exec_window", + id: expected.id, + stderr: expected.length, + }); + } + stdoutCredit += message.stdout ?? 0; + stderrCredit += message.stderr ?? 0; + pump(); + } + }); + }); + + await new Promise((resolve) => server.listen(socketPath, resolve)); + try { + const result = await runCli([ + "exec", + "--sock", + socketPath, + "--", + "large-output", + ]); + assert.equal(result.status, 0); + assert.equal( + result.stdout, + `connected to ${socketPath}\n${stdoutOutput.toString()}`, + ); + assert.equal(result.stderr, stderrOutput.toString()); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("cli exec --sock ignores buffered output after its response", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-cli-exec-")); + const socketPath = path.join(dir, "session.sock"); + const server = net.createServer((socket) => { + readClientMessages(socket, (message) => { + if (message.type !== "exec") return; + socket.write( + Buffer.concat([ + encodeServerFrame( + 0, + Buffer.from( + JSON.stringify({ + type: "exec_response", + id: message.id, + exit_code: 0, + } satisfies ServerMessage), + "utf8", + ), + ), + encodeServerFrame( + 1, + encodeOutputFrame(message.id, "stdout", Buffer.from("late")), + ), + ]), + ); + }); + }); + + await new Promise((resolve) => server.listen(socketPath, resolve)); + try { + const result = await runCli(["exec", "--sock", socketPath, "--", "true"]); + assert.equal(result.status, 0); + assert.equal(result.stdout, `connected to ${socketPath}\n`); + assert.equal(result.stderr, ""); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("cli exec --sock ignores stale errors from a previous command", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-cli-exec-")); + const socketPath = path.join(dir, "session.sock"); + const server = net.createServer((socket) => { + readClientMessages(socket, (message) => { + if (message.type !== "exec") return; + if (message.id === 1) { + sendJson(socket, { type: "exec_response", id: 1, exit_code: 0 }); + return; + } + + sendJson(socket, { + type: "error", + id: 1, + code: "stale_error", + message: "from previous command", + }); + sendJson(socket, { + type: "exec_response", + id: message.id, + exit_code: 0, + }); + }); + }); + + await new Promise((resolve) => server.listen(socketPath, resolve)); + try { + const result = await runCli([ + "exec", + "--sock", + socketPath, + "--cmd", + "first", + "--cmd", + "second", + ]); + assert.equal(result.status, 0); + assert.equal(result.stderr, ""); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("cli exec --sock ignores stale frames from a previous command", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-cli-exec-")); + const socketPath = path.join(dir, "session.sock"); + const server = net.createServer((socket) => { + readClientMessages(socket, (message) => { + if (message.type !== "exec") return; + if (message.id === 1) { + sendJson(socket, { type: "exec_response", id: 1, exit_code: 0 }); + return; + } + + socket.write( + Buffer.concat([ + encodeServerFrame( + 1, + encodeOutputFrame(1, "stdout", Buffer.from("stale")), + ), + encodeServerFrame( + 0, + Buffer.from( + JSON.stringify({ + type: "exec_response", + id: 1, + exit_code: 1, + } satisfies ServerMessage), + "utf8", + ), + ), + encodeServerFrame( + 1, + encodeOutputFrame(message.id, "stdout", Buffer.from("current")), + ), + encodeServerFrame( + 0, + Buffer.from( + JSON.stringify({ + type: "exec_response", + id: message.id, + exit_code: 0, + } satisfies ServerMessage), + "utf8", + ), + ), + ]), + ); + }); + }); + + await new Promise((resolve) => server.listen(socketPath, resolve)); + try { + const result = await runCli([ + "exec", + "--sock", + socketPath, + "--cmd", + "first", + "--cmd", + "second", + ]); + assert.equal(result.status, 0); + assert.equal(result.stdout, `connected to ${socketPath}\ncurrent`); + assert.equal(result.stderr, ""); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("cli exec --sock runs commands sequentially and preserves the first failure", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gondolin-cli-exec-")); + const socketPath = path.join(dir, "session.sock"); + const received: Array<{ id: number; cmd: string }> = []; + let active = 0; + const server = net.createServer((socket) => { + readClientMessages(socket, (message) => { + if (message.type !== "exec") return; + active += 1; + assert.equal(active, 1); + received.push({ id: message.id, cmd: message.cmd }); + queueMicrotask(() => { + sendJson(socket, { + type: "exec_response", + id: message.id, + exit_code: message.cmd === "first" ? 7 : 3, + }); + active -= 1; + }); + }); + }); + + await new Promise((resolve) => server.listen(socketPath, resolve)); + try { + const result = await runCli([ + "exec", + "--sock", + socketPath, + "--cmd", + "first", + "--cmd", + "second", + ]); + assert.deepEqual( + received.map(({ cmd }) => cmd), + ["first", "second"], + ); + assert.equal(result.status, 7); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + fs.rmSync(dir, { recursive: true, force: true }); + } +});