feat(FN-4642): complete Step 2 — add container backend prototype

Fusion-Task-Id: FN-4642
Fusion-Task-Lineage: a42a36f8-2d2a-43fe-b7c1-d7cf1ad321c3
This commit is contained in:
Fusion (runfusion.ai)
2026-05-16 00:34:57 -07:00
committed by gsxdsm
parent 13e74488a4
commit d8768a280f
2 changed files with 361 additions and 0 deletions

View File

@@ -0,0 +1,112 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as containerArgv from "../container-argv.js";
import { ContainerSandboxBackend } from "../container.js";
const { mockExec, mockExecFile } = vi.hoisted(() => ({
mockExec: vi.fn(),
mockExecFile: vi.fn(),
}));
vi.mock("node:child_process", () => ({
exec: mockExec,
execFile: mockExecFile,
spawn: vi.fn(),
}));
describe("ContainerSandboxBackend", () => {
beforeEach(() => {
vi.clearAllMocks();
mockExec.mockImplementation((_command: string, _options: unknown, callback: (error: Error | null, stdout: string, stderr: string) => void) => {
callback(null, "podman version", "");
return {} as never;
});
});
it("returns success result when command succeeds", async () => {
mockExecFile.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: (error: Error | null, stdout: string, stderr: string) => void) => {
callback(null, { stdout: "ok", stderr: "" } as unknown as string, "");
return {} as never;
},
);
const backend = new ContainerSandboxBackend({ runtime: "podman" });
await backend.prepare({ allowNetwork: true });
const result = await backend.run("echo ok", { cwd: "/tmp/work", timeoutMs: 1000, maxBuffer: 1024 });
expect(result).toMatchObject({
stdout: "ok",
stderr: "",
exitCode: 0,
timedOut: false,
bufferExceeded: false,
});
});
it("maps non-zero exit without throwing", async () => {
mockExecFile.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: (error: Error) => void) => {
callback({ code: 2, stdout: "", stderr: "bad" } as unknown as Error);
return {} as never;
},
);
const backend = new ContainerSandboxBackend({ runtime: "podman" });
const result = await backend.run("false", { cwd: "/tmp/work", timeoutMs: 1000, maxBuffer: 1024 });
expect(result.exitCode).toBe(2);
expect(result.stderr).toBe("bad");
});
it("maps timeout errors", async () => {
mockExecFile.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: (error: Error) => void) => {
callback({ killed: true, signal: "SIGTERM", message: "Command timed out" } as unknown as Error);
return {} as never;
},
);
const backend = new ContainerSandboxBackend({ runtime: "podman" });
const result = await backend.run("sleep 10", { cwd: "/tmp/work", timeoutMs: 1000, maxBuffer: 1024 });
expect(result.timedOut).toBe(true);
expect(result.signal).toBe("SIGTERM");
});
it("maps maxBuffer errors", async () => {
mockExecFile.mockImplementation(
(_file: string, _args: string[], _options: unknown, callback: (error: Error) => void) => {
callback({ code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER", message: "maxBuffer" } as unknown as Error);
return {} as never;
},
);
const backend = new ContainerSandboxBackend({ runtime: "podman" });
const result = await backend.run("cat big", { cwd: "/tmp/work", timeoutMs: 1000, maxBuffer: 10 });
expect(result.bufferExceeded).toBe(true);
});
it("returns structured spawnError when runtime probe fails", async () => {
const runtimeError = Object.assign(new Error("not found"), { code: "ENOENT" });
mockExec.mockImplementation((_command: string, _options: unknown, callback: (error: Error) => void) => {
callback(runtimeError);
return {} as never;
});
const argvSpy = vi.spyOn(containerArgv, "buildContainerArgv");
const backend = new ContainerSandboxBackend({ runtime: "podman" });
const result = await backend.run("echo ok", { cwd: "/tmp/work", timeoutMs: 1000, maxBuffer: 1024 });
expect(result.exitCode).toBeNull();
expect(result.spawnError).toBe(runtimeError);
expect(mockExecFile).not.toHaveBeenCalled();
expect(argvSpy).not.toHaveBeenCalled();
});
it("reports configured runtime through capabilities", () => {
const backend = new ContainerSandboxBackend({ runtime: "docker" });
expect(backend.capabilities().id).toBe("docker");
});
});

View File

