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/config/trustedFolders.ts b/packages/cli/src/config/trustedFolders.ts index f5271ac..56203ea 100644 --- a/packages/cli/src/config/trustedFolders.ts +++ b/packages/cli/src/config/trustedFolders.ts @@ -6,14 +6,16 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { homedir } from 'node:os'; import { getErrorMessage, isWithinRoot } from '@blackbox_ai/blackbox-cli-core'; import type { Settings } from './settings.js'; +import { + SETTINGS_DIRECTORY_NAME, + USER_SETTINGS_DIR, +} from './settings.js'; import stripJsonComments from 'strip-json-comments'; +export { SETTINGS_DIRECTORY_NAME, USER_SETTINGS_DIR }; export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json'; -export const SETTINGS_DIRECTORY_NAME = '.blackboxcli'; -export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME); export const USER_TRUSTED_FOLDERS_PATH = path.join( USER_SETTINGS_DIR, TRUSTED_FOLDERS_FILENAME, diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 3d2c5c6..e02737b 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -23,6 +23,7 @@ import { PassThrough } from 'node:stream'; import { BACKSLASH_ENTER_DETECTION_WINDOW_MS, CHAR_CODE_ESC, + ESC_TIMEOUT_MS, KITTY_CTRL_C, KITTY_KEYCODE_BACKSPACE, KITTY_KEYCODE_ENTER, @@ -448,13 +449,13 @@ export function KeypressProvider({ if (usePassthrough) { rl = readline.createInterface({ input: keypressStream, - escapeCodeTimeout: 0, + escapeCodeTimeout: ESC_TIMEOUT_MS, }); readline.emitKeypressEvents(keypressStream, rl); keypressStream.on('keypress', handleKeypress); stdin.on('data', handleRawKeypress); } else { - rl = readline.createInterface({ input: stdin, escapeCodeTimeout: 0 }); + rl = readline.createInterface({ input: stdin, escapeCodeTimeout: ESC_TIMEOUT_MS }); readline.emitKeypressEvents(stdin, rl); stdin.on('keypress', handleKeypress); } diff --git a/packages/cli/src/ui/utils/platformConstants.ts b/packages/cli/src/ui/utils/platformConstants.ts index 9766531..3513f3c 100644 --- a/packages/cli/src/ui/utils/platformConstants.ts +++ b/packages/cli/src/ui/utils/platformConstants.ts @@ -42,6 +42,17 @@ export const VSCODE_SHIFT_ENTER_SEQUENCE = '\\\r\n'; */ export const BACKSLASH_ENTER_DETECTION_WINDOW_MS = 5; +/** + * Escape code timeout in milliseconds for readline's keypress emitter. + * + * Over a network PTY (e.g. Tensorlake sandbox via WebSocket), escape sequences + * like arrow keys (ESC[A) can arrive in separate chunks. A timeout of 0 causes + * readline to emit the ESC immediately as a standalone key, breaking arrow keys + * and other multi-byte escape sequences. A small nonzero value gives readline + * enough time to buffer the full sequence without making the ESC key feel laggy. + */ +export const ESC_TIMEOUT_MS = 50; + /** * Maximum expected length of a Kitty keyboard protocol sequence. * Format: ESC [ ; u/~ diff --git a/packages/cli/src/utils/sandbox.test.ts b/packages/cli/src/utils/sandbox.test.ts new file mode 100644 index 0000000..21be5de --- /dev/null +++ b/packages/cli/src/utils/sandbox.test.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; + +/** + * These tests validate the Tensorlake sandbox ID parsing logic used in + * start_tensorlake_sandbox (sandbox.ts). + * + * The fix addressed two bugs: + * 1. The old regex `/sbx-[a-zA-Z0-9]+/` only matched IDs with a `sbx-` prefix, + * but the `tl` CLI produces IDs like `g0btnkk8k996i0sfq14kj` (no prefix). + * 2. The old code used `match[0]` (full match) instead of `match[1]` (capture group). + */ + +// Mirror the exact regex logic from sandbox.ts so the tests stay in sync. +function parseSandboxId(output: string): string | null { + const match = + output.match(/Created sandbox\s+([a-zA-Z0-9_-]+)/) || + output.match(/([a-zA-Z0-9_-]{10,})/); + return match ? match[1] : null; +} + +describe('Tensorlake sandbox ID parsing', () => { + describe('primary pattern – "Created sandbox "', () => { + it('parses an alphanumeric ID without a prefix', () => { + const output = 'Created sandbox g0btnkk8k996i0sfq14kj'; + expect(parseSandboxId(output)).toBe('g0btnkk8k996i0sfq14kj'); + }); + + it('parses the ID when the output also contains status info on the same line', () => { + // tl may append extra tokens on the same line + const output = 'Created sandbox g0btnkk8k996i0sfq14kj (pending)'; + expect(parseSandboxId(output)).toBe('g0btnkk8k996i0sfq14kj'); + }); + + it('parses the ID from multi-line output (status updates on separate lines)', () => { + const output = + 'Created sandbox g0btnkk8k996i0sfq14kj (pending)\nWaiting for sandbox to start... running'; + expect(parseSandboxId(output)).toBe('g0btnkk8k996i0sfq14kj'); + }); + + it('parses a classic sbx-prefixed ID (backwards compatibility)', () => { + const output = 'Created sandbox sbx-abc123xyz'; + expect(parseSandboxId(output)).toBe('sbx-abc123xyz'); + }); + + it('parses an ID that contains hyphens', () => { + const output = 'Created sandbox my-sandbox-id-001'; + expect(parseSandboxId(output)).toBe('my-sandbox-id-001'); + }); + + it('parses an ID that contains underscores', () => { + const output = 'Created sandbox my_sandbox_001'; + expect(parseSandboxId(output)).toBe('my_sandbox_001'); + }); + }); + + describe('secondary fallback pattern – any long alphanumeric token', () => { + it('falls back to extracting a long token when "Created sandbox" is absent', () => { + // Some CLI versions may produce different output format + const output = 'g0btnkk8k996i0sfq14kj'; + expect(parseSandboxId(output)).toBe('g0btnkk8k996i0sfq14kj'); + }); + + it('does not match tokens shorter than 10 characters', () => { + const output = 'short'; + expect(parseSandboxId(output)).toBeNull(); + }); + }); + + describe('regression – old regex would have failed', () => { + it('does NOT return null for an ID without the sbx- prefix (regression)', () => { + // This is the exact output that was failing before the fix. + const output = 'Created sandbox g0btnkk8k996i0sfq14kj (pending)\nWaiting for sandbox to start... running'; + const id = parseSandboxId(output); + // Old code: output.match(/sbx-[a-zA-Z0-9]+/) → null → threw error + expect(id).not.toBeNull(); + expect(id).toBe('g0btnkk8k996i0sfq14kj'); + }); + }); +}); diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index f2cc060..957b909 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -183,6 +183,482 @@ 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; + let rawCreateOutput = ''; + try { + rawCreateOutput = execSync( + `tl sbx new --cpus ${cpus} --memory ${memoryMb} --timeout ${timeoutSecs}`, + { env: { ...process.env }, encoding: 'utf-8', stdio: 'pipe' }, + ).trim(); + } 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. + // 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} ...`); + 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', () => 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 + execSync( + `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 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 2.5: Copy local user config (~/.blackboxcli/) into sandbox ─── + // The Docker/Podman backend mounts USER_SETTINGS_DIR via volume binds so + // settings, API keys, and sandbox.bashrc are immediately available. The + // Tensorlake backend has no volume-mount equivalent, so we replicate the + // same directory by taring it up and uploading it with the same chunked + // approach used for the workspace. + if (fs.existsSync(USER_SETTINGS_DIR)) { + console.error(`copying local config into sandbox ...`); + const configTarPath = path.join(os.tmpdir(), `tl-config-${sandboxId}.tar.gz`); + const configChunkPrefix = path.join(os.tmpdir(), `tl-cfg-chunk-${sandboxId}-`); + const remoteConfigDir = '/root/.blackboxcli'; + try { + // Create the settings directory in the sandbox + execSync( + `tl sbx exec ${sandboxId} -- mkdir -p ${remoteConfigDir}`, + { env: { ...process.env }, stdio: 'pipe', encoding: 'utf-8' }, + ); + + // Pack the settings directory + execSync( + `tar -czf ${quote([configTarPath])} -C ${quote([path.dirname(USER_SETTINGS_DIR)])} ${quote([path.basename(USER_SETTINGS_DIR)])}`, + { env: { ...process.env }, stdio: 'pipe' }, + ); + + // Split into 1.8MB chunks (safely under the 2MB limit) + execSync( + `split -b 1800k ${quote([configTarPath])} ${quote([configChunkPrefix])}`, + { env: { ...process.env }, stdio: 'pipe' }, + ); + + // Upload each chunk with retry + const configChunks = fs.readdirSync(os.tmpdir()) + .filter((f) => f.startsWith(path.basename(configChunkPrefix))) + .sort() + .map((f) => path.join(os.tmpdir(), f)); + + const MAX_CONFIG_CHUNK_ATTEMPTS = 5; + for (const chunk of configChunks) { + let lastErr: Error | undefined; + for (let attempt = 1; attempt <= MAX_CONFIG_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_CONFIG_CHUNK_ATTEMPTS) { + await new Promise((r) => setTimeout(r, 1000 * 2 ** (attempt - 1))); + } + } + } + if (lastErr) throw lastErr; + } + + // Reassemble and extract inside the sandbox at /root/ + const configChunkBasename = path.basename(configChunkPrefix); + const configReassembleCmd = `cat /tmp/${configChunkBasename}* > /tmp/config.tar.gz && tar -xzf /tmp/config.tar.gz -C /root/ && rm -f /tmp/${configChunkBasename}* /tmp/config.tar.gz`; + execSync( + `tl sbx exec ${sandboxId} -- bash -c ${quote([configReassembleCmd])}`, + { env: { ...process.env }, stdio: 'pipe', encoding: 'utf-8' }, + ); + } catch (err) { + // Non-fatal: log a warning but continue — the CLI will start with default settings + console.error( + `Warning: failed to copy local config to Tensorlake sandbox: ${(err as Error).message}`, + ); + } finally { + try { fs.unlinkSync(configTarPath); } catch { /* ignore */ } + const configChunks = fs.readdirSync(os.tmpdir()) + .filter((f) => f.startsWith(path.basename(configChunkPrefix))); + for (const f of configChunks) { + 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. + // 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(' && '); + 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( + '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}`); + + // 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 ${remotePathQ} && npm rebuild && npm run start --` + : `cd ${remotePathQ} && ${blackboxBin}`; + + // Source sandbox.bashrc files in the same order as the Docker entrypoint: + // 1. User-level: ~/.blackboxcli/sandbox.bashrc + // 2. Project-level: /.blackboxcli/sandbox.bashrc + const bashrcCmds: string[] = [ + `[ -f /root/.blackboxcli/sandbox.bashrc ] && source /root/.blackboxcli/sandbox.bashrc`, + `[ -f ${remotePathQ}/.blackboxcli/sandbox.bashrc ] && source ${remotePathQ}/.blackboxcli/sandbox.bashrc`, + ]; + + const remoteCmd = [ + ...forwardedEnvVars.map((v) => `export ${v}`), + ...bashrcCmds, + [cliCmd, ...quotedCliArgs].join(' '), + ].join('; '); + + // ── Step 6: Stream execution ─────────────────────────────────────────── + // `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