From 1e11704d1f371d5a344df515268621a27c005394 Mon Sep 17 00:00:00 2001 From: Fusion Date: Fri, 15 May 2026 11:47:38 -0700 Subject: [PATCH] =?UTF-8?q?feat(FN-4643):=20complete=20Step=202=20?= =?UTF-8?q?=E2=80=94=20add=20native=20runStreaming=20backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fusion-Task-Id: FN-4643 Fusion-Task-Lineage: edcfb4a1-f714-47e8-a919-50b35b40e6af --- .../engine/src/sandbox/bubblewrap-backend.ts | 15 +- packages/engine/src/sandbox/index.ts | 2 + packages/engine/src/sandbox/native.ts | 143 +++++++++++++++++- .../src/sandbox/sandbox-exec-backend.ts | 15 +- packages/engine/src/sandbox/types.ts | 22 +++ 5 files changed, 193 insertions(+), 4 deletions(-) diff --git a/packages/engine/src/sandbox/bubblewrap-backend.ts b/packages/engine/src/sandbox/bubblewrap-backend.ts index f9fb1e5d2..7873d2fbb 100644 --- a/packages/engine/src/sandbox/bubblewrap-backend.ts +++ b/packages/engine/src/sandbox/bubblewrap-backend.ts @@ -4,7 +4,15 @@ 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"; +import type { + SandboxBackend, + SandboxCapabilities, + SandboxPolicy, + SandboxRunOptions, + SandboxRunResult, + SandboxRunStreamingOptions, + SandboxStreamingResult, +} from "./types.js"; const execAsync = promisify(exec); @@ -29,6 +37,7 @@ export class BubblewrapBackend implements SandboxBackend { id: "bubblewrap", supportsNetworkPolicy: true, supportsFilesystemPolicy: true, + supportsStreaming: false, platform: ["linux"], }; } @@ -81,6 +90,10 @@ export class BubblewrapBackend implements SandboxBackend { return this.runBwrapSpawn(bwrapPath, [...policyArgs, "--", "/bin/sh", "-lc", command], options); } + async runStreaming(command: string, options: SandboxRunStreamingOptions): Promise { + return this.nativeBackend.runStreaming(command, options); + } + async dispose(): Promise { this.useNativeFallback = false; this.pnpmStorePathByCwd.clear(); diff --git a/packages/engine/src/sandbox/index.ts b/packages/engine/src/sandbox/index.ts index 3114aeb63..0b42e3dbe 100644 --- a/packages/engine/src/sandbox/index.ts +++ b/packages/engine/src/sandbox/index.ts @@ -9,6 +9,8 @@ export type { SandboxPolicy, SandboxRunOptions, SandboxRunResult, + SandboxRunStreamingOptions, + SandboxStreamingResult, } from "./types.js"; let sandboxBackendOverrideForTests: SandboxBackend | null = null; diff --git a/packages/engine/src/sandbox/native.ts b/packages/engine/src/sandbox/native.ts index 648bed966..a47e91ca2 100644 --- a/packages/engine/src/sandbox/native.ts +++ b/packages/engine/src/sandbox/native.ts @@ -1,7 +1,15 @@ -import { exec } from "node:child_process"; +import { exec, spawn } from "node:child_process"; import { promisify } from "node:util"; -import type { SandboxBackend, SandboxCapabilities, SandboxPolicy, SandboxRunOptions, SandboxRunResult } from "./types.js"; +import type { + SandboxBackend, + SandboxCapabilities, + SandboxPolicy, + SandboxRunOptions, + SandboxRunResult, + SandboxRunStreamingOptions, + SandboxStreamingResult, +} from "./types.js"; const execAsync = promisify(exec); @@ -11,6 +19,7 @@ export class NativeSandboxBackend implements SandboxBackend { id: "native", supportsNetworkPolicy: false, supportsFilesystemPolicy: false, + supportsStreaming: true, platform: "any", }; } @@ -64,6 +73,136 @@ export class NativeSandboxBackend implements SandboxBackend { } } + async runStreaming(command: string, options: SandboxRunStreamingOptions): Promise { + if (options.signal?.aborted) { + return { + outcome: "aborted", + phase: "pre-start", + stdout: "", + stderr: "", + }; + } + + return await new Promise((resolve) => { + const useProcessGroup = process.platform !== "win32"; + const child = spawn(command, { + cwd: options.cwd, + shell: true, + detached: useProcessGroup, + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + COREPACK_ENABLE_DOWNLOAD_PROMPT: "0", + ...(options.env ?? {}), + }, + }); + + let stdout = ""; + let stderr = ""; + let stdoutOverflow = false; + let stderrOverflow = false; + let timedOut = false; + let aborted = false; + let settled = false; + + const killTree = (sig: NodeJS.Signals) => { + if (child.pid === undefined) return; + try { + if (useProcessGroup) { + process.kill(-child.pid, sig); + } else { + child.kill(sig); + } + } catch { + // group may already be gone + } + }; + + const timer = setTimeout(() => { + timedOut = true; + killTree("SIGTERM"); + setTimeout(() => { + if (settled) return; + killTree("SIGKILL"); + }, 5_000).unref(); + }, options.timeout); + timer.unref(); + + const onAbort = () => { + aborted = true; + killTree("SIGTERM"); + setTimeout(() => { + if (settled) return; + killTree("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 { return Promise.resolve(); } diff --git a/packages/engine/src/sandbox/sandbox-exec-backend.ts b/packages/engine/src/sandbox/sandbox-exec-backend.ts index 575babd3a..c309afc3b 100644 --- a/packages/engine/src/sandbox/sandbox-exec-backend.ts +++ b/packages/engine/src/sandbox/sandbox-exec-backend.ts @@ -11,7 +11,15 @@ import { type SandboxExecPolicy, } from "./sandbox-exec-policy.js"; import { NativeSandboxBackend } from "./native.js"; -import type { SandboxBackend, SandboxCapabilities, SandboxPolicy, SandboxRunOptions, SandboxRunResult } from "./types.js"; +import type { + SandboxBackend, + SandboxCapabilities, + SandboxPolicy, + SandboxRunOptions, + SandboxRunResult, + SandboxRunStreamingOptions, + SandboxStreamingResult, +} from "./types.js"; import { createLogger } from "../logger.js"; const execAsync = promisify(exec); @@ -41,6 +49,7 @@ export class SandboxExecBackend implements SandboxBackend { id: "sandbox-exec", supportsNetworkPolicy: true, supportsFilesystemPolicy: true, + supportsStreaming: false, platform: ["darwin"], }; } @@ -173,6 +182,10 @@ export class SandboxExecBackend implements SandboxBackend { } } + async runStreaming(command: string, options: SandboxRunStreamingOptions): Promise { + return this.nativeBackend.runStreaming(command, options); + } + async dispose(): Promise { this.ctx = null; this.useNativeFallback = false; diff --git a/packages/engine/src/sandbox/types.ts b/packages/engine/src/sandbox/types.ts index 0e8fa07fa..c84971d8e 100644 --- a/packages/engine/src/sandbox/types.ts +++ b/packages/engine/src/sandbox/types.ts @@ -27,10 +27,26 @@ export interface SandboxRunResult { spawnError?: Error; } +export interface SandboxRunStreamingOptions { + cwd: string; + timeout: number; + maxBuffer: number; + signal?: AbortSignal; + env?: NodeJS.ProcessEnv; +} + +export type SandboxStreamingResult = + | { outcome: "success"; stdout: string; stderr: string; bufferOverflow: boolean } + | { outcome: "non-zero-exit"; stdout: string; stderr: string; exitCode: number | null; signal: NodeJS.Signals | null } + | { outcome: "timeout"; stdout: string; stderr: string; timeoutMs: number } + | { outcome: "aborted"; stdout: string; stderr: string; phase: "pre-start" | "mid-flight" } + | { outcome: "spawn-error"; error: Error; stdout: string; stderr: string }; + export interface SandboxCapabilities { id: "native" | "sandbox-exec" | "bubblewrap" | "firejail" | "docker" | "podman" | "custom"; supportsNetworkPolicy: boolean; supportsFilesystemPolicy: boolean; + supportsStreaming: boolean; platform: NodeJS.Platform[] | "any"; } @@ -41,6 +57,12 @@ export interface SandboxBackend { prepare(policy: SandboxPolicy): Promise; /** Execute a command in the backend's environment. */ run(command: string, options: SandboxRunOptions): Promise; + /** + * Execute a spawn-shaped streaming command path. + * Implementations must not throw for command-level outcomes (abort/timeout/non-zero); + * return a structured result instead. + */ + runStreaming(command: string, options: SandboxRunStreamingOptions): Promise; /** Best-effort cleanup hook for backend-owned resources. */ dispose(): Promise; }