From 22481a753cb7fc5e18f03698e6627cbd1b68f7e2 Mon Sep 17 00:00:00 2001 From: Antonio Jimeno Yepes Date: Fri, 27 Mar 2026 11:23:39 -0700 Subject: [PATCH 1/4] feat: integrate Tensorlake sandbox as a new sandbox backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tensorlake provides Firecracker MicroVM sandboxes with sub-second startup, durable filesystem state, auto suspend/resume, and live migration. This commit adds Tensorlake as a first-class sandbox provider alongside the existing docker, podman, and sandbox-exec backends. ## What changed ### packages/core/src/config/config.ts - Added 'tensorlake' to the SandboxConfig.command union type ### packages/cli/src/config/sandboxConfig.ts - Added 'tensorlake' to VALID_SANDBOX_COMMANDS - Detection uses the 'tl' CLI binary (Tensorlake's own binary name) - Tensorlake sandboxes do not require a container image URI; they use the Tensorlake cloud platform to provision MicroVMs. A 'tensorlake' placeholder is returned as the image field. - Updated auto-detection order: sandbox-exec → docker → podman → tensorlake - Updated error message to mention tensorlake as an option ### packages/cli/src/utils/sandbox.ts - Added start_tensorlake_sandbox() function implementing the full sandbox lifecycle: 1. Creates a Tensorlake MicroVM via 'tl sbx new' (with --json fallback) 2. Copies the current workspace into the sandbox 3. Installs Node.js + blackbox CLI inside the sandbox (best-effort) 4. Forwards all relevant API keys and environment variables 5. Executes 'blackbox' with the original CLI arguments inside the sandbox 6. Streams stdout/stderr back to the host terminal via stdio: 'inherit' 7. Terminates the sandbox on process exit, SIGINT, and SIGTERM - Added TENSORLAKE_API_KEY forwarding to docker/podman sandbox paths ### packages/cli/src/config/sandboxConfig.test.ts (new) - 8 unit tests covering the Tensorlake loadSandboxConfig path: - GEMINI_SANDBOX=tensorlake with tl installed → returns tensorlake config - GEMINI_SANDBOX=tensorlake without tl → throws FatalSandboxError - sandbox=tensorlake via CLI argv → returns tensorlake config - sandbox via settings.tools.sandbox → returns tensorlake config - GEMINI_SANDBOX_IMAGE is ignored for tensorlake (cloud-managed) - SANDBOX env var set → returns undefined (already inside sandbox) - docker takes priority over tensorlake in auto-detection - tensorlake auto-selected when only tl is available ## Usage Set GEMINI_SANDBOX=tensorlake and TENSORLAKE_API_KEY before running: export GEMINI_SANDBOX=tensorlake export TENSORLAKE_API_KEY=your_api_key blackbox Or pass via CLI flag: TENSORLAKE_API_KEY=... blackbox --sandbox tensorlake Optional tuning: TENSORLAKE_CPUS=4 # vCPUs (default: 2) TENSORLAKE_MEMORY=8192 # Memory MB (default: 4096) TENSORLAKE_TIMEOUT=7200 # Timeout seconds (default: 3600) --- packages/cli/src/config/sandboxConfig.test.ts | 135 +++++++++++ packages/cli/src/config/sandboxConfig.ts | 24 +- packages/cli/src/utils/sandbox.ts | 227 ++++++++++++++++++ packages/core/src/config/config.ts | 2 +- 4 files changed, 383 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/config/sandboxConfig.test.ts diff --git a/packages/cli/src/config/sandboxConfig.test.ts b/packages/cli/src/config/sandboxConfig.test.ts new file mode 100644 index 0000000..f0c21e7 --- /dev/null +++ b/packages/cli/src/config/sandboxConfig.test.ts @@ -0,0 +1,135 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// ── Mocks ────────────────────────────────────────────────────────────────── +vi.mock('command-exists', () => ({ + default: { sync: vi.fn() }, +})); + +vi.mock('../utils/package.js', () => ({ + getPackageJson: vi.fn().mockResolvedValue({ + config: { sandboxImageUri: 'ghcr.io/blackbox_ai/blackbox-cli:test' }, + }), +})); + +import commandExists from 'command-exists'; +import { loadSandboxConfig } from './sandboxConfig.js'; +import type { Settings } from './settings.js'; + +// Helper: empty settings object +const emptySettings: Settings = {} as Settings; + +describe('loadSandboxConfig – tensorlake', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + // Clear any SANDBOX / GEMINI_SANDBOX env vars before each test + delete process.env['SANDBOX']; + delete process.env['GEMINI_SANDBOX']; + delete process.env['GEMINI_SANDBOX_IMAGE']; + }); + + afterEach(() => { + // Restore original env + process.env = { ...originalEnv }; + vi.clearAllMocks(); + }); + + it('returns tensorlake config when GEMINI_SANDBOX=tensorlake and tl is installed', async () => { + process.env['GEMINI_SANDBOX'] = 'tensorlake'; + vi.mocked(commandExists.sync).mockImplementation( + (cmd: string) => cmd === 'tl', + ); + + const result = await loadSandboxConfig(emptySettings, {}); + + expect(result).toEqual({ command: 'tensorlake', image: 'tensorlake' }); + }); + + it('throws FatalSandboxError when GEMINI_SANDBOX=tensorlake but tl is not installed', async () => { + process.env['GEMINI_SANDBOX'] = 'tensorlake'; + vi.mocked(commandExists.sync).mockReturnValue(false); + + await expect(loadSandboxConfig(emptySettings, {})).rejects.toThrow( + /Missing sandbox command 'tl'/, + ); + }); + + it('returns tensorlake config when sandbox=tensorlake is passed via CLI argv', async () => { + vi.mocked(commandExists.sync).mockImplementation( + (cmd: string) => cmd === 'tl', + ); + + const result = await loadSandboxConfig(emptySettings, { + sandbox: 'tensorlake', + }); + + expect(result).toEqual({ command: 'tensorlake', image: 'tensorlake' }); + }); + + it('returns tensorlake config from settings.tools.sandbox when tl is present', async () => { + vi.mocked(commandExists.sync).mockImplementation( + (cmd: string) => cmd === 'tl', + ); + + const settingsWithTensorlake: Settings = { + tools: { sandbox: 'tensorlake' }, + } as unknown as Settings; + + const result = await loadSandboxConfig(settingsWithTensorlake, {}); + + expect(result).toEqual({ command: 'tensorlake', image: 'tensorlake' }); + }); + + it('uses tensorlake image placeholder regardless of GEMINI_SANDBOX_IMAGE', async () => { + process.env['GEMINI_SANDBOX'] = 'tensorlake'; + process.env['GEMINI_SANDBOX_IMAGE'] = 'my-custom-image:latest'; + vi.mocked(commandExists.sync).mockImplementation( + (cmd: string) => cmd === 'tl', + ); + + const result = await loadSandboxConfig(emptySettings, {}); + + // Tensorlake always uses 'tensorlake' as the image placeholder + expect(result).toEqual({ command: 'tensorlake', image: 'tensorlake' }); + }); + + it('returns undefined when SANDBOX env var is set (already inside sandbox)', async () => { + process.env['SANDBOX'] = 'tensorlake-sbx-abc123'; + process.env['GEMINI_SANDBOX'] = 'tensorlake'; + vi.mocked(commandExists.sync).mockImplementation( + (cmd: string) => cmd === 'tl', + ); + + const result = await loadSandboxConfig(emptySettings, {}); + + // When already inside a sandbox, no sandbox should be started + expect(result).toBeUndefined(); + }); + + it('does not auto-select tensorlake when sandbox=true but tl is not preferred over docker', async () => { + // docker takes priority over tensorlake in the auto-detection chain + vi.mocked(commandExists.sync).mockImplementation( + (cmd: string) => cmd === 'docker' || cmd === 'tl', + ); + + const result = await loadSandboxConfig(emptySettings, { sandbox: true }); + + expect(result?.command).toBe('docker'); + }); + + it('auto-selects tensorlake when sandbox=true and only tl is available (no docker/podman)', async () => { + vi.mocked(commandExists.sync).mockImplementation( + (cmd: string) => cmd === 'tl', + ); + + const result = await loadSandboxConfig(emptySettings, { sandbox: true }); + + expect(result).toEqual({ command: 'tensorlake', image: 'tensorlake' }); + }); +}); diff --git a/packages/cli/src/config/sandboxConfig.ts b/packages/cli/src/config/sandboxConfig.ts index a752ee0..4a7a43e 100644 --- a/packages/cli/src/config/sandboxConfig.ts +++ b/packages/cli/src/config/sandboxConfig.ts @@ -22,6 +22,7 @@ const VALID_SANDBOX_COMMANDS: ReadonlyArray = [ 'docker', 'podman', 'sandbox-exec', + 'tensorlake', ]; function isSandboxCommand(value: string): value is SandboxConfig['command'] { @@ -58,16 +59,18 @@ function getSandboxCommand( )}`, ); } + // tensorlake uses the 'tl' CLI binary rather than the command name itself + const executableName = sandbox === 'tensorlake' ? 'tl' : sandbox; // confirm that specified command exists - if (commandExists.sync(sandbox)) { + if (commandExists.sync(executableName)) { return sandbox; } throw new FatalSandboxError( - `Missing sandbox command '${sandbox}' (from GEMINI_SANDBOX)`, + `Missing sandbox command '${executableName}' (from GEMINI_SANDBOX=${sandbox})`, ); } - // look for seatbelt, docker, or podman, in that order + // look for seatbelt, docker, podman, or tensorlake, in that order // for container-based sandboxing, require sandbox to be enabled explicitly if (os.platform() === 'darwin' && commandExists.sync('sandbox-exec')) { return 'sandbox-exec'; @@ -75,13 +78,15 @@ function getSandboxCommand( return 'docker'; } else if (commandExists.sync('podman') && sandbox === true) { return 'podman'; + } else if (commandExists.sync('tl') && sandbox === true) { + return 'tensorlake'; } // throw an error if user requested sandbox but no command was found if (sandbox === true) { throw new FatalSandboxError( 'GEMINI_SANDBOX is true but failed to determine command for sandbox; ' + - 'install docker or podman or specify command in GEMINI_SANDBOX', + 'install docker, podman, or tensorlake CLI (tl) or specify command in GEMINI_SANDBOX', ); } @@ -95,6 +100,17 @@ export async function loadSandboxConfig( const sandboxOption = argv.sandbox ?? settings.tools?.sandbox; const command = getSandboxCommand(sandboxOption); + if (!command) { + return undefined; + } + + // Tensorlake manages its own sandbox images via the cloud platform. + // The 'image' field is not used for tensorlake sandboxes; we store a + // placeholder so the SandboxConfig shape remains consistent. + if (command === 'tensorlake') { + return { command, image: 'tensorlake' }; + } + const packageJson = await getPackageJson(); const image = argv.sandboxImage ?? diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index f2cc060..ae086d7 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -183,6 +183,227 @@ function entrypoint(workdir: string, cliArgs: string[]): string[] { return ['bash', '-c', args.join(' ')]; } +/** + * Starts a Tensorlake MicroVM sandbox and runs the blackbox CLI inside it. + * + * Tensorlake provides Firecracker-based MicroVM sandboxes with sub-second + * startup, durable filesystem, and auto suspend/resume. + * + * The sandbox lifecycle: + * 1. Create a new Tensorlake sandbox via `tl sbx new` + * 2. Copy the current working directory into the sandbox + * 3. Install Node.js + the blackbox CLI inside the sandbox + * 4. Forward the relevant environment variables + * 5. Execute `blackbox` with the original CLI arguments inside the sandbox + * 6. Stream stdout/stderr back to the host terminal + * 7. Terminate the sandbox on exit + * + * Required environment variables: + * - TENSORLAKE_API_KEY API key from https://cloud.tensorlake.ai + * + * Optional environment variables: + * - TENSORLAKE_CPUS Number of vCPUs for the sandbox (default: 2) + * - TENSORLAKE_MEMORY Memory in MB for the sandbox (default: 4096) + * - TENSORLAKE_TIMEOUT Sandbox timeout in seconds (default: 3600) + */ +async function start_tensorlake_sandbox( + nodeArgs: string[] = [], + cliConfig?: Config, + cliArgs: string[] = [], +) { + const apiKey = process.env['TENSORLAKE_API_KEY']; + if (!apiKey) { + throw new FatalSandboxError( + 'TENSORLAKE_API_KEY environment variable is required for Tensorlake sandbox. ' + + 'Get your API key at https://cloud.tensorlake.ai', + ); + } + + const cpus = process.env['TENSORLAKE_CPUS'] ?? '2'; + const memoryMb = process.env['TENSORLAKE_MEMORY'] ?? '4096'; + const timeoutSecs = process.env['TENSORLAKE_TIMEOUT'] ?? '3600'; + + console.error( + `hopping into Tensorlake sandbox (cpus: ${cpus}, memory: ${memoryMb}MB) ...`, + ); + + // ── Step 1: Create sandbox ───────────────────────────────────────────── + let sandboxId: string; + try { + const createOutput = execSync( + `tl sbx new --cpus ${cpus} --memory ${memoryMb} --timeout ${timeoutSecs} --json`, + { env: { ...process.env }, encoding: 'utf-8' }, + ).trim(); + const parsed = JSON.parse(createOutput) as { sandbox_id?: string; id?: string }; + sandboxId = parsed.sandbox_id ?? parsed.id ?? ''; + if (!sandboxId) { + throw new Error(`unexpected response: ${createOutput}`); + } + } catch (err) { + // Some older tl versions don't support --json; fall back to text parsing + try { + const createOutput = execSync( + `tl sbx new --cpus ${cpus} --memory ${memoryMb} --timeout ${timeoutSecs}`, + { env: { ...process.env }, encoding: 'utf-8' }, + ).trim(); + // Attempt to parse a sandbox ID from output like "Created sandbox sbx-abc123" + const match = createOutput.match(/sbx-[a-zA-Z0-9]+/); + if (!match) { + throw new Error(`could not parse sandbox ID from: ${createOutput}`); + } + sandboxId = match[0]; + } catch (fallbackErr) { + throw new FatalSandboxError( + `Failed to create Tensorlake sandbox: ${(fallbackErr as Error).message}`, + ); + } + } + + console.error(`Tensorlake sandbox created: ${sandboxId}`); + + // Register cleanup handler to terminate sandbox on exit + const terminateSandbox = () => { + try { + console.error(`terminating Tensorlake sandbox ${sandboxId} ...`); + execSync(`tl sbx terminate ${sandboxId}`, { + env: { ...process.env }, + stdio: 'pipe', + }); + } catch { + // Best-effort cleanup; don't throw on exit + } + }; + process.on('exit', terminateSandbox); + process.on('SIGINT', () => { + terminateSandbox(); + process.exit(130); + }); + process.on('SIGTERM', () => { + terminateSandbox(); + process.exit(143); + }); + + const workdir = path.resolve(process.cwd()); + const remotePath = `/workspace${workdir}`; + + // ── Step 2: Copy workspace into sandbox ──────────────────────────────── + console.error(`copying workspace into sandbox ...`); + try { + // Create target directory structure first + execSync( + `tl sbx exec ${sandboxId} -- mkdir -p ${remotePath}`, + { env: { ...process.env }, stdio: 'pipe', encoding: 'utf-8' }, + ); + execSync( + `tl sbx cp -r ${workdir}/. ${sandboxId}:${remotePath}/`, + { env: { ...process.env }, stdio: 'inherit', encoding: 'utf-8' }, + ); + } catch (err) { + throw new FatalSandboxError( + `Failed to copy workspace to Tensorlake sandbox ${sandboxId}: ${(err as Error).message}`, + ); + } + + // ── Step 3: Install Node.js + blackbox CLI inside the sandbox ────────── + console.error(`installing blackbox CLI inside sandbox ...`); + try { + // Install node via nvm for a hermetic environment; fall back to system node + const setupCmd = [ + 'export NVM_DIR="$HOME/.nvm"', + '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"', + 'command -v node >/dev/null 2>&1 || (curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt-get install -y nodejs 2>/dev/null)', + 'command -v blackbox >/dev/null 2>&1 || npm install -g @blackbox_ai/blackbox-cli 2>/dev/null', + ].join(' && '); + execSync( + `tl sbx exec ${sandboxId} -- bash -c ${quote([setupCmd])}`, + { env: { ...process.env }, stdio: 'pipe', encoding: 'utf-8' }, + ); + } catch { + // Non-fatal: the sandbox image may already have node/blackbox installed + console.error( + 'Warning: pre-installation step failed — assuming blackbox is already available inside sandbox', + ); + } + + // ── Step 4: Build environment variable forwarding ────────────────────── + const forwardedEnvVars: string[] = []; + const envVarsToForward = [ + 'GEMINI_API_KEY', + 'GOOGLE_API_KEY', + 'OPENAI_API_KEY', + 'OPENAI_BASE_URL', + 'OPENAI_MODEL', + 'BLACKBOX_API_KEY', + 'TAVILY_API_KEY', + 'GOOGLE_GENAI_USE_VERTEXAI', + 'GOOGLE_GENAI_USE_GCA', + 'GOOGLE_CLOUD_PROJECT', + 'GOOGLE_CLOUD_LOCATION', + 'GEMINI_MODEL', + 'TERM', + 'COLORTERM', + 'BLACKBOX_CODE_IDE_SERVER_PORT', + 'BLACKBOX_CODE_IDE_WORKSPACE_PATH', + 'TERM_PROGRAM', + 'TENSORLAKE_API_KEY', + ]; + for (const varName of envVarsToForward) { + if (process.env[varName]) { + forwardedEnvVars.push(`${varName}=${quote([process.env[varName]!])}`); + } + } + + // Forward NODE_OPTIONS + const existingNodeOptions = process.env['NODE_OPTIONS'] || ''; + const allNodeOptions = [ + ...(existingNodeOptions ? [existingNodeOptions] : []), + ...nodeArgs, + ] + .join(' ') + .trim(); + if (allNodeOptions) { + forwardedEnvVars.push(`NODE_OPTIONS=${quote([allNodeOptions])}`); + } + + // Mark that we are inside the sandbox so that the CLI does not try to re-enter + forwardedEnvVars.push(`SANDBOX=tensorlake-${sandboxId}`); + + // ── Step 5: Execute blackbox inside the sandbox ──────────────────────── + const quotedCliArgs = cliArgs.slice(2).map((arg) => quote([arg])); + const cliCmd = + process.env['NODE_ENV'] === 'development' + ? `cd ${remotePath} && npm rebuild && npm run start --` + : `cd ${remotePath} && blackbox`; + + const remoteCmd = [ + `export ${forwardedEnvVars.join(' ')}`, + cliCmd, + ...quotedCliArgs, + ].join(' '); + + // ── Step 6: Stream execution ─────────────────────────────────────────── + const sandboxProcess = spawn( + 'tl', + ['sbx', 'exec', sandboxId, '--', 'bash', '-c', remoteCmd], + { stdio: 'inherit', env: { ...process.env } }, + ); + + sandboxProcess.on('error', (err) => { + console.error(`Tensorlake sandbox process error: ${err.message}`); + }); + + await new Promise((resolve) => { + sandboxProcess.on('close', (code, signal) => { + if (code !== 0 && code !== null) { + console.error( + `Tensorlake sandbox process exited with code: ${code}, signal: ${signal}`, + ); + } + resolve(); + }); + }); +} + export async function start_sandbox( config: SandboxConfig, nodeArgs: string[] = [], @@ -346,6 +567,11 @@ export async function start_sandbox( return; } + if (config.command === 'tensorlake') { + await start_tensorlake_sandbox(nodeArgs, cliConfig, cliArgs); + return; + } + console.error(`hopping into sandbox (command: ${config.command}) ...`); // determine full path for gemini-cli to distinguish linked vs installed setting @@ -636,6 +862,7 @@ export async function start_sandbox( 'BLACKBOX_CODE_IDE_SERVER_PORT', 'BLACKBOX_CODE_IDE_WORKSPACE_PATH', 'TERM_PROGRAM', + 'TENSORLAKE_API_KEY', ]) { if (process.env[envVar]) { args.push('--env', `${envVar}=${process.env[envVar]}`); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index bfb4f61..1148f89 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -166,7 +166,7 @@ export enum AuthProviderType { } export interface SandboxConfig { - command: 'docker' | 'podman' | 'sandbox-exec'; + command: 'docker' | 'podman' | 'sandbox-exec' | 'tensorlake'; image: string; } From afa5ade4eea4cded66081553be6e562c978da614 Mon Sep 17 00:00:00 2001 From: Antonio Jimeno Yepes Date: Fri, 27 Mar 2026 16:15:25 -0700 Subject: [PATCH 2/4] Updated fix sandboxes --- packages/cli/src/utils/sandbox.ts | 257 ++++++++++++++++++++++++------ 1 file changed, 205 insertions(+), 52 deletions(-) diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index ae086d7..43f5f60 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -229,39 +229,32 @@ async function start_tensorlake_sandbox( // ── Step 1: Create sandbox ───────────────────────────────────────────── let sandboxId: string; + let rawCreateOutput = ''; try { - const createOutput = execSync( - `tl sbx new --cpus ${cpus} --memory ${memoryMb} --timeout ${timeoutSecs} --json`, - { env: { ...process.env }, encoding: 'utf-8' }, + rawCreateOutput = execSync( + `tl sbx new --cpus ${cpus} --memory ${memoryMb} --timeout ${timeoutSecs}`, + { env: { ...process.env }, encoding: 'utf-8', stdio: 'pipe' }, ).trim(); - const parsed = JSON.parse(createOutput) as { sandbox_id?: string; id?: string }; - sandboxId = parsed.sandbox_id ?? parsed.id ?? ''; - if (!sandboxId) { - throw new Error(`unexpected response: ${createOutput}`); - } - } catch (err) { - // Some older tl versions don't support --json; fall back to text parsing - try { - const createOutput = execSync( - `tl sbx new --cpus ${cpus} --memory ${memoryMb} --timeout ${timeoutSecs}`, - { env: { ...process.env }, encoding: 'utf-8' }, - ).trim(); - // Attempt to parse a sandbox ID from output like "Created sandbox sbx-abc123" - const match = createOutput.match(/sbx-[a-zA-Z0-9]+/); - if (!match) { - throw new Error(`could not parse sandbox ID from: ${createOutput}`); - } - sandboxId = match[0]; - } catch (fallbackErr) { - throw new FatalSandboxError( - `Failed to create Tensorlake sandbox: ${(fallbackErr as Error).message}`, - ); - } + } catch (execErr) { + const execError = execErr as NodeJS.ErrnoException & { stderr?: string; stdout?: string }; + rawCreateOutput = (execError.stdout ?? '') + '\n' + (execError.stderr ?? ''); } + // Parse the sandbox ID from output like "Created sandbox g0btnkk8k996i0sfq14kj" + const sandboxIdMatch = + rawCreateOutput.match(/Created sandbox\s+([a-zA-Z0-9_-]+)/) || + rawCreateOutput.match(/([a-zA-Z0-9_-]{10,})/); + if (!sandboxIdMatch) { + throw new FatalSandboxError( + `Failed to create Tensorlake sandbox: could not parse sandbox ID from: ${rawCreateOutput.trim()}`, + ); + } + sandboxId = sandboxIdMatch[1]!; console.error(`Tensorlake sandbox created: ${sandboxId}`); - // Register cleanup handler to terminate sandbox on exit + // Register cleanup handler to terminate sandbox on exit. + // Only the 'exit' handler actually terminates — the signal handlers just + // call process.exit() so the 'exit' event fires exactly once. const terminateSandbox = () => { try { console.error(`terminating Tensorlake sandbox ${sandboxId} ...`); @@ -274,50 +267,159 @@ async function start_tensorlake_sandbox( } }; process.on('exit', terminateSandbox); - process.on('SIGINT', () => { - terminateSandbox(); - process.exit(130); - }); - process.on('SIGTERM', () => { - terminateSandbox(); - process.exit(143); - }); + process.on('SIGINT', () => process.exit(130)); + process.on('SIGTERM', () => process.exit(143)); + + // Wait for both the exec daemon AND the HTTP file API to be reachable. + // The VM may report "running" before its internal services are fully up. + // We probe the exec path first, then validate the file API with a test cp. + console.error(`waiting for sandbox to be ready ...`); + const MAX_READY_ATTEMPTS = 30; + for (let attempt = 1; attempt <= MAX_READY_ATTEMPTS; attempt++) { + try { + execSync(`tl sbx exec ${sandboxId} -- echo ready`, { + env: { ...process.env }, + stdio: 'pipe', + encoding: 'utf-8', + }); + break; // exec daemon is reachable + } catch { + if (attempt === MAX_READY_ATTEMPTS) { + throw new FatalSandboxError( + `Tensorlake sandbox ${sandboxId} did not become reachable after ${MAX_READY_ATTEMPTS} attempts`, + ); + } + await new Promise((r) => setTimeout(r, 1000)); + } + } + + // Separately probe the HTTP file API used by `tl sbx cp`, which can lag + // behind the exec daemon becoming ready. + const MAX_FILE_API_ATTEMPTS = 20; + const probeFile = path.join(os.tmpdir(), `tl-probe-${sandboxId}`); + try { + fs.writeFileSync(probeFile, ''); + for (let attempt = 1; attempt <= MAX_FILE_API_ATTEMPTS; attempt++) { + try { + execSync( + `tl sbx cp ${quote([probeFile])} ${sandboxId}:/tmp/${path.basename(probeFile)}`, + { env: { ...process.env }, stdio: 'pipe', encoding: 'utf-8' }, + ); + break; // file API is reachable + } catch { + if (attempt === MAX_FILE_API_ATTEMPTS) { + throw new FatalSandboxError( + `Tensorlake sandbox ${sandboxId} file API did not become reachable after ${MAX_FILE_API_ATTEMPTS} attempts`, + ); + } + await new Promise((r) => setTimeout(r, 1500)); + } + } + } finally { + try { fs.unlinkSync(probeFile); } catch { /* ignore */ } + } const workdir = path.resolve(process.cwd()); const remotePath = `/workspace${workdir}`; + // Shell-safe single-quoted version of remotePath for use inside bash commands. + const remotePathQ = `'${remotePath.replace(/'/g, "'\\''")}'`; // ── Step 2: Copy workspace into sandbox ──────────────────────────────── + // tl sbx cp has a 2MB per-file upload limit, so we tar the workspace, + // split it into 2MB chunks, upload each chunk, and reassemble + extract + // inside the sandbox. console.error(`copying workspace into sandbox ...`); + const tarPath = path.join(os.tmpdir(), `tl-workspace-${sandboxId}.tar.gz`); + const chunkPrefix = path.join(os.tmpdir(), `tl-ws-chunk-${sandboxId}-`); try { - // Create target directory structure first + // Create target directory structure execSync( - `tl sbx exec ${sandboxId} -- mkdir -p ${remotePath}`, + `tl sbx exec ${sandboxId} -- mkdir -p ${remotePathQ}`, { env: { ...process.env }, stdio: 'pipe', encoding: 'utf-8' }, ); + + // Pack workspace (skip .git, node_modules, dist to keep size down) + execSync( + `tar -czf ${quote([tarPath])} -C ${quote([workdir])} --exclude='.git' --exclude='node_modules' --exclude='dist' .`, + { env: { ...process.env }, stdio: 'pipe' }, + ); + + // Split into 1.8MB chunks (safely under the 2MB limit) + execSync( + `split -b 1800k ${quote([tarPath])} ${quote([chunkPrefix])}`, + { env: { ...process.env }, stdio: 'pipe' }, + ); + + // Upload each chunk with exponential backoff retry + const chunks = fs.readdirSync(os.tmpdir()) + .filter((f) => f.startsWith(path.basename(chunkPrefix))) + .sort() + .map((f) => path.join(os.tmpdir(), f)); + + const MAX_CHUNK_ATTEMPTS = 5; + for (const chunk of chunks) { + let lastErr: Error | undefined; + for (let attempt = 1; attempt <= MAX_CHUNK_ATTEMPTS; attempt++) { + try { + execSync( + `tl sbx cp ${quote([chunk])} ${sandboxId}:/tmp/${path.basename(chunk)}`, + { env: { ...process.env }, stdio: 'pipe', encoding: 'utf-8' }, + ); + lastErr = undefined; + break; + } catch (err) { + lastErr = err as Error; + if (attempt < MAX_CHUNK_ATTEMPTS) { + // Exponential backoff: 1s, 2s, 4s, 8s + await new Promise((r) => setTimeout(r, 1000 * 2 ** (attempt - 1))); + } + } + } + if (lastErr) throw lastErr; + } + + // Reassemble and extract inside the sandbox + const chunkBasename = path.basename(chunkPrefix); + const reassembleCmd = `cat /tmp/${chunkBasename}* > /tmp/workspace.tar.gz && tar -xzf /tmp/workspace.tar.gz -C ${remotePathQ} && rm -f /tmp/${chunkBasename}* /tmp/workspace.tar.gz`; execSync( - `tl sbx cp -r ${workdir}/. ${sandboxId}:${remotePath}/`, - { env: { ...process.env }, stdio: 'inherit', encoding: 'utf-8' }, + `tl sbx exec ${sandboxId} -- bash -c ${quote([reassembleCmd])}`, + { env: { ...process.env }, stdio: 'pipe', encoding: 'utf-8' }, ); } catch (err) { throw new FatalSandboxError( `Failed to copy workspace to Tensorlake sandbox ${sandboxId}: ${(err as Error).message}`, ); + } finally { + // Clean up local temp files + try { fs.unlinkSync(tarPath); } catch { /* ignore */ } + const chunks = fs.readdirSync(os.tmpdir()) + .filter((f) => f.startsWith(path.basename(chunkPrefix))); + for (const f of chunks) { + try { fs.unlinkSync(path.join(os.tmpdir(), f)); } catch { /* ignore */ } + } } // ── Step 3: Install Node.js + blackbox CLI inside the sandbox ────────── console.error(`installing blackbox CLI inside sandbox ...`); try { - // Install node via nvm for a hermetic environment; fall back to system node + // Install node via nvm for a hermetic environment; fall back to system node. + // After installing, emit the resolved blackbox binary path so we can use an + // absolute path in the exec command (avoids PATH issues in non-login shells). const setupCmd = [ 'export NVM_DIR="$HOME/.nvm"', '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"', 'command -v node >/dev/null 2>&1 || (curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt-get install -y nodejs 2>/dev/null)', 'command -v blackbox >/dev/null 2>&1 || npm install -g @blackbox_ai/blackbox-cli 2>/dev/null', + 'echo "BLACKBOX_BIN=$(command -v blackbox 2>/dev/null || npm root -g 2>/dev/null | xargs -I{} find {} -name blackbox -type f 2>/dev/null | head -1)"', ].join(' && '); - execSync( + const installOutput = execSync( `tl sbx exec ${sandboxId} -- bash -c ${quote([setupCmd])}`, { env: { ...process.env }, stdio: 'pipe', encoding: 'utf-8' }, ); + const binMatch = installOutput.match(/^BLACKBOX_BIN=(.+)$/m); + if (binMatch?.[1]?.trim()) { + process.env['_TENSORLAKE_BLACKBOX_BIN'] = binMatch[1].trim(); + } } catch { // Non-fatal: the sandbox image may already have node/blackbox installed console.error( @@ -368,25 +470,76 @@ async function start_tensorlake_sandbox( // Mark that we are inside the sandbox so that the CLI does not try to re-enter forwardedEnvVars.push(`SANDBOX=tensorlake-${sandboxId}`); + // Ensure HOME is set to /root inside the sandbox so that os.homedir() resolves + // correctly (tl sbx exec runs as root; without this, $HOME may be unset or '/' + // causing settings to land at /.blackboxcli/settings.json instead of + // /root/.blackboxcli/settings.json). + forwardedEnvVars.push(`HOME=/root`); + // ── Step 5: Execute blackbox inside the sandbox ──────────────────────── const quotedCliArgs = cliArgs.slice(2).map((arg) => quote([arg])); + // Use the absolute path resolved during install if available so the binary + // is found even when the remote shell's PATH doesn't include npm's global bin. + const blackboxBin = process.env['_TENSORLAKE_BLACKBOX_BIN'] || 'blackbox'; + delete process.env['_TENSORLAKE_BLACKBOX_BIN']; const cliCmd = process.env['NODE_ENV'] === 'development' - ? `cd ${remotePath} && npm rebuild && npm run start --` - : `cd ${remotePath} && blackbox`; + ? `cd ${remotePathQ} && npm rebuild && npm run start --` + : `cd ${remotePathQ} && ${blackboxBin}`; const remoteCmd = [ - `export ${forwardedEnvVars.join(' ')}`, - cliCmd, - ...quotedCliArgs, - ].join(' '); + ...forwardedEnvVars.map((v) => `export ${v}`), + [cliCmd, ...quotedCliArgs].join(' '), + ].join('; '); // ── Step 6: Stream execution ─────────────────────────────────────────── - const sandboxProcess = spawn( - 'tl', - ['sbx', 'exec', sandboxId, '--', 'bash', '-c', remoteCmd], - { stdio: 'inherit', env: { ...process.env } }, - ); + // `tl sbx exec` has no TTY support (-t means --timeout there). + // For interactive use we write the startup script to the sandbox and use + // `tl sbx ssh --shell