feat(FN-4637): complete Step 3 — implement bubblewrap backend
Fusion-Task-Id: FN-4637 Fusion-Task-Lineage: 564c5692-3aaf-4396-9306-a395703cf365
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { cwd } from "node:process";
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { detectMock } = vi.hoisted(() => ({ detectMock: vi.fn() }));
|
||||
|
||||
vi.mock("../../sandbox/bubblewrap-detect.js", () => ({
|
||||
detectBwrap: detectMock,
|
||||
}));
|
||||
|
||||
import { BubblewrapBackend, SandboxUnavailableError } from "../../sandbox/bubblewrap-backend.js";
|
||||
import type { SandboxBackend, SandboxRunResult } from "../../sandbox/types.js";
|
||||
|
||||
describe("BubblewrapBackend", () => {
|
||||
beforeEach(() => {
|
||||
detectMock.mockReset();
|
||||
});
|
||||
|
||||
it("reports bubblewrap capabilities", () => {
|
||||
const backend = new BubblewrapBackend();
|
||||
expect(backend.capabilities().id).toBe("bubblewrap");
|
||||
expect(backend.capabilities().supportsFilesystemPolicy).toBe(true);
|
||||
});
|
||||
|
||||
it("throws on prepare when bwrap unavailable and fail-hard", async () => {
|
||||
detectMock.mockResolvedValue({ available: false, reason: "not-installed" });
|
||||
const backend = new BubblewrapBackend();
|
||||
|
||||
await expect(backend.prepare({ allowNetwork: true })).rejects.toBeInstanceOf(SandboxUnavailableError);
|
||||
});
|
||||
|
||||
it("falls back to native when requested", async () => {
|
||||
detectMock.mockResolvedValue({ available: false, reason: "not-installed" });
|
||||
const runResult: SandboxRunResult = {
|
||||
stdout: "native",
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
bufferExceeded: false,
|
||||
};
|
||||
|
||||
const nativeStub: SandboxBackend = {
|
||||
capabilities: () => ({ id: "native", supportsNetworkPolicy: false, supportsFilesystemPolicy: false, platform: "any" }),
|
||||
prepare: vi.fn(async () => undefined),
|
||||
run: vi.fn(async () => runResult),
|
||||
dispose: vi.fn(async () => undefined),
|
||||
};
|
||||
|
||||
const backend = new BubblewrapBackend(nativeStub);
|
||||
await backend.prepare({ allowNetwork: true, env: {}, allowedReadPaths: [], allowedWritePaths: [], failureMode: "fallback-native" } as any);
|
||||
|
||||
const result = await backend.run("echo hi", { cwd: cwd(), timeoutMs: 1_000, maxBuffer: 1024, encoding: "utf-8" });
|
||||
expect(result.stdout).toBe("native");
|
||||
expect(nativeStub.run).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attempts bwrap execution when available", async () => {
|
||||
detectMock.mockResolvedValue({ available: true, path: "bwrap" });
|
||||
const backend = new BubblewrapBackend();
|
||||
await backend.prepare({ allowNetwork: true });
|
||||
|
||||
const result = await backend.run("echo hello", {
|
||||
cwd: cwd(),
|
||||
timeoutMs: 5_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty("stdout");
|
||||
expect(result).toHaveProperty("stderr");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { BubblewrapBackend } from "../bubblewrap-backend.js";
|
||||
import { NativeSandboxBackend } from "../native.js";
|
||||
import { resolveSandboxBackend } from "../index.js";
|
||||
|
||||
@@ -12,7 +13,12 @@ describe("resolveSandboxBackend", () => {
|
||||
expect(resolveSandboxBackend({ backendId: "native" })).toBeInstanceOf(NativeSandboxBackend);
|
||||
});
|
||||
|
||||
it("returns native for unknown backend id", () => {
|
||||
expect(resolveSandboxBackend({ backendId: "bubblewrap" })).toBeInstanceOf(NativeSandboxBackend);
|
||||
it("returns bubblewrap on linux when requested", () => {
|
||||
const backend = resolveSandboxBackend({ backendId: "bubblewrap" });
|
||||
if (process.platform === "linux") {
|
||||
expect(backend).toBeInstanceOf(BubblewrapBackend);
|
||||
return;
|
||||
}
|
||||
expect(backend).toBeInstanceOf(NativeSandboxBackend);
|
||||
});
|
||||
});
|
||||
|
||||
146
packages/engine/src/sandbox/bubblewrap-backend.ts
Normal file
146
packages/engine/src/sandbox/bubblewrap-backend.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { detectBwrap } from "./bubblewrap-detect.js";
|
||||
import { policyToBwrapArgs, type BubblewrapPolicy } from "./bubblewrap-policy.js";
|
||||
import { NativeSandboxBackend } from "./native.js";
|
||||
import type { SandboxBackend, SandboxCapabilities, SandboxPolicy, SandboxRunOptions, SandboxRunResult } from "./types.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
type FailureMode = "fail-hard" | "fallback-native";
|
||||
|
||||
export class SandboxUnavailableError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "SandboxUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
function quote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
export class BubblewrapBackend implements SandboxBackend {
|
||||
private policy: BubblewrapPolicy = { allowNetwork: true };
|
||||
private useNativeFallback = false;
|
||||
|
||||
constructor(private readonly nativeBackend: SandboxBackend = new NativeSandboxBackend()) {}
|
||||
|
||||
capabilities(): SandboxCapabilities {
|
||||
return {
|
||||
id: "bubblewrap",
|
||||
supportsNetworkPolicy: true,
|
||||
supportsFilesystemPolicy: true,
|
||||
platform: ["linux"],
|
||||
};
|
||||
}
|
||||
|
||||
async prepare(policy: SandboxPolicy): Promise<void> {
|
||||
this.policy = policy as BubblewrapPolicy;
|
||||
|
||||
const detect = await detectBwrap();
|
||||
if (detect.available) return;
|
||||
|
||||
const failureMode = (this.policy as BubblewrapPolicy & { failureMode?: FailureMode }).failureMode ?? "fail-hard";
|
||||
if (failureMode === "fallback-native") {
|
||||
this.useNativeFallback = true;
|
||||
await this.nativeBackend.prepare(policy);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new SandboxUnavailableError(
|
||||
`bubblewrap backend unavailable (${detect.reason ?? "unknown"}). Install bubblewrap and retry.`,
|
||||
);
|
||||
}
|
||||
|
||||
async run(command: string, options: SandboxRunOptions): Promise<SandboxRunResult> {
|
||||
if (this.useNativeFallback) {
|
||||
return this.nativeBackend.run(command, options);
|
||||
}
|
||||
|
||||
const detect = await detectBwrap();
|
||||
if (!detect.available) {
|
||||
const failureMode = (this.policy as BubblewrapPolicy & { failureMode?: FailureMode }).failureMode ?? "fail-hard";
|
||||
if (failureMode === "fallback-native") {
|
||||
return this.nativeBackend.run(command, options);
|
||||
}
|
||||
throw new SandboxUnavailableError(
|
||||
`bubblewrap backend unavailable (${detect.reason ?? "unknown"}). Install bubblewrap and retry.`,
|
||||
);
|
||||
}
|
||||
|
||||
const pnpmStorePath = await this.resolvePnpmStorePath(options.cwd);
|
||||
const args = policyToBwrapArgs(this.policy, {
|
||||
worktreePath: options.cwd,
|
||||
repoRootPath: options.cwd,
|
||||
pnpmStorePath,
|
||||
nodeBinPath: process.execPath,
|
||||
homeDir: process.env.HOME ?? "",
|
||||
envSource: options.env ?? process.env,
|
||||
});
|
||||
|
||||
const bwrapPath = detect.path ?? "bwrap";
|
||||
const shellCommand = `${quote(bwrapPath)} ${args.map(quote).join(" ")} -- /bin/sh -lc ${quote(command)}`;
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(shellCommand, {
|
||||
cwd: options.cwd,
|
||||
timeout: options.timeoutMs,
|
||||
maxBuffer: options.maxBuffer,
|
||||
...(options.encoding !== undefined && { encoding: options.encoding }),
|
||||
...(options.env !== undefined && { env: options.env }),
|
||||
...(options.signal !== undefined && { signal: options.signal }),
|
||||
} as any);
|
||||
|
||||
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> {
|
||||
this.useNativeFallback = false;
|
||||
}
|
||||
|
||||
private async resolvePnpmStorePath(cwd: string): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execAsync("pnpm store path --silent", {
|
||||
cwd,
|
||||
timeout: 10_000,
|
||||
maxBuffer: 256 * 1024,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
const path = stdout.trim();
|
||||
return path || `${process.env.HOME ?? ""}/.local/share/pnpm`;
|
||||
} catch {
|
||||
return `${process.env.HOME ?? ""}/.local/share/pnpm`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BubblewrapBackend } from "./bubblewrap-backend.js";
|
||||
import { NativeSandboxBackend } from "./native.js";
|
||||
import type { SandboxBackend, SandboxCapabilities } from "./types.js";
|
||||
|
||||
@@ -19,11 +20,14 @@ export function __resetSandboxBackendForTests(): void {
|
||||
sandboxBackendOverrideForTests = null;
|
||||
}
|
||||
|
||||
export function resolveSandboxBackend(_options?: { backendId?: SandboxCapabilities["id"] }): SandboxBackend {
|
||||
export function resolveSandboxBackend(options?: { backendId?: SandboxCapabilities["id"] }): SandboxBackend {
|
||||
if (sandboxBackendOverrideForTests) {
|
||||
return sandboxBackendOverrideForTests;
|
||||
}
|
||||
|
||||
// TODO(FN-4637/FN-4638/FN-4642): branch by backend id once additional implementations land.
|
||||
if (options?.backendId === "bubblewrap" && process.platform === "linux") {
|
||||
return new BubblewrapBackend();
|
||||
}
|
||||
|
||||
return new NativeSandboxBackend();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user