feat(FN-4636): complete Step 2 — add native sandbox backend
Fusion-Task-Id: FN-4636 Fusion-Task-Lineage: 38ff2f48-4cb1-42c2-8f64-eb3b8d3d7f2c
This commit is contained in:
72
packages/engine/src/sandbox/__tests__/native.test.ts
Normal file
72
packages/engine/src/sandbox/__tests__/native.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { cwd } from "node:process";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { NativeSandboxBackend } from "../native.js";
|
||||
|
||||
describe("NativeSandboxBackend", () => {
|
||||
it("returns stdout on success", async () => {
|
||||
const backend = new NativeSandboxBackend();
|
||||
const result = await backend.run("node -e 'process.stdout.write(\"ok\")'", {
|
||||
cwd: cwd(),
|
||||
timeoutMs: 5_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toBe("ok");
|
||||
expect(result.timedOut).toBe(false);
|
||||
expect(result.bufferExceeded).toBe(false);
|
||||
});
|
||||
|
||||
it("maps timeout failures", async () => {
|
||||
const backend = new NativeSandboxBackend();
|
||||
const result = await backend.run("node -e 'setTimeout(() => {}, 1000)'", {
|
||||
cwd: cwd(),
|
||||
timeoutMs: 50,
|
||||
maxBuffer: 1024 * 1024,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBeNull();
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.signal).toBe("SIGTERM");
|
||||
});
|
||||
|
||||
it("maps non-zero exits", async () => {
|
||||
const backend = new NativeSandboxBackend();
|
||||
const result = await backend.run("node -e 'process.stderr.write(\"fail\"); process.exit(7)'", {
|
||||
cwd: cwd(),
|
||||
timeoutMs: 5_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(7);
|
||||
expect(result.stderr).toContain("fail");
|
||||
expect(result.timedOut).toBe(false);
|
||||
});
|
||||
|
||||
it("maps maxBuffer failures", async () => {
|
||||
const backend = new NativeSandboxBackend();
|
||||
const result = await backend.run("node -e 'process.stdout.write(\"x\".repeat(5000))'", {
|
||||
cwd: cwd(),
|
||||
timeoutMs: 5_000,
|
||||
maxBuffer: 512,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
expect(result.bufferExceeded).toBe(true);
|
||||
expect(result.exitCode).toBeNull();
|
||||
});
|
||||
|
||||
it("prepare/dispose are idempotent no-ops", async () => {
|
||||
const backend = new NativeSandboxBackend();
|
||||
|
||||
await expect(backend.prepare({ allowNetwork: true })).resolves.toBeUndefined();
|
||||
await expect(backend.prepare({ allowNetwork: false })).resolves.toBeUndefined();
|
||||
await expect(backend.dispose()).resolves.toBeUndefined();
|
||||
await expect(backend.dispose()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
69
packages/engine/src/sandbox/native.ts
Normal file
69
packages/engine/src/sandbox/native.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import type { SandboxBackend, SandboxCapabilities, SandboxPolicy, SandboxRunOptions, SandboxRunResult } from "./types.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export class NativeSandboxBackend implements SandboxBackend {
|
||||
capabilities(): SandboxCapabilities {
|
||||
return {
|
||||
id: "native",
|
||||
supportsNetworkPolicy: false,
|
||||
supportsFilesystemPolicy: false,
|
||||
platform: "any",
|
||||
};
|
||||
}
|
||||
|
||||
async prepare(_policy: SandboxPolicy): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async run(command: string, options: SandboxRunOptions): Promise<SandboxRunResult> {
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(command, {
|
||||
cwd: options.cwd,
|
||||
timeout: options.timeoutMs,
|
||||
maxBuffer: options.maxBuffer,
|
||||
...(options.encoding !== undefined && { encoding: options.encoding }),
|
||||
...(options.shell !== undefined && { shell: options.shell }),
|
||||
...(options.env !== undefined && { env: options.env }),
|
||||
...(options.signal !== undefined && { signal: options.signal }),
|
||||
});
|
||||
|
||||
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 dispose(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user