Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions packages/cli/src/config/sandboxConfig.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
24 changes: 20 additions & 4 deletions packages/cli/src/config/sandboxConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const VALID_SANDBOX_COMMANDS: ReadonlyArray<SandboxConfig['command']> = [
'docker',
'podman',
'sandbox-exec',
'tensorlake',
];

function isSandboxCommand(value: string): value is SandboxConfig['command'] {
Expand Down Expand Up @@ -58,30 +59,34 @@ 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';
} else if (commandExists.sync('docker') && sandbox === true) {
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',
);
}

Expand All @@ -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 ??
Expand Down
8 changes: 5 additions & 3 deletions packages/cli/src/config/trustedFolders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/ui/contexts/KeypressContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/ui/utils/platformConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [ <keycode> ; <modifiers> u/~
Expand Down
85 changes: 85 additions & 0 deletions packages/cli/src/utils/sandbox.test.ts
Original file line number Diff line number Diff line change
@@ -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 <id>"', () => {
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');
});
});
});
Loading