feat(FN-4638): complete Step 4 — implement sandbox-exec backend
Fusion-Task-Id: FN-4638 Fusion-Task-Lineage: a6dcc3d9-b7ab-42c1-af88-c9e09f770d92
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
import { cwd } from "node:process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { detectMock, policyToProfileMock, presetMock, nativeRunMock, nativePrepareMock, nativeDisposeMock, loggerMock, execMock } = vi.hoisted(() => ({
|
||||
detectMock: vi.fn(),
|
||||
policyToProfileMock: vi.fn(),
|
||||
presetMock: vi.fn(),
|
||||
nativeRunMock: vi.fn(),
|
||||
nativePrepareMock: vi.fn(),
|
||||
nativeDisposeMock: vi.fn(),
|
||||
loggerMock: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
execMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
exec: execMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../sandbox/sandbox-exec-detect.js", () => ({
|
||||
detectSandboxExec: detectMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../sandbox/sandbox-exec-policy.js", () => ({
|
||||
policyToSbplProfile: policyToProfileMock,
|
||||
fusionWorktreePreset: presetMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../logger.js", () => ({
|
||||
createLogger: () => loggerMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../sandbox/native.js", () => ({
|
||||
NativeSandboxBackend: class {
|
||||
prepare = nativePrepareMock;
|
||||
run = nativeRunMock;
|
||||
dispose = nativeDisposeMock;
|
||||
capabilities() {
|
||||
return { id: "native", supportsNetworkPolicy: false, supportsFilesystemPolicy: false, platform: "any" };
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
describe("SandboxExecBackend", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
detectMock.mockReset();
|
||||
policyToProfileMock.mockReset();
|
||||
presetMock.mockReset();
|
||||
nativeRunMock.mockReset();
|
||||
nativePrepareMock.mockReset();
|
||||
nativeDisposeMock.mockReset();
|
||||
execMock.mockReset();
|
||||
(execMock as unknown as Record<symbol, unknown>)[promisify.custom] = vi.fn();
|
||||
loggerMock.info.mockReset();
|
||||
loggerMock.warn.mockReset();
|
||||
loggerMock.error.mockReset();
|
||||
loggerMock.debug.mockReset();
|
||||
});
|
||||
|
||||
it("reports capabilities", async () => {
|
||||
const { SandboxExecBackend } = await import("../../sandbox/sandbox-exec-backend.js");
|
||||
const backend = new SandboxExecBackend();
|
||||
expect(backend.capabilities().id).toBe("sandbox-exec");
|
||||
});
|
||||
|
||||
it("throws on prepare fail-hard when unavailable", async () => {
|
||||
detectMock.mockResolvedValue({ available: false, reason: "not-installed" });
|
||||
const { SandboxExecBackend, SandboxUnavailableError } = await import("../../sandbox/sandbox-exec-backend.js");
|
||||
const backend = new SandboxExecBackend();
|
||||
|
||||
await expect(backend.prepare({ allowNetwork: true })).rejects.toBeInstanceOf(SandboxUnavailableError);
|
||||
});
|
||||
|
||||
it("fallback-native delegates runs and logs once", async () => {
|
||||
detectMock.mockResolvedValue({ available: false, reason: "not-installed" });
|
||||
nativeRunMock.mockResolvedValue({ stdout: "native", stderr: "", exitCode: 0, signal: null, timedOut: false, bufferExceeded: false });
|
||||
|
||||
const { SandboxExecBackend } = await import("../../sandbox/sandbox-exec-backend.js");
|
||||
const backend = new SandboxExecBackend();
|
||||
|
||||
await backend.prepare({ allowNetwork: true, failureMode: "fallback-native" } as any);
|
||||
await backend.run("echo a", { cwd: cwd(), timeoutMs: 1_000, maxBuffer: 1024 });
|
||||
await backend.run("echo b", { cwd: cwd(), timeoutMs: 1_000, maxBuffer: 1024 });
|
||||
|
||||
expect(nativeRunMock).toHaveBeenCalledTimes(2);
|
||||
expect(loggerMock.warn).toHaveBeenCalledTimes(1);
|
||||
expect(loggerMock.warn.mock.calls[0]?.[0]).toContain("sandbox:fallback");
|
||||
});
|
||||
|
||||
it("executes sandbox-exec command and maps success", async () => {
|
||||
detectMock.mockResolvedValue({ available: true, path: "/usr/bin/sandbox-exec" });
|
||||
presetMock.mockReturnValue({ allowNetwork: true });
|
||||
policyToProfileMock.mockReturnValue("(version 1)\n(allow default)");
|
||||
((execMock as unknown as Record<symbol, unknown>)[promisify.custom] as ReturnType<typeof vi.fn>).mockResolvedValue({ stdout: "ok", stderr: "" });
|
||||
|
||||
const { SandboxExecBackend } = await import("../../sandbox/sandbox-exec-backend.js");
|
||||
const backend = new SandboxExecBackend();
|
||||
await backend.prepare({ allowNetwork: true, allowedWritePaths: [cwd()] });
|
||||
const result = await backend.run("echo hello", { cwd: cwd(), timeoutMs: 2_000, maxBuffer: 1024 * 1024 });
|
||||
|
||||
const execAsyncMock = (execMock as unknown as Record<symbol, unknown>)[promisify.custom] as ReturnType<typeof vi.fn>;
|
||||
const cmd = execAsyncMock.mock.calls.find((call) => String(call[0]).includes("sandbox-exec"))?.[0] as string;
|
||||
expect(cmd).toContain("sandbox-exec -p");
|
||||
expect(cmd).toContain("/bin/sh -c");
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toBe("ok");
|
||||
expect(loggerMock.info).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps timeout and maxBuffer failures", async () => {
|
||||
detectMock.mockResolvedValue({ available: true, path: "/usr/bin/sandbox-exec" });
|
||||
presetMock.mockReturnValue({ allowNetwork: true });
|
||||
policyToProfileMock.mockReturnValue("(version 1)\n(allow default)");
|
||||
const err = Object.assign(new Error("timed out maxBuffer"), {
|
||||
code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER",
|
||||
killed: true,
|
||||
signal: "SIGTERM",
|
||||
stdout: "",
|
||||
stderr: "boom",
|
||||
});
|
||||
((execMock as unknown as Record<symbol, unknown>)[promisify.custom] as ReturnType<typeof vi.fn>).mockRejectedValue(err);
|
||||
|
||||
const { SandboxExecBackend } = await import("../../sandbox/sandbox-exec-backend.js");
|
||||
const backend = new SandboxExecBackend();
|
||||
await backend.prepare({ allowNetwork: true, allowedWritePaths: [cwd()] });
|
||||
const result = await backend.run("echo hello", { cwd: cwd(), timeoutMs: 2_000, maxBuffer: 1 });
|
||||
|
||||
expect(result.bufferExceeded).toBe(true);
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(loggerMock.warn).toHaveBeenCalledWith(expect.stringContaining("sandbox:failure"));
|
||||
});
|
||||
|
||||
it.skipIf(process.platform !== "darwin")("runs real sandbox-exec hello integration", async () => {
|
||||
const childProcess = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
const { promisify } = await import("node:util");
|
||||
const execAsync = promisify(childProcess.exec);
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync("sandbox-exec -p '(version 1)(allow default)' /bin/echo hello", {
|
||||
cwd: cwd(),
|
||||
timeout: 5_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
expect(stdout).toBe("hello\n");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("not found")) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -21,4 +21,14 @@ describe("resolveSandboxBackend", () => {
|
||||
}
|
||||
expect(backend).toBeInstanceOf(NativeSandboxBackend);
|
||||
});
|
||||
|
||||
it("returns sandbox-exec on darwin when requested", async () => {
|
||||
const { SandboxExecBackend } = await import("../sandbox-exec-backend.js");
|
||||
const backend = resolveSandboxBackend({ backendId: "sandbox-exec" });
|
||||
if (process.platform === "darwin") {
|
||||
expect(backend).toBeInstanceOf(SandboxExecBackend);
|
||||
return;
|
||||
}
|
||||
expect(backend).toBeInstanceOf(NativeSandboxBackend);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BubblewrapBackend } from "./bubblewrap-backend.js";
|
||||
import { NativeSandboxBackend } from "./native.js";
|
||||
import { SandboxExecBackend } from "./sandbox-exec-backend.js";
|
||||
import type { SandboxBackend, SandboxCapabilities } from "./types.js";
|
||||
|
||||
export type {
|
||||
@@ -29,5 +30,12 @@ export function resolveSandboxBackend(options?: { backendId?: SandboxCapabilitie
|
||||
return new BubblewrapBackend();
|
||||
}
|
||||
|
||||
if (options?.backendId === "sandbox-exec") {
|
||||
if (process.platform === "darwin") {
|
||||
return new SandboxExecBackend();
|
||||
}
|
||||
return new NativeSandboxBackend();
|
||||
}
|
||||
|
||||
return new NativeSandboxBackend();
|
||||
}
|
||||
|
||||
180
packages/engine/src/sandbox/sandbox-exec-backend.ts
Normal file
180
packages/engine/src/sandbox/sandbox-exec-backend.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { exec } from "node:child_process";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { detectSandboxExec } from "./sandbox-exec-detect.js";
|
||||
import {
|
||||
fusionWorktreePreset,
|
||||
policyToSbplProfile,
|
||||
type SandboxExecContext,
|
||||
type SandboxExecPolicy,
|
||||
} from "./sandbox-exec-policy.js";
|
||||
import { NativeSandboxBackend } from "./native.js";
|
||||
import type { SandboxBackend, SandboxCapabilities, SandboxPolicy, SandboxRunOptions, SandboxRunResult } from "./types.js";
|
||||
import { createLogger } from "../logger.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const log = createLogger("sandbox-exec");
|
||||
|
||||
export class SandboxUnavailableError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "SandboxUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
function shEscape(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
export class SandboxExecBackend implements SandboxBackend {
|
||||
private currentPolicy: SandboxExecPolicy = { allowNetwork: true };
|
||||
private ctx: SandboxExecContext | null = null;
|
||||
private useNativeFallback = false;
|
||||
private fallbackAlerted = false;
|
||||
|
||||
constructor(private readonly nativeBackend: SandboxBackend = new NativeSandboxBackend()) {}
|
||||
|
||||
capabilities(): SandboxCapabilities {
|
||||
return {
|
||||
id: "sandbox-exec",
|
||||
supportsNetworkPolicy: true,
|
||||
supportsFilesystemPolicy: true,
|
||||
platform: ["darwin"],
|
||||
};
|
||||
}
|
||||
|
||||
async prepare(policy: SandboxPolicy): Promise<void> {
|
||||
const nextPolicy = policy as SandboxExecPolicy;
|
||||
const policyChanged = JSON.stringify(this.currentPolicy) !== JSON.stringify(nextPolicy);
|
||||
if (!policyChanged && this.ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentPolicy = nextPolicy;
|
||||
|
||||
const detect = await detectSandboxExec();
|
||||
if (!detect.available) {
|
||||
if ((nextPolicy.failureMode ?? "fail-hard") === "fallback-native") {
|
||||
this.useNativeFallback = true;
|
||||
if (!this.fallbackAlerted) {
|
||||
log.warn(`[event:sandbox:fallback] backend=sandbox-exec reason=${detect.reason ?? "unknown"}`);
|
||||
this.fallbackAlerted = true;
|
||||
}
|
||||
await this.nativeBackend.prepare(policy);
|
||||
return;
|
||||
}
|
||||
throw new SandboxUnavailableError(
|
||||
"sandbox-exec not available on this host; install Xcode Command Line Tools or set sandbox.failureMode='fallback-native'",
|
||||
);
|
||||
}
|
||||
|
||||
this.useNativeFallback = false;
|
||||
|
||||
const worktreePath = this.currentPolicy.allowedWritePaths?.[0] ?? process.cwd();
|
||||
const repoRootPath = process.cwd();
|
||||
|
||||
let pnpmStorePath = path.join(os.homedir(), "Library/pnpm/store");
|
||||
try {
|
||||
const { stdout } = await execAsync("pnpm store path --silent", {
|
||||
cwd: worktreePath,
|
||||
timeout: 10_000,
|
||||
maxBuffer: 256 * 1024,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
pnpmStorePath = stdout.trim() || pnpmStorePath;
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
|
||||
this.ctx = {
|
||||
worktreePath,
|
||||
repoRootPath,
|
||||
pnpmStorePath,
|
||||
nodeBinPath: process.execPath,
|
||||
homeDir: os.homedir(),
|
||||
tmpDirOverride: os.tmpdir(),
|
||||
};
|
||||
|
||||
log.info("[event:sandbox:prepare] backend=sandbox-exec");
|
||||
}
|
||||
|
||||
async run(command: string, options: SandboxRunOptions): Promise<SandboxRunResult> {
|
||||
const startedAt = Date.now();
|
||||
|
||||
if (this.useNativeFallback) {
|
||||
return this.nativeBackend.run(command, options);
|
||||
}
|
||||
|
||||
if (!this.ctx) {
|
||||
this.ctx = {
|
||||
worktreePath: options.cwd,
|
||||
repoRootPath: options.cwd,
|
||||
pnpmStorePath: path.join(os.homedir(), "Library/pnpm/store"),
|
||||
nodeBinPath: process.execPath,
|
||||
homeDir: os.homedir(),
|
||||
tmpDirOverride: os.tmpdir(),
|
||||
};
|
||||
}
|
||||
|
||||
const basePolicy = this.currentPolicy.allowedWritePaths || this.currentPolicy.allowedReadPaths
|
||||
? this.currentPolicy
|
||||
: fusionWorktreePreset(this.ctx);
|
||||
const profile = policyToSbplProfile(basePolicy, this.ctx);
|
||||
|
||||
const wrappedCommand = `sandbox-exec -p ${shEscape(profile)} /bin/sh -c ${shEscape(command)}`;
|
||||
log.info(`[event:sandbox:run] backend=sandbox-exec cmd=${JSON.stringify(command)}`);
|
||||
|
||||
try {
|
||||
const execOptions: Parameters<typeof exec>[1] = {
|
||||
cwd: options.cwd,
|
||||
timeout: options.timeoutMs,
|
||||
maxBuffer: options.maxBuffer,
|
||||
env: options.env,
|
||||
signal: options.signal,
|
||||
...(options.encoding !== undefined && { encoding: options.encoding }),
|
||||
...(typeof options.shell === "string" && { shell: options.shell }),
|
||||
};
|
||||
const { stdout, stderr } = await execAsync(wrappedCommand, execOptions);
|
||||
|
||||
const result: SandboxRunResult = {
|
||||
stdout: stdout?.toString?.() ?? "",
|
||||
stderr: stderr?.toString?.() ?? "",
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
bufferExceeded: false,
|
||||
};
|
||||
log.info(`[event:sandbox:run] backend=sandbox-exec durationMs=${Date.now() - startedAt} exitCode=0`);
|
||||
return result;
|
||||
} 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 ?? "");
|
||||
|
||||
log.warn(`[event:sandbox:failure] backend=sandbox-exec durationMs=${Date.now() - startedAt} exitCode=${String(exitCode)}`);
|
||||
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> {
|
||||
this.ctx = null;
|
||||
this.useNativeFallback = false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user