basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2025 Google LLC4 * SPDX-License-Identifier: Apache-2.05 */6 7import { exec, execSync, spawn, type ChildProcess } from 'node:child_process';8import os from 'node:os';9import path from 'node:path';10import fs from 'node:fs';11import { quote, parse } from 'shell-quote';12import {13 getUserSettingsDir,14 SETTINGS_DIRECTORY_NAME,15} from '../config/settings.js';16import { promisify } from 'node:util';17import type { Config, SandboxConfig } from '@qwen-code/qwen-code-core';18import {19 FatalSandboxError,20 Storage,21 isSubpath,22 resolveBundleDir,23} from '@qwen-code/qwen-code-core';24import { randomBytes } from 'node:crypto';25import { writeStderrLine } from './stdioHelpers.js';26import { parseSandboxImageName } from './sandboxImageName.js';27import { isContainerPathWithinWorkdir } from './sandbox-path.js';28import { parseSandboxMountSpec } from './sandboxMounts.js';29 30const execAsync = promisify(exec);31 32function getContainerPath(hostPath: string): string {33 if (os.platform() !== 'win32') {34 return hostPath;35 }36 37 const withForwardSlashes = hostPath.replace(/\\/g, '/');38 const match = withForwardSlashes.match(/^([A-Z]):\/(.*)/i);39 if (match) {40 return `/${match[1].toLowerCase()}/${match[2]}`;41 }42 return hostPath;43}44 45function ensureDirectoryAndGetRealPath(dir: string): string {46 if (!fs.existsSync(dir)) {47 fs.mkdirSync(dir, { recursive: true });48 }49 return fs.realpathSync(dir);50}51 52const LOCAL_DEV_SANDBOX_IMAGE_NAME = 'qwen-code-sandbox';53const SANDBOX_NETWORK_NAME = 'qwen-code-sandbox';54const SANDBOX_PROXY_NAME = 'qwen-code-sandbox-proxy';55const BUILTIN_SEATBELT_PROFILES = [56 'permissive-open',57 'permissive-closed',58 'permissive-proxied',59 'restrictive-open',60 'restrictive-closed',61 'restrictive-proxied',62];63 64export function resolveSeatbeltProfileFile(65 profile: string,66 importMetaUrl = import.meta.url,67): string {68 if (!BUILTIN_SEATBELT_PROFILES.includes(profile)) {69 return path.join(SETTINGS_DIRECTORY_NAME, `sandbox-macos-${profile}.sb`);70 }71 72 return path.join(73 resolveBundleDir(importMetaUrl),74 `sandbox-macos-${profile}.sb`,75 );76}77 78/**79 * Determines whether the sandbox container should be run with the current user's UID and GID.80 * This is often necessary on Linux systems when using rootful Docker without userns-remap81 * configured, to avoid permission issues with82 * mounted volumes.83 *84 * The behavior is controlled by the `SANDBOX_SET_UID_GID` environment variable:85 * - If `SANDBOX_SET_UID_GID` is "1" or "true", this function returns `true`.86 * - If `SANDBOX_SET_UID_GID` is "0" or "false", this function returns `false`.87 * - If `SANDBOX_SET_UID_GID` is not set:88 * - On Linux, it defaults to `true`.89 * - On other OSes, it defaults to `false`.90 *91 * For more context on running Docker containers as non-root, see:92 * https://medium.com/redbubble/running-a-docker-container-as-a-non-root-user-7d2e00f8ee1593 *94 * @returns {Promise<boolean>} A promise that resolves to true if the current user's UID/GID should be used, false otherwise.95 */96async function shouldUseCurrentUserInSandbox(): Promise<boolean> {97 const envVar = process.env['SANDBOX_SET_UID_GID']?.toLowerCase().trim();98 99 if (envVar === '1' || envVar === 'true') {100 return true;101 }102 if (envVar === '0' || envVar === 'false') {103 return false;104 }105 106 if (os.platform() === 'linux') {107 const debugEnv = [process.env['DEBUG'], process.env['DEBUG_MODE']].some(108 (v) => v === 'true' || v === '1',109 );110 if (debugEnv) {111 // Use stderr so it doesn't clutter normal STDOUT output (e.g. in `--prompt` runs).112 writeStderrLine(113 'INFO: Using current user UID/GID in Linux sandbox. Set SANDBOX_SET_UID_GID=false to disable.',114 );115 }116 return true;117 }118 119 return false;120}121 122function ports(): string[] {123 return (process.env['SANDBOX_PORTS'] ?? '')124 .split(',')125 .filter((p) => p.trim())126 .map((p) => p.trim());127}128 129function entrypoint(workdir: string, cliArgs: string[]): string[] {130 const isWindows = os.platform() === 'win32';131 const containerWorkdir = getContainerPath(workdir);132 const shellCmds = [];133 const pathSeparator = isWindows ? ';' : ':';134 135 let pathSuffix = '';136 if (process.env['PATH']) {137 const paths = process.env['PATH'].split(pathSeparator);138 for (const p of paths) {139 const containerPath = getContainerPath(p);140 if (isContainerPathWithinWorkdir(containerWorkdir, containerPath)) {141 pathSuffix += `:${containerPath}`;142 }143 }144 }145 if (pathSuffix) {146 shellCmds.push(`export PATH="$PATH${pathSuffix}";`);147 }148 149 let pythonPathSuffix = '';150 if (process.env['PYTHONPATH']) {151 const paths = process.env['PYTHONPATH'].split(pathSeparator);152 for (const p of paths) {153 const containerPath = getContainerPath(p);154 if (isContainerPathWithinWorkdir(containerWorkdir, containerPath)) {155 pythonPathSuffix += `:${containerPath}`;156 }157 }158 }159 if (pythonPathSuffix) {160 shellCmds.push(`export PYTHONPATH="$PYTHONPATH${pythonPathSuffix}";`);161 }162 163 const projectSandboxBashrc = path.join(164 SETTINGS_DIRECTORY_NAME,165 'sandbox.bashrc',166 );167 if (fs.existsSync(projectSandboxBashrc)) {168 shellCmds.push(`source ${getContainerPath(projectSandboxBashrc)};`);169 }170 171 ports().forEach((p) =>172 shellCmds.push(173 `socat TCP4-LISTEN:${p},bind=$(hostname -i),fork,reuseaddr TCP4:127.0.0.1:${p} 2> /dev/null &`,174 ),175 );176 177 const quotedCliArgs = cliArgs.slice(2).map((arg) => quote([arg]));178 const cliCmd =179 process.env['NODE_ENV'] === 'development'180 ? process.env['DEBUG']181 ? 'npm run debug --'182 : 'npm rebuild && npm run start --'183 : process.env['DEBUG']184 ? `node --inspect-brk=0.0.0.0:${process.env['DEBUG_PORT'] || '9229'} $(which qwen)`185 : 'qwen';186 187 const args = [...shellCmds, cliCmd, ...quotedCliArgs];188 return ['bash', '-c', args.join(' ')];189}190 191export async function start_sandbox(192 config: SandboxConfig,193 nodeArgs: string[] = [],194 cliConfig?: Config,195 cliArgs: string[] = [],196): Promise<number> {197 if (config.command === 'sandbox-exec') {198 // disallow BUILD_SANDBOX199 if (process.env['BUILD_SANDBOX']) {200 throw new FatalSandboxError(201 'Cannot BUILD_SANDBOX when using macOS Seatbelt',202 );203 }204 205 const profile = (process.env['SEATBELT_PROFILE'] ??= 'permissive-open');206 const profileFile = resolveSeatbeltProfileFile(profile);207 if (!fs.existsSync(profileFile)) {208 throw new FatalSandboxError(209 `Missing macos seatbelt profile file '${profileFile}'`,210 );211 }212 // Log on STDERR so it doesn't clutter the output on STDOUT213 writeStderrLine(`using macos seatbelt (profile: ${profile}) ...`);214 // if DEBUG is set, convert to --inspect-brk in NODE_OPTIONS215 const nodeOptions = [216 ...(process.env['DEBUG'] ? ['--inspect-brk'] : []),217 ...nodeArgs,218 ].join(' ');219 220 // Canonicalize via realpathSync so seatbelt's `subpath` matcher sees the221 // same path the kernel will. mkdirSync first because realpathSync throws222 // on missing dirs and a custom QWEN_HOME / QWEN_RUNTIME_DIR may not exist223 // yet on first run.224 const qwenDir = Storage.getGlobalQwenDir();225 const runtimeDir = Storage.getRuntimeBaseDir();226 fs.mkdirSync(qwenDir, { recursive: true });227 fs.mkdirSync(runtimeDir, { recursive: true });228 229 const args = [230 '-D',231 `TARGET_DIR=${fs.realpathSync(process.cwd())}`,232 '-D',233 `TMP_DIR=${fs.realpathSync(os.tmpdir())}`,234 '-D',235 `HOME_DIR=${fs.realpathSync(os.homedir())}`,236 '-D',237 `CACHE_DIR=${fs.realpathSync(execSync(`getconf DARWIN_USER_CACHE_DIR`).toString().trim())}`,238 '-D',239 `QWEN_DIR=${fs.realpathSync(qwenDir)}`,240 '-D',241 `RUNTIME_DIR=${fs.realpathSync(runtimeDir)}`,242 ];243 244 // Add included directories from the workspace context245 // Always add 5 INCLUDE_DIR parameters to ensure .sb files can reference them246 const MAX_INCLUDE_DIRS = 5;247 const targetDir = fs.realpathSync(cliConfig?.getTargetDir() || '');248 const includedDirs: string[] = [];249 250 if (cliConfig) {251 const workspaceContext = cliConfig.getWorkspaceContext();252 const directories = workspaceContext.getDirectories();253 254 // Filter out TARGET_DIR255 for (const dir of directories) {256 const realDir = fs.realpathSync(dir);257 if (realDir !== targetDir) {258 includedDirs.push(realDir);259 }260 }261 }262 263 for (let i = 0; i < MAX_INCLUDE_DIRS; i++) {264 let dirPath = '/dev/null'; // Default to a safe path that won't cause issues265 266 if (i < includedDirs.length) {267 dirPath = includedDirs[i];268 }269 270 args.push('-D', `INCLUDE_DIR_${i}=${dirPath}`);271 }272 273 const finalArgv = cliArgs;274 275 args.push(276 '-f',277 profileFile,278 'sh',279 '-c',280 [281 `SANDBOX=sandbox-exec`,282 `NODE_OPTIONS="${nodeOptions}"`,283 ...finalArgv.map((arg) => quote([arg])),284 ].join(' '),285 );286 // start and set up proxy if QWEN_SANDBOX_PROXY_COMMAND is set287 const proxyCommand = process.env['QWEN_SANDBOX_PROXY_COMMAND'];288 let proxyProcess: ChildProcess | undefined = undefined;289 let sandboxProcess: ChildProcess | undefined = undefined;290 const sandboxEnv = { ...process.env };291 if (proxyCommand) {292 const proxy =293 process.env['HTTPS_PROXY'] ||294 process.env['https_proxy'] ||295 process.env['HTTP_PROXY'] ||296 process.env['http_proxy'] ||297 'http://localhost:8877';298 sandboxEnv['HTTPS_PROXY'] = proxy;299 sandboxEnv['https_proxy'] = proxy; // lower-case can be required, e.g. for curl300 sandboxEnv['HTTP_PROXY'] = proxy;301 sandboxEnv['http_proxy'] = proxy;302 const noProxy = process.env['NO_PROXY'] || process.env['no_proxy'];303 if (noProxy) {304 sandboxEnv['NO_PROXY'] = noProxy;305 sandboxEnv['no_proxy'] = noProxy;306 }307 // Note: CodeQL flags this as js/shell-command-injection-from-environment.308 // This is intentional - CLI tool executes user-provided proxy commands.309 proxyProcess = spawn('bash', ['-c', proxyCommand], {310 stdio: ['ignore', 'pipe', 'pipe'],311 detached: true,312 });313 // install handlers to stop proxy on exit/signal314 const stopProxy = () => {315 writeStderrLine('stopping proxy ...');316 if (proxyProcess?.pid) {317 process.kill(-proxyProcess.pid, 'SIGTERM');318 }319 };320 process.on('exit', stopProxy);321 process.on('SIGINT', stopProxy);322 process.on('SIGTERM', stopProxy);323 324 // Proxy stdout is intentionally not piped — it disrupts ink rendering.325 proxyProcess.stderr?.on('data', (data) => {326 writeStderrLine(data.toString());327 });328 proxyProcess.on('close', (code, signal) => {329 if (sandboxProcess?.pid) {330 process.kill(-sandboxProcess.pid, 'SIGTERM');331 }332 throw new FatalSandboxError(333 `Proxy command '${proxyCommand}' exited with code ${code}, signal ${signal}`,334 );335 });336 writeStderrLine('waiting for proxy to start ...');337 await execAsync(338 `until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done`,339 );340 }341 // spawn child and let it inherit stdio342 process.stdin.pause();343 sandboxProcess = spawn(config.command, args, {344 stdio: 'inherit',345 });346 return new Promise((resolve, reject) => {347 sandboxProcess?.on('error', reject);348 sandboxProcess?.on('close', (code) => {349 process.stdin.resume();350 resolve(code ?? 1);351 });352 });353 }354 355 writeStderrLine(`hopping into sandbox (command: ${config.command}) ...`);356 357 // determine full path for qwen-code to distinguish linked vs installed setting358 const gcPath = fs.realpathSync(process.argv[1]);359 360 const projectSandboxDockerfile = path.join(361 SETTINGS_DIRECTORY_NAME,362 'sandbox.Dockerfile',363 );364 const isCustomProjectSandbox = fs.existsSync(projectSandboxDockerfile);365 366 const image = config.image;367 const workdir = path.resolve(process.cwd());368 const containerWorkdir = getContainerPath(workdir);369 370 // if BUILD_SANDBOX is set, then call scripts/build_sandbox.js under qwen-code repo371 //372 // note this can only be done with binary linked from qwen-code repo373 if (process.env['BUILD_SANDBOX']) {374 if (!gcPath.includes('qwen-code/packages/')) {375 throw new FatalSandboxError(376 'Cannot build sandbox using installed Qwen Code binary; ' +377 'run `npm link ./packages/cli` under QwenCode-cli repo to switch to linked binary.',378 );379 } else {380 writeStderrLine('building sandbox ...');381 const gcRoot = gcPath.split('/packages/')[0];382 // if project folder has sandbox.Dockerfile under project settings folder, use that383 let buildArgs = '';384 const projectSandboxDockerfile = path.join(385 SETTINGS_DIRECTORY_NAME,386 'sandbox.Dockerfile',387 );388 if (isCustomProjectSandbox) {389 writeStderrLine(`using ${projectSandboxDockerfile} for sandbox`);390 buildArgs += `-f ${path.resolve(projectSandboxDockerfile)} -i ${image}`;391 }392 execSync(393 `cd ${gcRoot} && node scripts/build_sandbox.js -s ${buildArgs}`,394 {395 stdio: 'inherit',396 env: {397 ...process.env,398 QWEN_SANDBOX: config.command, // in case sandbox is enabled via flags (see config.ts under cli package)399 },400 },401 );402 }403 }404 405 // stop if image is missing406 if (!(await ensureSandboxImageIsPresent(config.command, image))) {407 const remedy =408 image === LOCAL_DEV_SANDBOX_IMAGE_NAME409 ? 'Try running `npm run build:all` or `npm run build:sandbox` under the qwen-code repo to build it locally, or check the image name and your network connection.'410 : 'Please check the image name, your network connection, or notify qwen-code-dev@service.alibaba.com if the issue persists.';411 throw new FatalSandboxError(412 `Sandbox image '${image}' is missing or could not be pulled. ${remedy}`,413 );414 }415 416 // use interactive mode and auto-remove container on exit417 // run init binary inside container to forward signals & reap zombies418 const args = ['run', '-i', '--rm', '--init', '--workdir', containerWorkdir];419 420 // add custom flags from SANDBOX_FLAGS421 if (process.env['SANDBOX_FLAGS']) {422 const flags = parse(process.env['SANDBOX_FLAGS'], process.env).filter(423 (f): f is string => typeof f === 'string',424 );425 args.push(...flags);426 }427 428 // add TTY only if stdin is TTY as well, i.e. for piped input don't init TTY in container429 if (process.stdin.isTTY) {430 args.push('-t');431 }432 433 // allow access to host.docker.internal434 args.push('--add-host', 'host.docker.internal:host-gateway');435 436 // mount current directory as working directory in sandbox (set via --workdir)437 args.push('--volume', `${workdir}:${containerWorkdir}`);438 439 // Mount user settings at /home/node/.qwen and at the canonical host path440 // used by QWEN_HOME, unless that host path is already covered by a broader441 // runtime-dir mount below.442 const userSettingsDirOnHost = getUserSettingsDir();443 const runtimeBaseDirOnHost = Storage.getRuntimeBaseDir();444 const userSettingsDirRealPath = ensureDirectoryAndGetRealPath(445 userSettingsDirOnHost,446 );447 const runtimeBaseDirRealPath =448 ensureDirectoryAndGetRealPath(runtimeBaseDirOnHost);449 const userSettingsDirInSandbox = getContainerPath(450 `/home/node/${SETTINGS_DIRECTORY_NAME}`,451 );452 const userSettingsDirContainerPath = getContainerPath(453 userSettingsDirRealPath,454 );455 const runtimeBaseDirContainerPath = getContainerPath(runtimeBaseDirRealPath);456 const runtimeCoveredByUserSettings = isSubpath(457 userSettingsDirRealPath,458 runtimeBaseDirRealPath,459 );460 const userSettingsCoveredByRuntime = isSubpath(461 runtimeBaseDirRealPath,462 userSettingsDirRealPath,463 );464 const runtimeSameAsUserSettings =465 runtimeCoveredByUserSettings && userSettingsCoveredByRuntime;466 467 args.push(468 '--volume',469 `${userSettingsDirRealPath}:${userSettingsDirInSandbox}`,470 );471 if (472 (!userSettingsCoveredByRuntime || runtimeSameAsUserSettings) &&473 userSettingsDirInSandbox !== userSettingsDirContainerPath474 ) {475 args.push(476 '--volume',477 `${userSettingsDirRealPath}:${userSettingsDirContainerPath}`,478 );479 }480 481 // Pass QWEN_HOME so the sandboxed CLI resolves the global qwen dir to the482 // same path the host did, instead of relying on the /home/node/.qwen mount483 // being the default fallback.484 args.push('--env', `QWEN_HOME=${userSettingsDirContainerPath}`);485 486 // Mount the runtime base dir and pass QWEN_RUNTIME_DIR when it diverges487 // from the global qwen dir; otherwise the existing user-settings mount488 // already covers it.489 if (!runtimeCoveredByUserSettings) {490 args.push(491 '--volume',492 `${runtimeBaseDirRealPath}:${runtimeBaseDirContainerPath}`,493 );494 }495 if (!runtimeSameAsUserSettings) {496 args.push('--env', `QWEN_RUNTIME_DIR=${runtimeBaseDirContainerPath}`);497 }498 499 // mount os.tmpdir() as os.tmpdir() inside container500 args.push('--volume', `${os.tmpdir()}:${getContainerPath(os.tmpdir())}`);501 502 // mount gcloud config directory if it exists503 const gcloudConfigDir = path.join(os.homedir(), '.config', 'gcloud');504 if (fs.existsSync(gcloudConfigDir)) {505 args.push(506 '--volume',507 `${gcloudConfigDir}:${getContainerPath(gcloudConfigDir)}:ro`,508 );509 }510 511 // mount ADC file if GOOGLE_APPLICATION_CREDENTIALS is set512 if (process.env['GOOGLE_APPLICATION_CREDENTIALS']) {513 const adcFile = process.env['GOOGLE_APPLICATION_CREDENTIALS'];514 if (fs.existsSync(adcFile)) {515 args.push('--volume', `${adcFile}:${getContainerPath(adcFile)}:ro`);516 args.push(517 '--env',518 `GOOGLE_APPLICATION_CREDENTIALS=${getContainerPath(adcFile)}`,519 );520 }521 }522 523 // mount paths listed in SANDBOX_MOUNTS524 if (process.env['SANDBOX_MOUNTS']) {525 for (let mount of process.env['SANDBOX_MOUNTS'].split(',')) {526 if (mount.trim()) {527 // parse mount as from:to:opts528 const { from, to, opts } = parseSandboxMountSpec(mount);529 mount = `${from}:${to}:${opts}`;530 // check that from path is absolute531 if (!path.isAbsolute(from)) {532 throw new FatalSandboxError(533 `Path '${from}' listed in SANDBOX_MOUNTS must be absolute`,534 );535 }536 // check that from path exists on host537 if (!fs.existsSync(from)) {538 throw new FatalSandboxError(539 `Missing mount path '${from}' listed in SANDBOX_MOUNTS`,540 );541 }542 writeStderrLine(`SANDBOX_MOUNTS: ${from} -> ${to} (${opts})`);543 args.push('--volume', mount);544 }545 }546 }547 548 // expose env-specified ports on the sandbox549 ports().forEach((p) => args.push('--publish', `${p}:${p}`));550 551 // if DEBUG is set, expose debugging port552 if (process.env['DEBUG']) {553 const debugPort = process.env['DEBUG_PORT'] || '9229';554 args.push(`--publish`, `${debugPort}:${debugPort}`);555 }556 557 // copy proxy environment variables, replacing localhost with SANDBOX_PROXY_NAME558 // copy as both upper-case and lower-case as is required by some utilities559 // QWEN_SANDBOX_PROXY_COMMAND implies HTTPS_PROXY unless HTTP_PROXY is set560 const proxyCommand = process.env['QWEN_SANDBOX_PROXY_COMMAND'];561 562 if (proxyCommand) {563 let proxy =564 process.env['HTTPS_PROXY'] ||565 process.env['https_proxy'] ||566 process.env['HTTP_PROXY'] ||567 process.env['http_proxy'] ||568 'http://localhost:8877';569 proxy = proxy.replace('localhost', SANDBOX_PROXY_NAME);570 if (proxy) {571 args.push('--env', `HTTPS_PROXY=${proxy}`);572 args.push('--env', `https_proxy=${proxy}`); // lower-case can be required, e.g. for curl573 args.push('--env', `HTTP_PROXY=${proxy}`);574 args.push('--env', `http_proxy=${proxy}`);575 }576 const noProxy = process.env['NO_PROXY'] || process.env['no_proxy'];577 if (noProxy) {578 args.push('--env', `NO_PROXY=${noProxy}`);579 args.push('--env', `no_proxy=${noProxy}`);580 }581 582 // if using proxy, switch to internal networking through proxy583 if (proxy) {584 execSync(585 `${config.command} network inspect ${SANDBOX_NETWORK_NAME} || ${config.command} network create --internal ${SANDBOX_NETWORK_NAME}`,586 );587 args.push('--network', SANDBOX_NETWORK_NAME);588 // if proxy command is set, create a separate network w/ host access (i.e. non-internal)589 // we will run proxy in its own container connected to both host network and internal network590 // this allows proxy to work even on rootless podman on macos with host<->vm<->container isolation591 if (proxyCommand) {592 execSync(593 `${config.command} network inspect ${SANDBOX_PROXY_NAME} || ${config.command} network create ${SANDBOX_PROXY_NAME}`,594 );595 }596 }597 }598 599 // name container after image, plus random suffix to avoid conflicts600 const imageName = parseSandboxImageName(image);601 const isIntegrationTest =602 process.env['QWEN_CODE_INTEGRATION_TEST'] === 'true';603 let containerName;604 if (isIntegrationTest) {605 containerName = `qwen-code-integration-test-${randomBytes(4).toString(606 'hex',607 )}`;608 writeStderrLine(`ContainerName: ${containerName}`);609 } else {610 let index = 0;611 const containerNameCheck = execSync(612 `${config.command} ps -a --format "{{.Names}}"`,613 )614 .toString()615 .trim();616 while (containerNameCheck.includes(`${imageName}-${index}`)) {617 index++;618 }619 containerName = `${imageName}-${index}`;620 writeStderrLine(`ContainerName (regular): ${containerName}`);621 }622 args.push('--name', containerName, '--hostname', containerName);623 624 // copy QWEN_CODE_TEST_VAR for integration tests625 if (process.env['QWEN_CODE_TEST_VAR']) {626 args.push(627 '--env',628 `QWEN_CODE_TEST_VAR=${process.env['QWEN_CODE_TEST_VAR']}`,629 );630 }631 for (const envVar of [632 'QWEN_DEBUG_LOG_FILE',633 'QWEN_CODE_LEGACY_MCP_BLOCKING',634 ] as const) {635 if (process.env[envVar]) {636 args.push('--env', `${envVar}=${process.env[envVar]}`);637 }638 }639 if (process.env['QWEN_CODE_MCP_APPROVALS_PATH']) {640 args.push(641 '--env',642 `QWEN_CODE_MCP_APPROVALS_PATH=${getContainerPath(643 process.env['QWEN_CODE_MCP_APPROVALS_PATH'],644 )}`,645 );646 }647 648 // copy GEMINI_API_KEY(s)649 if (process.env['GEMINI_API_KEY']) {650 args.push('--env', `GEMINI_API_KEY=${process.env['GEMINI_API_KEY']}`);651 }652 if (process.env['GOOGLE_API_KEY']) {653 args.push('--env', `GOOGLE_API_KEY=${process.env['GOOGLE_API_KEY']}`);654 }655 656 // copy OPENAI_API_KEY and related env vars for Qwen657 if (process.env['OPENAI_API_KEY']) {658 args.push('--env', `OPENAI_API_KEY=${process.env['OPENAI_API_KEY']}`);659 }660 if (process.env['OPENAI_BASE_URL']) {661 args.push('--env', `OPENAI_BASE_URL=${process.env['OPENAI_BASE_URL']}`);662 }663 if (process.env['OPENAI_MODEL']) {664 args.push('--env', `OPENAI_MODEL=${process.env['OPENAI_MODEL']}`);665 }666 667 // copy GOOGLE_GENAI_USE_VERTEXAI668 if (process.env['GOOGLE_GENAI_USE_VERTEXAI']) {669 args.push(670 '--env',671 `GOOGLE_GENAI_USE_VERTEXAI=${process.env['GOOGLE_GENAI_USE_VERTEXAI']}`,672 );673 }674 675 // copy GOOGLE_GENAI_USE_GCA676 if (process.env['GOOGLE_GENAI_USE_GCA']) {677 args.push(678 '--env',679 `GOOGLE_GENAI_USE_GCA=${process.env['GOOGLE_GENAI_USE_GCA']}`,680 );681 }682 683 // copy GOOGLE_CLOUD_PROJECT684 if (process.env['GOOGLE_CLOUD_PROJECT']) {685 args.push(686 '--env',687 `GOOGLE_CLOUD_PROJECT=${process.env['GOOGLE_CLOUD_PROJECT']}`,688 );689 }690 691 // copy GOOGLE_CLOUD_LOCATION692 if (process.env['GOOGLE_CLOUD_LOCATION']) {693 args.push(694 '--env',695 `GOOGLE_CLOUD_LOCATION=${process.env['GOOGLE_CLOUD_LOCATION']}`,696 );697 }698 699 // copy GEMINI_MODEL700 if (process.env['GEMINI_MODEL']) {701 args.push('--env', `GEMINI_MODEL=${process.env['GEMINI_MODEL']}`);702 }703 704 // copy TERM and COLORTERM to try to maintain terminal setup705 if (process.env['TERM']) {706 args.push('--env', `TERM=${process.env['TERM']}`);707 }708 if (process.env['COLORTERM']) {709 args.push('--env', `COLORTERM=${process.env['COLORTERM']}`);710 }711 712 // Pass through IDE mode environment variables713 for (const envVar of [714 'QWEN_CODE_IDE_SERVER_PORT',715 'QWEN_CODE_IDE_WORKSPACE_PATH',716 'TERM_PROGRAM',717 ]) {718 if (process.env[envVar]) {719 args.push('--env', `${envVar}=${process.env[envVar]}`);720 }721 }722 723 // copy VIRTUAL_ENV if under working directory724 // also mount-replace VIRTUAL_ENV directory with <project_settings>/sandbox.venv725 // sandbox can then set up this new VIRTUAL_ENV directory using sandbox.bashrc (see below)726 // directory will be empty if not set up, which is still preferable to having host binaries727 const virtualEnv = process.env['VIRTUAL_ENV'];728 if (729 virtualEnv &&730 isContainerPathWithinWorkdir(731 getContainerPath(workdir),732 getContainerPath(virtualEnv),733 )734 ) {735 const sandboxVenvPath = path.resolve(736 SETTINGS_DIRECTORY_NAME,737 'sandbox.venv',738 );739 if (!fs.existsSync(sandboxVenvPath)) {740 fs.mkdirSync(sandboxVenvPath, { recursive: true });741 }742 args.push('--volume', `${sandboxVenvPath}:${getContainerPath(virtualEnv)}`);743 args.push('--env', `VIRTUAL_ENV=${getContainerPath(virtualEnv)}`);744 }745 746 // copy additional environment variables from SANDBOX_ENV747 if (process.env['SANDBOX_ENV']) {748 for (let env of process.env['SANDBOX_ENV'].split(',')) {749 if ((env = env.trim())) {750 if (env.includes('=')) {751 writeStderrLine(`SANDBOX_ENV: ${env}`);752 args.push('--env', env);753 } else {754 throw new FatalSandboxError(755 'SANDBOX_ENV must be a comma-separated list of key=value pairs',756 );757 }758 }759 }760 }761 762 // copy NODE_OPTIONS763 const existingNodeOptions = process.env['NODE_OPTIONS'] || '';764 const allNodeOptions = [765 ...(existingNodeOptions ? [existingNodeOptions] : []),766 ...nodeArgs,767 ].join(' ');768 769 if (allNodeOptions.length > 0) {770 args.push('--env', `NODE_OPTIONS="${allNodeOptions}"`);771 }772 773 // set SANDBOX as container name774 args.push('--env', `SANDBOX=${containerName}`);775 776 // for podman only, use empty --authfile to skip unnecessary auth refresh overhead777 if (config.command === 'podman') {778 const emptyAuthFilePath = path.join(os.tmpdir(), 'empty_auth.json');779 fs.writeFileSync(emptyAuthFilePath, '{}', 'utf-8');780 args.push('--authfile', emptyAuthFilePath);781 }782 783 // Determine if the current user's UID/GID should be passed to the sandbox.784 // See shouldUseCurrentUserInSandbox for more details.785 let userFlag = '';786 const finalEntrypoint = entrypoint(workdir, cliArgs);787 788 // Check if we should use current user's UID/GID in sandbox789 // In integration test mode, we still respect SANDBOX_SET_UID_GID to allow790 // tests that need to access host's ~/.qwen (e.g., --resume functionality)791 const useCurrentUser = await shouldUseCurrentUserInSandbox();792 793 if (useCurrentUser) {794 // SANDBOX_SET_UID_GID is enabled: create user with host's UID/GID795 // This includes integration test mode with SANDBOX_SET_UID_GID=true,796 // allowing tests that need to access host's ~/.qwen (e.g., --resume) to work.797 // For the user-creation logic to work, the container must start as root.798 // The entrypoint script then handles dropping privileges to the correct user.799 args.push('--user', 'root');800 801 const uid = execSync('id -u').toString().trim();802 const gid = execSync('id -g').toString().trim();803 804 // Instead of passing --user to the main sandbox container, we let it805 // start as root, then create a user with the host's UID/GID, and806 // finally switch to that user to run the qwen process. This is807 // necessary on Linux to ensure the user exists within the808 // container's /etc/passwd file, which is required by os.userInfo().809 const username = 'qwen';810 const homeDir = getContainerPath(os.homedir());811 812 const setupUserCommands = [813 // Use -f with groupadd to avoid errors if the group already exists.814 `groupadd -f -g ${gid} ${username}`,815 // Create user only if it doesn't exist. Use -o for non-unique UID.816 `id -u ${username} &>/dev/null || useradd -o -u ${uid} -g ${gid} -d ${homeDir} -s /bin/bash ${username}`,817 ].join(' && ');818 819 const originalCommand = finalEntrypoint[2];820 const escapedOriginalCommand = originalCommand.replace(/'/g, "'\\''");821 822 // Use `su -p` to preserve the environment.823 const suCommand = `su -p ${username} -c '${escapedOriginalCommand}'`;824 825 // The entrypoint is always `['bash', '-c', '<command>']`, so we modify the command part.826 finalEntrypoint[2] = `${setupUserCommands} && ${suCommand}`;827 828 // We still need userFlag for the simpler proxy container, which does not have this issue.829 userFlag = `--user ${uid}:${gid}`;830 // When forcing a UID in the sandbox, $HOME can be reset to '/', so we copy $HOME as well.831 args.push('--env', `HOME=${os.homedir()}`);832 } else if (isIntegrationTest) {833 // Integration test mode with UID/GID matching disabled: use root834 args.push('--user', 'root');835 userFlag = '--user root';836 }837 // else: non-IT mode with UID/GID matching disabled - use image default user (node)838 839 // push container image name840 args.push(image);841 842 // push container entrypoint (including args)843 args.push(...finalEntrypoint);844 845 // start and set up proxy if QWEN_SANDBOX_PROXY_COMMAND is set846 let proxyProcess: ChildProcess | undefined = undefined;847 let sandboxProcess: ChildProcess | undefined = undefined;848 849 if (proxyCommand) {850 // run proxyCommand in its own container851 const proxyContainerCommand = `${config.command} run --rm --init ${userFlag} --name ${SANDBOX_PROXY_NAME} --network ${SANDBOX_PROXY_NAME} -p 8877:8877 -v ${process.cwd()}:${workdir} --workdir ${workdir} ${image} ${proxyCommand}`;852 const isWindows = os.platform() === 'win32';853 const proxyShell = isWindows ? 'cmd.exe' : 'bash';854 const proxyShellArgs = isWindows855 ? ['/c', proxyContainerCommand]856 : ['-c', proxyContainerCommand];857 // Note: CodeQL flags this as js/shell-command-injection-from-environment.858 // This is intentional - CLI tool executes user-provided proxy commands in container.859 proxyProcess = spawn(proxyShell, proxyShellArgs, {860 stdio: ['ignore', 'pipe', 'pipe'],861 detached: true,862 });863 // install handlers to stop proxy on exit/signal864 const stopProxy = () => {865 writeStderrLine('stopping proxy container ...');866 execSync(`${config.command} rm -f ${SANDBOX_PROXY_NAME}`);867 };868 process.on('exit', stopProxy);869 process.on('SIGINT', stopProxy);870 process.on('SIGTERM', stopProxy);871 872 // Proxy stdout is intentionally not piped — it disrupts ink rendering.873 proxyProcess.stderr?.on('data', (data) => {874 writeStderrLine(data.toString().trim());875 });876 proxyProcess.on('close', (code, signal) => {877 if (sandboxProcess?.pid) {878 process.kill(-sandboxProcess.pid, 'SIGTERM');879 }880 throw new FatalSandboxError(881 `Proxy container command '${proxyContainerCommand}' exited with code ${code}, signal ${signal}`,882 );883 });884 writeStderrLine('waiting for proxy to start ...');885 await execAsync(886 `until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done`,887 );888 // connect proxy container to sandbox network889 // (workaround for older versions of docker that don't support multiple --network args)890 await execAsync(891 `${config.command} network connect ${SANDBOX_NETWORK_NAME} ${SANDBOX_PROXY_NAME}`,892 );893 }894 895 // spawn child and let it inherit stdio896 process.stdin.pause();897 sandboxProcess = spawn(config.command, args, {898 stdio: 'inherit',899 });900 901 return new Promise<number>((resolve, reject) => {902 sandboxProcess.on('error', (err) => {903 writeStderrLine(`Sandbox process error: ${err}`);904 reject(err);905 });906 907 sandboxProcess?.on('close', (code, signal) => {908 process.stdin.resume();909 if (code !== 0 && code !== null) {910 writeStderrLine(911 `Sandbox process exited with code: ${code}, signal: ${signal}`,912 );913 }914 resolve(code ?? 1);915 });916 });917}918 919// Helper functions to ensure sandbox image is present920async function imageExists(sandbox: string, image: string): Promise<boolean> {921 return new Promise((resolve) => {922 const args = ['images', '-q', image];923 const checkProcess = spawn(sandbox, args);924 925 let stdoutData = '';926 if (checkProcess.stdout) {927 checkProcess.stdout.on('data', (data) => {928 stdoutData += data.toString();929 });930 }931 932 checkProcess.on('error', (err) => {933 writeStderrLine(934 `Failed to start '${sandbox}' command for image check: ${err.message}`,935 );936 resolve(false);937 });938 939 checkProcess.on('close', () => {940 // Non-zero exit code may indicate docker daemon not running, etc.941 // The primary success indicator is non-empty stdoutData.942 resolve(stdoutData.trim() !== '');943 });944 });945}946 947async function pullImage(sandbox: string, image: string): Promise<boolean> {948 writeStderrLine(`Attempting to pull image ${image} using ${sandbox}...`);949 return new Promise((resolve) => {950 const args = ['pull', image];951 const pullProcess = spawn(sandbox, args, { stdio: 'pipe' });952 953 let stderrData = '';954 955 const onStdoutData = (data: Buffer) => {956 writeStderrLine(data.toString().trim()); // Show pull progress957 };958 959 const onStderrData = (data: Buffer) => {960 stderrData += data.toString();961 writeStderrLine(data.toString().trim()); // Show pull errors/info from the command itself962 };963 964 const onError = (err: Error) => {965 writeStderrLine(966 `Failed to start '${sandbox} pull ${image}' command: ${err.message}`,967 );968 cleanup();969 resolve(false);970 };971 972 const onClose = (code: number | null) => {973 if (code === 0) {974 writeStderrLine(`Successfully pulled image ${image}.`);975 cleanup();976 resolve(true);977 } else {978 writeStderrLine(979 `Failed to pull image ${image}. '${sandbox} pull ${image}' exited with code ${code}.`,980 );981 if (stderrData.trim()) {982 // Details already printed by the stderr listener above983 }984 cleanup();985 resolve(false);986 }987 };988 989 const cleanup = () => {990 if (pullProcess.stdout) {991 pullProcess.stdout.removeListener('data', onStdoutData);992 }993 if (pullProcess.stderr) {994 pullProcess.stderr.removeListener('data', onStderrData);995 }996 pullProcess.removeListener('error', onError);997 pullProcess.removeListener('close', onClose);998 if (pullProcess.connected) {999 pullProcess.disconnect();1000 }1001 };1002 1003 if (pullProcess.stdout) {1004 pullProcess.stdout.on('data', onStdoutData);1005 }1006 if (pullProcess.stderr) {1007 pullProcess.stderr.on('data', onStderrData);1008 }1009 pullProcess.on('error', onError);1010 pullProcess.on('close', onClose);1011 });1012}1013 1014async function ensureSandboxImageIsPresent(1015 sandbox: string,1016 image: string,1017): Promise<boolean> {1018 writeStderrLine(`Checking for sandbox image: ${image}`);1019 if (await imageExists(sandbox, image)) {1020 writeStderrLine(`Sandbox image ${image} found locally.`);1021 return true;1022 }1023 1024 writeStderrLine(`Sandbox image ${image} not found locally.`);1025 if (image === LOCAL_DEV_SANDBOX_IMAGE_NAME) {1026 // user needs to build the image themselves1027 return false;1028 }1029 1030 if (await pullImage(sandbox, image)) {1031 // After attempting to pull, check again to be certain1032 if (await imageExists(sandbox, image)) {1033 writeStderrLine(`Sandbox image ${image} is now available after pulling.`);1034 return true;1035 } else {1036 writeStderrLine(1037 `Sandbox image ${image} still not found after a pull attempt. This might indicate an issue with the image name or registry, or the pull command reported success but failed to make the image available.`,1038 );1039 return false;1040 }1041 }1042 1043 writeStderrLine(1044 `Failed to obtain sandbox image ${image} after check and pull attempt.`,1045 );1046 return false; // Pull command failed or image still not present1047}1048 