diff --git a/package.json b/package.json index a73275025..22fd4a265 100644 --- a/package.json +++ b/package.json @@ -21,9 +21,9 @@ "sandboxImageUri": "ghcr.io/vybestack/llxprt-code/sandbox:0.8.0" }, "scripts": { - "start": "cross-env NODE_ENV=development node scripts/start.js", + "start": "./scripts/sanitize-node-options.sh cross-env NODE_ENV=development node scripts/start.js", "start:a2a-server": "CODER_AGENT_PORT=41242 npm run start --workspace @google/gemini-cli-a2a-server", - "debug": "cross-env DEBUG=1 node --inspect-brk scripts/start.js", + "debug": "./scripts/sanitize-node-options.sh cross-env DEBUG=1 node --inspect-brk scripts/start.js", "deflake": "node scripts/deflake.js", "deflake:test:integration:sandbox:none": "npm run deflake -- --command='npm run test:integration:sandbox:none'", "deflake:test:integration:sandbox:docker": "npm run deflake -- --command='npm run test:integration:sandbox:docker'", diff --git a/packages/cli/llxprt.sh b/packages/cli/llxprt.sh new file mode 100755 index 000000000..9167857e4 --- /dev/null +++ b/packages/cli/llxprt.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Shell wrapper for llxprt CLI that sanitizes NODE_OPTIONS before invoking node. +# This prevents warnings from --localstorage-file flags that may be set by IDEs. + +if [[ -n "${NODE_OPTIONS}" ]]; then + # Remove --localstorage-file with optional value (but don't consume following flags starting with -) + # Handles: --localstorage-file, --localstorage-file=value, --localstorage-file value + NODE_OPTIONS=$(echo "${NODE_OPTIONS}" | sed -E 's/(^|[[:space:]])--localstorage-file(=[^[:space:]]*|[[:space:]]+[^-][^[:space:]]*)?//g' | sed -E 's/[[:space:]]+/ /g' | sed -E 's/^[[:space:]]+|[[:space:]]+$//g') + export NODE_OPTIONS +fi + +# Get the directory where this script is located +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) + +# Execute the main CLI entry point +exec node --no-deprecation "${SCRIPT_DIR}/dist/index.js" "$@" diff --git a/packages/cli/package.json b/packages/cli/package.json index 1db133bc0..cbe40fa5a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -10,7 +10,7 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "bin": { - "llxprt": "dist/index.js" + "llxprt": "llxprt.sh" }, "scripts": { "build": "node ../../scripts/build_package.js", @@ -30,7 +30,8 @@ "prepack": "npm run build" }, "files": [ - "dist" + "dist", + "llxprt.sh" ], "config": { "sandboxImageUri": "ghcr.io/vybestack/llxprt-code/sandbox:0.8.0" diff --git a/packages/cli/src/utils/relaunch.test.ts b/packages/cli/src/utils/relaunch.test.ts index 9ae9570b6..4f3747529 100644 --- a/packages/cli/src/utils/relaunch.test.ts +++ b/packages/cli/src/utils/relaunch.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as childProcess from 'node:child_process'; import { EventEmitter } from 'node:events'; -import { relaunchAppInChildProcess } from './relaunch.js'; +import { relaunchAppInChildProcess, sanitizeNodeOptions } from './relaunch.js'; import { RELAUNCH_EXIT_CODE } from './bootstrap.js'; vi.mock('node:child_process'); @@ -135,4 +135,102 @@ describe('relaunchAppInChildProcess', () => { >; expect(spawnEnv.CUSTOM_VAR).toBe('test-value'); }); + + it('should sanitize NODE_OPTIONS to remove --localstorage-file', async () => { + const nodeArgs = ['--max-old-space-size=4096']; + process.env = { + ...process.env, + NODE_OPTIONS: + '--max-old-space-size=2048 --localstorage-file --enable-source-maps', + }; + + const promise = relaunchAppInChildProcess(nodeArgs); + + mockChildProcess.emit('close', 0); + await promise; + + const spawnEnv = mockedChildProcess.spawn.mock.calls[0][2]?.env as Record< + string, + string | undefined + >; + expect(spawnEnv.NODE_OPTIONS).toBe( + '--max-old-space-size=2048 --enable-source-maps', + ); + }); + + it('should remove NODE_OPTIONS entirely if only --localstorage-file present', async () => { + const nodeArgs = ['--max-old-space-size=4096']; + process.env = { ...process.env, NODE_OPTIONS: '--localstorage-file' }; + + const promise = relaunchAppInChildProcess(nodeArgs); + + mockChildProcess.emit('close', 0); + await promise; + + const spawnEnv = mockedChildProcess.spawn.mock.calls[0][2]?.env as Record< + string, + string | undefined + >; + expect(spawnEnv.NODE_OPTIONS).toBeUndefined(); + }); +}); + +describe('sanitizeNodeOptions', () => { + it('should return undefined for undefined input', () => { + expect(sanitizeNodeOptions(undefined)).toBeUndefined(); + }); + + it('should return undefined for empty string', () => { + expect(sanitizeNodeOptions('')).toBeUndefined(); + }); + + it('should remove --localstorage-file without value', () => { + expect(sanitizeNodeOptions('--localstorage-file')).toBeUndefined(); + }); + + it('should remove --localstorage-file with equals value', () => { + expect( + sanitizeNodeOptions('--localstorage-file=/some/path'), + ).toBeUndefined(); + }); + + it('should remove --localstorage-file with space-separated value', () => { + expect( + sanitizeNodeOptions('--localstorage-file /some/path'), + ).toBeUndefined(); + }); + + it('should preserve other options before --localstorage-file', () => { + expect( + sanitizeNodeOptions('--max-old-space-size=4096 --localstorage-file'), + ).toBe('--max-old-space-size=4096'); + }); + + it('should preserve other options after --localstorage-file', () => { + expect( + sanitizeNodeOptions('--localstorage-file --enable-source-maps'), + ).toBe('--enable-source-maps'); + }); + + it('should preserve options on both sides of --localstorage-file', () => { + expect( + sanitizeNodeOptions( + '--max-old-space-size=4096 --localstorage-file --enable-source-maps', + ), + ).toBe('--max-old-space-size=4096 --enable-source-maps'); + }); + + it('should not consume following flags as values', () => { + expect(sanitizeNodeOptions('--localstorage-file --other-flag value')).toBe( + '--other-flag value', + ); + }); + + it('should handle multiple spaces', () => { + expect( + sanitizeNodeOptions( + ' --max-old-space-size=4096 --localstorage-file ', + ), + ).toBe('--max-old-space-size=4096'); + }); }); diff --git a/packages/cli/src/utils/relaunch.ts b/packages/cli/src/utils/relaunch.ts index 503833f7a..47d3adee0 100644 --- a/packages/cli/src/utils/relaunch.ts +++ b/packages/cli/src/utils/relaunch.ts @@ -11,6 +11,25 @@ import { spawn } from 'node:child_process'; +/** + * Sanitize NODE_OPTIONS to remove --localstorage-file flags that may cause warnings. + * Node emits a warning when --localstorage-file is present without a valid path. + * This can happen when IDEs like VSCode set NODE_OPTIONS in the environment. + * + * @param nodeOptions - The NODE_OPTIONS environment variable value + * @returns Sanitized NODE_OPTIONS string, or undefined if empty + */ +export function sanitizeNodeOptions( + nodeOptions: string | undefined, +): string | undefined { + if (!nodeOptions) return undefined; + const sanitized = nodeOptions + .replace(/\s*--localstorage-file(?:(?:\s*=\s*|\s+)(?!-)\S+)?/g, '') + .replace(/\s+/g, ' ') + .trim(); + return sanitized || undefined; +} + /** * Relaunch the current application in a child process with additional Node.js arguments. * This is used to restart with higher memory limits or enter sandboxed environments. @@ -25,7 +44,12 @@ export async function relaunchAppInChildProcess( additionalArgs: string[], ): Promise { const nodeArgs = [...additionalArgs, ...process.argv.slice(1)]; - const newEnv = { ...process.env, LLXPRT_CODE_NO_RELAUNCH: 'true' }; + const sanitizedNodeOptions = sanitizeNodeOptions(process.env.NODE_OPTIONS); + const newEnv: Record = { + ...process.env, + LLXPRT_CODE_NO_RELAUNCH: 'true', + NODE_OPTIONS: sanitizedNodeOptions, + }; const child = spawn(process.execPath, nodeArgs, { stdio: 'inherit', diff --git a/scripts/sanitize-node-options.sh b/scripts/sanitize-node-options.sh new file mode 100755 index 000000000..55992c55d --- /dev/null +++ b/scripts/sanitize-node-options.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Sanitize NODE_OPTIONS to remove --localstorage-file flags that may cause warnings. +# This must happen BEFORE node is invoked, since Node parses NODE_OPTIONS at startup. + +if [[ -n "${NODE_OPTIONS}" ]]; then + # Remove --localstorage-file with optional value (but don't consume following flags starting with -) + # Handles: --localstorage-file, --localstorage-file=value, --localstorage-file value + SANITIZED=$(echo "${NODE_OPTIONS}" | sed -E 's/(^|[[:space:]])--localstorage-file(=[^[:space:]]*|[[:space:]]+[^-][^[:space:]]*)?//g' | sed -E 's/[[:space:]]+/ /g' | sed -E 's/^[[:space:]]+|[[:space:]]+$//g') + export NODE_OPTIONS="${SANITIZED}" +fi + +# Execute the remaining arguments +exec "$@" diff --git a/scripts/tests/sanitize-node-options.test.js b/scripts/tests/sanitize-node-options.test.js new file mode 100644 index 000000000..a8db36e6e --- /dev/null +++ b/scripts/tests/sanitize-node-options.test.js @@ -0,0 +1,96 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { execSync } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const scriptPath = join(__dirname, '..', 'sanitize-node-options.sh'); + +function runWithNodeOptions(nodeOptions, command = 'echo "$NODE_OPTIONS"') { + const env = { ...process.env }; + if (nodeOptions !== undefined) { + env.NODE_OPTIONS = nodeOptions; + } else { + delete env.NODE_OPTIONS; + } + + try { + return execSync(`${scriptPath} bash -c '${command}'`, { + env, + encoding: 'utf-8', + }).trim(); + } catch (error) { + throw new Error(`Script failed: ${error.message}`); + } +} + +describe('sanitize-node-options.sh', () => { + it('should remove --localstorage-file without value', () => { + const result = runWithNodeOptions('--localstorage-file'); + expect(result).toBe(''); + }); + + it('should remove --localstorage-file with equals value', () => { + const result = runWithNodeOptions('--localstorage-file=/some/path'); + expect(result).toBe(''); + }); + + it('should remove --localstorage-file with space-separated value', () => { + const result = runWithNodeOptions('--localstorage-file /some/path'); + expect(result).toBe(''); + }); + + it('should preserve other options before --localstorage-file', () => { + const result = runWithNodeOptions( + '--max-old-space-size=4096 --localstorage-file', + ); + expect(result).toBe('--max-old-space-size=4096'); + }); + + it('should preserve other options after --localstorage-file', () => { + const result = runWithNodeOptions( + '--localstorage-file --enable-source-maps', + ); + expect(result).toBe('--enable-source-maps'); + }); + + it('should preserve options on both sides', () => { + const result = runWithNodeOptions( + '--max-old-space-size=4096 --localstorage-file --enable-source-maps', + ); + expect(result).toBe('--max-old-space-size=4096 --enable-source-maps'); + }); + + it('should not modify NODE_OPTIONS when not set', () => { + const result = runWithNodeOptions(undefined); + expect(result).toBe(''); + }); + + it('should handle empty NODE_OPTIONS', () => { + const result = runWithNodeOptions(''); + expect(result).toBe(''); + }); + + it('should pass through to child command', () => { + const result = runWithNodeOptions( + '--localstorage-file', + 'echo "hello world"', + ); + expect(result).toBe('hello world'); + }); + + it('should allow node to run without warning when NODE_OPTIONS has --localstorage-file', () => { + // This test verifies that node runs successfully without the warning + const result = runWithNodeOptions( + '--localstorage-file', + 'node -e "console.log(\\"success\\")"', + ); + expect(result).toBe('success'); + }); +});