CoolFace
Datasetpublic

basant307/AI_Governance_Project

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes48downloads
sandbox.ts1048 linesDownload Raw Back to utils
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 
basant307/AI_Governance_Project · CoolFace