Skip to content
Closed
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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/llxprt.sh
Original file line number Diff line number Diff line change
@@ -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" "$@"
5 changes: 3 additions & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -30,7 +30,8 @@
"prepack": "npm run build"
},
"files": [
"dist"
"dist",
"llxprt.sh"
],
"config": {
"sandboxImageUri": "ghcr.io/vybestack/llxprt-code/sandbox:0.8.0"
Expand Down
100 changes: 99 additions & 1 deletion packages/cli/src/utils/relaunch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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');
});
});
26 changes: 25 additions & 1 deletion packages/cli/src/utils/relaunch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -25,7 +44,12 @@ export async function relaunchAppInChildProcess(
additionalArgs: string[],
): Promise<number> {
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<string, string | undefined> = {
...process.env,
LLXPRT_CODE_NO_RELAUNCH: 'true',
NODE_OPTIONS: sanitizedNodeOptions,
};

const child = spawn(process.execPath, nodeArgs, {
stdio: 'inherit',
Expand Down
13 changes: 13 additions & 0 deletions scripts/sanitize-node-options.sh
Original file line number Diff line number Diff line change
@@ -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 "$@"
96 changes: 96 additions & 0 deletions scripts/tests/sanitize-node-options.test.js
Original file line number Diff line number Diff line change
@@ -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}'`, {

Check warning

Code scanning / CodeQL

Shell command built from environment values Medium test

This shell command depends on an uncontrolled
absolute path
.

Copilot Autofix

AI 8 days ago

In general, to fix this kind of issue you should avoid building a single shell command string that mixes paths and dynamic values. Instead, either (1) call the program directly with execFileSync or spawn and pass arguments as an array, or (2) if you must use a shell, provide the command and its arguments via the execFileSync/spawn interface rather than interpolating them into one string.

For this specific code, the best fix with minimal functional change is to replace execSync’s single-string command with a call that passes the script path and its arguments as an array. The shell semantics we need are: run sanitize-node-options.sh with arguments bash, -c, and the provided command string. That is equivalent to invoking execSync with scriptPath as the file to execute and ['bash', '-c', command] as the arguments. This preserves behavior (including use of bash -c and the current command string) while preventing scriptPath from being embedded inside a larger shell command, and it stops the shell from re-parsing scriptPath. Concretely, in scripts/tests/sanitize-node-options.test.js, change line 24 to call execSync(scriptPath, ['bash', '-c', command], { ... }) instead of using a template string. No new imports or helper functions are needed.

Suggested changeset 1
scripts/tests/sanitize-node-options.test.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/scripts/tests/sanitize-node-options.test.js b/scripts/tests/sanitize-node-options.test.js
--- a/scripts/tests/sanitize-node-options.test.js
+++ b/scripts/tests/sanitize-node-options.test.js
@@ -21,7 +21,7 @@
   }
 
   try {
-    return execSync(`${scriptPath} bash -c '${command}'`, {
+    return execSync(scriptPath, ['bash', '-c', command], {
       env,
       encoding: 'utf-8',
     }).trim();
EOF
@@ -21,7 +21,7 @@
}

try {
return execSync(`${scriptPath} bash -c '${command}'`, {
return execSync(scriptPath, ['bash', '-c', command], {
env,
encoding: 'utf-8',
}).trim();
Copilot is powered by AI and may make mistakes. Always verify output.
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');
});
});
Loading