@@ -0,0 +1,249 @@
import { exec, execFile, spawn } from "node:child_process";
import { promisify } from "node:util";
import { buildContainerArgv } from "./container-argv.js";
import type {
SandboxBackend,
SandboxCapabilities,
SandboxPolicy,
SandboxRunOptions,
SandboxRunResult,
SandboxRunStreamingOptions,
SandboxStreamingResult,
} from "./types.js";
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
/**
* Experimental container-backed sandbox execution using Podman or Docker.
*/
export class ContainerSandboxBackend implements SandboxBackend {
private readonly runtime: "podman" | "docker";
private policy: SandboxPolicy = { allowNetwork: true };
private probeCompleted = false;
private runtimeAvailable = true;
private runtimeProbeError: Error | undefined;
constructor(options?: { runtime?: "podman" | "docker" }) {
this.runtime = options?.runtime ?? "podman";
}
capabilities(): SandboxCapabilities {
return {
id: this.runtime,
supportsNetworkPolicy: true,
supportsFilesystemPolicy: false,
supportsStreaming: true,
platform: ["linux", "darwin"],
};
}
async prepare(policy: SandboxPolicy): Promise<void> {
this.policy = policy;
if (this.probeCompleted) {
return;
}
try {
await execAsync(`${this.runtime} --version`, { timeout: 5_000, maxBuffer: 1024 * 1024 });
this.runtimeAvailable = true;
this.runtimeProbeError = undefined;
} catch (error) {
this.runtimeAvailable = false;
this.runtimeProbeError = error as Error;
} finally {
this.probeCompleted = true;
}
}
async run(command: string, options: SandboxRunOptions): Promise<SandboxRunResult> {
if (!this.probeCompleted) {
await this.prepare(this.policy);
}
if (!this.runtimeAvailable) {
return {
stdout: "",
stderr: "",
exitCode: null,
signal: null,
timedOut: false,
bufferExceeded: false,
spawnError: this.runtimeProbeError,
};
}
const argv = buildContainerArgv(this.runtime, command, options, this.policy);
try {
const execResult = await execFileAsync(argv[0]!, argv.slice(1), {
cwd: options.cwd,
timeout: options.timeoutMs,
maxBuffer: options.maxBuffer,
encoding: options.encoding ?? "utf-8",
signal: options.signal,
});
const stdout = typeof execResult === "object" && execResult && "stdout" in execResult ? execResult.stdout : execResult;
const stderr = typeof execResult === "object" && execResult && "stderr" in execResult ? execResult.stderr : "";
return {
stdout: stdout?.toString?.() ?? "",
stderr: stderr?.toString?.() ?? "",
exitCode: 0,
signal: null,
timedOut: false,
bufferExceeded: false,
};
} catch (error) {
const errObj = error as Record<string, unknown>;
const code = errObj.code;
const status = typeof errObj.status === "number" ? errObj.status : null;
const exitCode = typeof code === "number" ? code : status;
const message = String(errObj.message ?? "");
return {
stdout: typeof (errObj.stdout as { toString?: unknown })?.toString === "function" ? String(errObj.stdout) : "",
stderr: typeof (errObj.stderr as { toString?: unknown })?.toString === "function" ? String(errObj.stderr) : "",
exitCode,
signal: (errObj.signal as NodeJS.Signals | null | undefined) ?? null,
bufferExceeded:
code === "ENOBUFS"
|| code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
|| message.includes("maxBuffer"),
timedOut:
code === "ETIMEDOUT"
|| (errObj.killed === true && (errObj.signal === "SIGTERM" || message.includes("timed out"))),
spawnError: code === "ENOENT" || code === "EACCES" ? (error as Error) : undefined,
};
}
}
async runStreaming(command: string, options: SandboxRunStreamingOptions): Promise<SandboxStreamingResult> {
if (options.signal?.aborted) {
return {
outcome: "aborted",
phase: "pre-start",
stdout: "",
stderr: "",
};
}
if (!this.probeCompleted) {
await this.prepare(this.policy);
}
if (!this.runtimeAvailable) {
return {
outcome: "spawn-error",
error: this.runtimeProbeError ?? new Error(`${this.runtime} unavailable`),
stdout: "",
stderr: "",
};
}
const runOptions: SandboxRunOptions = {
cwd: options.cwd,
timeoutMs: options.timeout,
maxBuffer: options.maxBuffer,
env: options.env,
signal: options.signal,
};
const argv = buildContainerArgv(this.runtime, command, runOptions, this.policy);
return await new Promise((resolve) => {
const child = spawn(argv[0]!, argv.slice(1), {
cwd: options.cwd,
stdio: ["ignore", "pipe", "pipe"],
env: options.env,
});
let stdout = "";
let stderr = "";
let stdoutOverflow = false;
let stderrOverflow = false;
let timedOut = false;
let aborted = false;
let settled = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
setTimeout(() => {
if (settled) return;
child.kill("SIGKILL");
}, 5_000).unref();
}, options.timeout);
timer.unref();
const onAbort = () => {
aborted = true;
child.kill("SIGTERM");
setTimeout(() => {
if (settled) return;
child.kill("SIGKILL");
}, 5_000).unref();
};
options.signal?.addEventListener("abort", onAbort, { once: true });
child.stdout?.on("data", (chunk: Buffer) => {
if (stdoutOverflow) return;
if (stdout.length + chunk.length > options.maxBuffer) {
stdoutOverflow = true;
stdout += chunk.toString("utf-8", 0, options.maxBuffer - stdout.length);
return;
}
stdout += chunk.toString("utf-8");
});
child.stderr?.on("data", (chunk: Buffer) => {
if (stderrOverflow) return;
if (stderr.length + chunk.length > options.maxBuffer) {
stderrOverflow = true;
stderr += chunk.toString("utf-8", 0, options.maxBuffer - stderr.length);
return;
}
stderr += chunk.toString("utf-8");
});
const finish = (err: NodeJS.ErrnoException | null, code: number | null, signal: NodeJS.Signals | null) => {
if (settled) return;
settled = true;
clearTimeout(timer);
options.signal?.removeEventListener("abort", onAbort);
if (aborted) {
resolve({ outcome: "aborted", phase: "mid-flight", stdout, stderr });
return;
}
if (timedOut) {
resolve({ outcome: "timeout", timeoutMs: options.timeout, stdout, stderr });
return;
}
if (err) {
resolve({ outcome: "spawn-error", error: err, stdout, stderr });
return;
}
if (code === 0) {
resolve({ outcome: "success", stdout, stderr, bufferOverflow: stdoutOverflow || stderrOverflow });
return;
}
resolve({ outcome: "non-zero-exit", stdout, stderr, exitCode: code, signal });
};
child.on("error", (err) => finish(err, null, null));
child.on("close", (code, signal) => finish(null, code, signal));
});
}
async dispose(): Promise<void> {
return Promise.resolve();
}
}