/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import {
  exec,
  execFile,
  execSync,
  spawn,
  spawnSync,
  type ChildProcess,
} from 'node:child_process';
import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
import { quote, parse } from 'shell-quote';
import { promisify } from 'node:util';
import type { Config, SandboxConfig } from '@google/gemini-cli-core';
import {
  coreEvents,
  debugLogger,
  FatalSandboxError,
  GEMINI_DIR,
  homedir,
} from '@google/gemini-cli-core';
import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js';
import { randomBytes } from 'node:crypto';
import {
  getContainerPath,
  shouldUseCurrentUserInSandbox,
  parseImageName,
  ports,
  entrypoint,
  LOCAL_DEV_SANDBOX_IMAGE_NAME,
  SANDBOX_NETWORK_NAME,
  SANDBOX_PROXY_NAME,
  BUILTIN_SEATBELT_PROFILES,
} from './sandboxUtils.js';

const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);

export async function start_sandbox(
  config: SandboxConfig,
  nodeArgs: string[] = [],
  cliConfig?: Config,
  cliArgs: string[] = [],
): Promise<number> {
  const patcher = new ConsolePatcher({
    debugMode: cliConfig?.getDebugMode() || !!process.env['DEBUG'],
    stderr: true,
  });
  patcher.patch();

  let stopProxy: (() => void) | undefined = undefined;

  try {
    if (config.command === 'sandbox-exec') {
      // disallow BUILD_SANDBOX
      if (process.env['BUILD_SANDBOX']) {
        throw new FatalSandboxError(
          'Cannot BUILD_SANDBOX when using macOS Seatbelt',
        );
      }

      const profile = (process.env['SEATBELT_PROFILE'] ??= 'permissive-open');
      let profileFile = fileURLToPath(
        new URL(`sandbox-macos-${profile}.sb`, import.meta.url),
      );
      // if profile name is not recognized, look in user-level ~/.gemini first,
      // then fall back to project-level .gemini. path.basename() strips any
      // directory separators to prevent path traversal via SEATBELT_PROFILE.
      if (!BUILTIN_SEATBELT_PROFILES.includes(profile)) {
        const safeProfile = path.basename(profile);
        const fileName = `sandbox-macos-${safeProfile}.sb`;
        const userProfileFile = path.join(homedir(), GEMINI_DIR, fileName);
        const projectProfileFile = path.join(GEMINI_DIR, fileName);
        profileFile = fs.existsSync(userProfileFile)
          ? userProfileFile
          : projectProfileFile;
      }
      if (!fs.existsSync(profileFile)) {
        throw new FatalSandboxError(
          `Missing macos seatbelt profile file '${profileFile}'`,
        );
      }
      debugLogger.log(`using macos seatbelt (profile: ${profile}) ...`);
      // if DEBUG is set, convert to --inspect-brk in NODE_OPTIONS
      const nodeOptions = [
        ...(process.env['DEBUG'] ? ['--inspect-brk'] : []),
        ...nodeArgs,
      ].join(' ');

      const args = [
        '-D',
        `TARGET_DIR=${fs.realpathSync(process.cwd())}`,
        '-D',
        `TMP_DIR=${fs.realpathSync(os.tmpdir())}`,
        '-D',
        `HOME_DIR=${fs.realpathSync(homedir())}`,
        '-D',
        `CACHE_DIR=${fs.realpathSync((await execAsync('getconf DARWIN_USER_CACHE_DIR')).stdout.trim())}`,
      ];

      // Add included directories from the workspace context
      // Always add 5 INCLUDE_DIR parameters to ensure .sb files can reference them
      const MAX_INCLUDE_DIRS = 5;
      const targetDir = fs.realpathSync(cliConfig?.getTargetDir() || '');
      const includedDirs: string[] = [];

      if (cliConfig) {
        const workspaceContext = cliConfig.getWorkspaceContext();
        const directories = workspaceContext.getDirectories();

        // Filter out TARGET_DIR
        for (const dir of directories) {
          const realDir = fs.realpathSync(dir);
          if (realDir !== targetDir) {
            includedDirs.push(realDir);
          }
        }
      }

      // Add custom allowed paths from config
      if (config.allowedPaths) {
        for (const hostPath of config.allowedPaths) {
          if (
            hostPath &&
            path.isAbsolute(hostPath) &&
            fs.existsSync(hostPath)
          ) {
            const realDir = fs.realpathSync(hostPath);
            if (!includedDirs.includes(realDir) && realDir !== targetDir) {
              includedDirs.push(realDir);
            }
          }
        }
      }

      for (let i = 0; i < MAX_INCLUDE_DIRS; i++) {
        let dirPath = '/dev/null'; // Default to a safe path that won't cause issues

        if (i < includedDirs.length) {
          dirPath = includedDirs[i];
        }

        args.push('-D', `INCLUDE_DIR_${i}=${dirPath}`);
      }

      const finalArgv = cliArgs;

      args.push(
        '-f',
        profileFile,
        'sh',
        '-c',
        [
          `SANDBOX=sandbox-exec`,
          `NODE_OPTIONS="${nodeOptions}"`,
          ...finalArgv.map((arg) => quote([arg])),
        ].join(' '),
      );
      // start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set
      const proxyCommand = process.env['GEMINI_SANDBOX_PROXY_COMMAND'];
      let proxyProcess: ChildProcess | undefined = undefined;
      let sandboxProcess: ChildProcess | undefined = undefined;
      const sandboxEnv = { ...process.env };
      if (proxyCommand) {
        const proxy =
          process.env['HTTPS_PROXY'] ||
          process.env['https_proxy'] ||
          process.env['HTTP_PROXY'] ||
          process.env['http_proxy'] ||
          'http://localhost:8877';
        sandboxEnv['HTTPS_PROXY'] = proxy;
        sandboxEnv['https_proxy'] = proxy; // lower-case can be required, e.g. for curl
        sandboxEnv['HTTP_PROXY'] = proxy;
        sandboxEnv['http_proxy'] = proxy;
        const noProxy = process.env['NO_PROXY'] || process.env['no_proxy'];
        if (noProxy) {
          sandboxEnv['NO_PROXY'] = noProxy;
          sandboxEnv['no_proxy'] = noProxy;
        }
        proxyProcess = spawn(proxyCommand, {
          stdio: ['ignore', 'pipe', 'pipe'],
          shell: true,
          detached: true,
        });
        // install handlers to stop proxy on exit/signal
        stopProxy = () => {
          debugLogger.log('stopping proxy ...');
          if (proxyProcess?.pid) {
            try {
              process.kill(-proxyProcess.pid, 'SIGTERM');
            } catch {
              // ignore
            }
          }
        };
        process.on('exit', stopProxy);
        process.on('SIGINT', stopProxy);
        process.on('SIGTERM', stopProxy);

        // commented out as it disrupts ink rendering
        // proxyProcess.stdout?.on('data', (data) => {
        //   console.info(data.toString());
        // });
        proxyProcess.stderr?.on('data', (data) => {
          debugLogger.debug(`[PROXY STDERR]: ${data.toString().trim()}`);
        });
        proxyProcess.on('close', (code, signal) => {
          if (sandboxProcess?.pid) {
            process.kill(-sandboxProcess.pid, 'SIGTERM');
          }
          throw new FatalSandboxError(
            `Proxy command '${proxyCommand}' exited with code ${code}, signal ${signal}`,
          );
        });
        debugLogger.log('waiting for proxy to start ...');
        await execAsync(
          `until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done`,
        );
      }
      // spawn child and let it inherit stdio
      process.stdin.pause();
      sandboxProcess = spawn(config.command, args, {
        stdio: 'inherit',
      });
      return await new Promise((resolve, reject) => {
        sandboxProcess?.on('error', reject);
        sandboxProcess?.on('close', (code) => {
          process.stdin.resume();
          resolve(code ?? 1);
        });
      });
    }

    if (config.command === 'lxc') {
      return await start_lxc_sandbox(config, nodeArgs, cliArgs);
    }

    // runsc uses docker with --runtime=runsc
    const command = config.command === 'runsc' ? 'docker' : config.command;
    if (!command) throw new FatalSandboxError('Sandbox command is required');

    debugLogger.log(`hopping into sandbox (command: ${command}) ...`);

    // determine full path for gemini-cli to distinguish linked vs installed setting
    const gcPath = process.argv[1] ? fs.realpathSync(process.argv[1]) : '';

    const projectSandboxDockerfile = path.join(
      GEMINI_DIR,
      'sandbox.Dockerfile',
    );
    const isCustomProjectSandbox = fs.existsSync(projectSandboxDockerfile);

    const image = config.image;
    if (!image) throw new FatalSandboxError('Sandbox image is required');
    if (!/^[a-zA-Z0-9_.:/-]+$/.test(image))
      throw new FatalSandboxError('Invalid sandbox image name');
    const workdir = path.resolve(process.cwd());
    const containerWorkdir = getContainerPath(workdir);

    // if BUILD_SANDBOX is set, then call scripts/build_sandbox.js under gemini-cli repo
    //
    // note this can only be done with binary linked from gemini-cli repo
    if (process.env['BUILD_SANDBOX']) {
      if (!gcPath.includes('gemini-cli/packages/')) {
        throw new FatalSandboxError(
          'Cannot build sandbox using installed gemini binary; ' +
            'run `npm link ./packages/cli` under gemini-cli repo to switch to linked binary.',
        );
      } else {
        debugLogger.log('building sandbox ...');
        const gcRoot = gcPath.split('/packages/')[0];
        // if project folder has sandbox.Dockerfile under project settings folder, use that
        let buildArgs = '';
        const projectSandboxDockerfile = path.join(
          GEMINI_DIR,
          'sandbox.Dockerfile',
        );
        if (isCustomProjectSandbox) {
          debugLogger.log(`using ${projectSandboxDockerfile} for sandbox`);
          buildArgs += `-f ${path.resolve(projectSandboxDockerfile)} -i ${image}`;
        }
        execSync(
          `cd ${gcRoot} && node scripts/build_sandbox.js -s ${buildArgs}`,
          {
            stdio: 'inherit',
            env: {
              ...process.env,
              GEMINI_SANDBOX: command, // in case sandbox is enabled via flags (see config.ts under cli package)
            },
          },
        );
      }
    }

    // stop if image is missing
    if (!(await ensureSandboxImageIsPresent(command, image, cliConfig))) {
      const remedy =
        image === LOCAL_DEV_SANDBOX_IMAGE_NAME
          ? 'Try running `npm run build:all` or `npm run build:sandbox` under the gemini-cli repo to build it locally, or check the image name and your network connection.'
          : 'Please check the image name, your network connection, or notify gemini-cli-dev@google.com if the issue persists.';
      throw new FatalSandboxError(
        `Sandbox image '${image}' is missing or could not be pulled. ${remedy}`,
      );
    }

    // use interactive mode and auto-remove container on exit
    // run init binary inside container to forward signals & reap zombies
    const args = ['run', '-i', '--rm', '--init', '--workdir', containerWorkdir];

    // explicitly clear the entrypoint to prevent the container's default
    // entrypoint from interfering with the CLI's spawn command.
    args.push('--entrypoint', '');

    // add runsc runtime if using runsc
    if (config.command === 'runsc') {
      args.push('--runtime=runsc');
    }

    // add custom flags from SANDBOX_FLAGS
    if (process.env['SANDBOX_FLAGS']) {
      const flags = parse(process.env['SANDBOX_FLAGS'], process.env).filter(
        (f): f is string => typeof f === 'string',
      );

      args.push(...flags);
    }

    // add TTY only if stdin is TTY as well, i.e. for piped input don't init TTY in container
    if (process.stdin.isTTY) {
      args.push('-t');
    }

    // allow access to host.docker.internal
    args.push('--add-host', 'host.docker.internal:host-gateway');

    // mount current directory as working directory in sandbox (set via --workdir)
    args.push('--volume', `${workdir}:${containerWorkdir}`);

    // mount user settings directory inside container, after creating if missing
    // note user/home changes inside sandbox and we mount at BOTH paths for consistency
    const userHomeDirOnHost = homedir();
    const userSettingsDirInSandbox = getContainerPath(
      `/home/node/${GEMINI_DIR}`,
    );
    if (!fs.existsSync(userHomeDirOnHost)) {
      fs.mkdirSync(userHomeDirOnHost, { recursive: true });
    }
    const userSettingsDirOnHost = path.join(userHomeDirOnHost, GEMINI_DIR);
    if (!fs.existsSync(userSettingsDirOnHost)) {
      fs.mkdirSync(userSettingsDirOnHost, { recursive: true });
    }

    args.push(
      '--volume',
      `${userSettingsDirOnHost}:${userSettingsDirInSandbox}`,
    );
    if (userSettingsDirInSandbox !== getContainerPath(userSettingsDirOnHost)) {
      args.push(
        '--volume',
        `${userSettingsDirOnHost}:${getContainerPath(userSettingsDirOnHost)}`,
      );
    }

    // mount os.tmpdir() as os.tmpdir() inside container
    args.push('--volume', `${os.tmpdir()}:${getContainerPath(os.tmpdir())}`);

    // mount homedir() as homedir() inside container
    if (userHomeDirOnHost !== os.homedir()) {
      args.push(
        '--volume',
        `${userHomeDirOnHost}:${getContainerPath(userHomeDirOnHost)}`,
      );
    }

    // mount gcloud config directory if it exists
    const gcloudConfigDir = path.join(homedir(), '.config', 'gcloud');
    if (fs.existsSync(gcloudConfigDir)) {
      args.push(
        '--volume',
        `${gcloudConfigDir}:${getContainerPath(gcloudConfigDir)}:ro`,
      );
    }

    // mount ADC file if GOOGLE_APPLICATION_CREDENTIALS is set
    if (process.env['GOOGLE_APPLICATION_CREDENTIALS']) {
      const adcFile = process.env['GOOGLE_APPLICATION_CREDENTIALS'];
      if (fs.existsSync(adcFile)) {
        args.push('--volume', `${adcFile}:${getContainerPath(adcFile)}:ro`);
        args.push(
          '--env',
          `GOOGLE_APPLICATION_CREDENTIALS=${getContainerPath(adcFile)}`,
        );
      }
    }

    // mount paths listed in SANDBOX_MOUNTS
    if (process.env['SANDBOX_MOUNTS']) {
      for (let mount of process.env['SANDBOX_MOUNTS'].split(',')) {
        if (mount.trim()) {
          // parse mount as from:to:opts
          let [from, to, opts] = mount.trim().split(':');
          to = to || from; // default to mount at same path inside container
          opts = opts || 'ro'; // default to read-only
          mount = `${from}:${to}:${opts}`;
          // check that from path is absolute
          if (!path.isAbsolute(from)) {
            throw new FatalSandboxError(
              `Path '${from}' listed in SANDBOX_MOUNTS must be absolute`,
            );
          }
          // check that from path exists on host
          if (!fs.existsSync(from)) {
            throw new FatalSandboxError(
              `Missing mount path '${from}' listed in SANDBOX_MOUNTS`,
            );
          }
          debugLogger.log(`SANDBOX_MOUNTS: ${from} -> ${to} (${opts})`);
          args.push('--volume', mount);
        }
      }
    }

    // mount paths listed in config.allowedPaths
    if (config.allowedPaths) {
      for (const hostPath of config.allowedPaths) {
        if (hostPath && path.isAbsolute(hostPath) && fs.existsSync(hostPath)) {
          const containerPath = getContainerPath(hostPath);
          debugLogger.log(
            `Config allowedPath: ${hostPath} -> ${containerPath} (ro)`,
          );
          args.push('--volume', `${hostPath}:${containerPath}:ro`);
        }
      }
    }

    // expose env-specified ports on the sandbox
    ports().forEach((p) => args.push('--publish', `${p}:${p}`));

    // if DEBUG is set, expose debugging port
    if (process.env['DEBUG']) {
      const debugPort = process.env['DEBUG_PORT'] || '9229';
      args.push(`--publish`, `${debugPort}:${debugPort}`);
    }

    // copy proxy environment variables, replacing localhost with SANDBOX_PROXY_NAME
    // copy as both upper-case and lower-case as is required by some utilities
    // GEMINI_SANDBOX_PROXY_COMMAND implies HTTPS_PROXY unless HTTP_PROXY is set
    const proxyCommand = process.env['GEMINI_SANDBOX_PROXY_COMMAND'];

    if (proxyCommand) {
      let proxy =
        process.env['HTTPS_PROXY'] ||
        process.env['https_proxy'] ||
        process.env['HTTP_PROXY'] ||
        process.env['http_proxy'] ||
        'http://localhost:8877';
      proxy = proxy.replace('localhost', SANDBOX_PROXY_NAME);
      if (proxy) {
        args.push('--env', `HTTPS_PROXY=${proxy}`);
        args.push('--env', `https_proxy=${proxy}`); // lower-case can be required, e.g. for curl
        args.push('--env', `HTTP_PROXY=${proxy}`);
        args.push('--env', `http_proxy=${proxy}`);
      }
      const noProxy = process.env['NO_PROXY'] || process.env['no_proxy'];
      if (noProxy) {
        args.push('--env', `NO_PROXY=${noProxy}`);
        args.push('--env', `no_proxy=${noProxy}`);
      }
    }

    // handle network access and proxy configuration
    if (!config.networkAccess || proxyCommand) {
      const isInternal = !config.networkAccess || !!proxyCommand;
      const networkFlags = isInternal ? '--internal' : '';

      execSync(
        `${command} network inspect ${SANDBOX_NETWORK_NAME} || ${command} network create ${networkFlags} ${SANDBOX_NETWORK_NAME}`,
        { stdio: 'ignore' },
      );
      args.push('--network', SANDBOX_NETWORK_NAME);

      if (proxyCommand) {
        // if proxy command is set, create a separate network w/ host access (i.e. non-internal)
        // we will run proxy in its own container connected to both host network and internal network
        // this allows proxy to work even on rootless podman on macos with host<->vm<->container isolation
        execSync(
          `${command} network inspect ${SANDBOX_PROXY_NAME} || ${command} network create ${SANDBOX_PROXY_NAME}`,
          { stdio: 'ignore' },
        );
      }
    }

    // Use a random suffix instead of probing existing containers so concurrent
    // CLI starts cannot race on the same sequential name.
    const imageName = parseImageName(image);
    const isIntegrationTest =
      process.env['GEMINI_CLI_INTEGRATION_TEST'] === 'true';
    const containerNamePrefix = isIntegrationTest
      ? 'gemini-cli-integration-test'
      : imageName;
    const containerName = `${containerNamePrefix}-${randomBytes(6).toString(
      'hex',
    )}`;
    debugLogger.log(`ContainerName: ${containerName}`);
    args.push('--name', containerName, '--hostname', containerName);

    // copy GEMINI_CLI_TEST_VAR for integration tests
    if (process.env['GEMINI_CLI_TEST_VAR']) {
      args.push(
        '--env',
        `GEMINI_CLI_TEST_VAR=${process.env['GEMINI_CLI_TEST_VAR']}`,
      );
    }

    // copy GEMINI_API_KEY(s)
    if (process.env['GEMINI_API_KEY']) {
      args.push('--env', `GEMINI_API_KEY=${process.env['GEMINI_API_KEY']}`);
    }
    if (process.env['GOOGLE_API_KEY']) {
      args.push('--env', `GOOGLE_API_KEY=${process.env['GOOGLE_API_KEY']}`);
    }

    // copy GOOGLE_GEMINI_BASE_URL and GOOGLE_VERTEX_BASE_URL
    if (process.env['GOOGLE_GEMINI_BASE_URL']) {
      args.push(
        '--env',
        `GOOGLE_GEMINI_BASE_URL=${process.env['GOOGLE_GEMINI_BASE_URL']}`,
      );
    }
    if (process.env['GOOGLE_VERTEX_BASE_URL']) {
      args.push(
        '--env',
        `GOOGLE_VERTEX_BASE_URL=${process.env['GOOGLE_VERTEX_BASE_URL']}`,
      );
    }

    // copy GOOGLE_GENAI_USE_VERTEXAI
    if (process.env['GOOGLE_GENAI_USE_VERTEXAI']) {
      args.push(
        '--env',
        `GOOGLE_GENAI_USE_VERTEXAI=${process.env['GOOGLE_GENAI_USE_VERTEXAI']}`,
      );
    }

    // copy GOOGLE_GENAI_USE_GCA
    if (process.env['GOOGLE_GENAI_USE_GCA']) {
      args.push(
        '--env',
        `GOOGLE_GENAI_USE_GCA=${process.env['GOOGLE_GENAI_USE_GCA']}`,
      );
    }

    // copy GOOGLE_CLOUD_PROJECT
    if (process.env['GOOGLE_CLOUD_PROJECT']) {
      args.push(
        '--env',
        `GOOGLE_CLOUD_PROJECT=${process.env['GOOGLE_CLOUD_PROJECT']}`,
      );
    }

    // copy GOOGLE_CLOUD_LOCATION
    if (process.env['GOOGLE_CLOUD_LOCATION']) {
      args.push(
        '--env',
        `GOOGLE_CLOUD_LOCATION=${process.env['GOOGLE_CLOUD_LOCATION']}`,
      );
    }

    // copy GEMINI_MODEL
    if (process.env['GEMINI_MODEL']) {
      args.push('--env', `GEMINI_MODEL=${process.env['GEMINI_MODEL']}`);
    }

    // copy TERM and COLORTERM to try to maintain terminal setup
    if (process.env['TERM']) {
      args.push('--env', `TERM=${process.env['TERM']}`);
    }
    if (process.env['COLORTERM']) {
      args.push('--env', `COLORTERM=${process.env['COLORTERM']}`);
    }

    // Pass through IDE mode environment variables
    for (const envVar of [
      'GEMINI_CLI_IDE_SERVER_PORT',
      'GEMINI_CLI_IDE_WORKSPACE_PATH',
      'TERM_PROGRAM',
    ]) {
      if (process.env[envVar]) {
        args.push('--env', `${envVar}=${process.env[envVar]}`);
      }
    }

    // copy VIRTUAL_ENV if under working directory
    // also mount-replace VIRTUAL_ENV directory with <project_settings>/sandbox.venv
    // sandbox can then set up this new VIRTUAL_ENV directory using sandbox.bashrc (see below)
    // directory will be empty if not set up, which is still preferable to having host binaries
    if (
      process.env['VIRTUAL_ENV']
        ?.toLowerCase()
        .startsWith(workdir.toLowerCase())
    ) {
      const sandboxVenvPath = path.resolve(GEMINI_DIR, 'sandbox.venv');
      if (!fs.existsSync(sandboxVenvPath)) {
        fs.mkdirSync(sandboxVenvPath, { recursive: true });
      }
      args.push(
        '--volume',
        `${sandboxVenvPath}:${getContainerPath(process.env['VIRTUAL_ENV'])}`,
      );
      args.push(
        '--env',
        `VIRTUAL_ENV=${getContainerPath(process.env['VIRTUAL_ENV'])}`,
      );
    }

    // copy additional environment variables from SANDBOX_ENV
    if (process.env['SANDBOX_ENV']) {
      for (let env of process.env['SANDBOX_ENV'].split(',')) {
        if ((env = env.trim())) {
          if (env.includes('=')) {
            debugLogger.log(`SANDBOX_ENV: ${env}`);
            args.push('--env', env);
          } else {
            throw new FatalSandboxError(
              'SANDBOX_ENV must be a comma-separated list of key=value pairs',
            );
          }
        }
      }
    }

    // copy NODE_OPTIONS
    const existingNodeOptions = process.env['NODE_OPTIONS'] || '';
    const allNodeOptions = [
      ...(existingNodeOptions ? [existingNodeOptions] : []),
      ...nodeArgs,
    ].join(' ');

    if (allNodeOptions.length > 0) {
      args.push('--env', `NODE_OPTIONS="${allNodeOptions}"`);
    }

    // set SANDBOX as container name
    args.push('--env', `SANDBOX=${containerName}`);

    // for podman only, use empty --authfile to skip unnecessary auth refresh overhead
    if (command === 'podman') {
      const emptyAuthFilePath = path.join(os.tmpdir(), 'empty_auth.json');
      fs.writeFileSync(emptyAuthFilePath, '{}', 'utf-8');
      args.push('--authfile', emptyAuthFilePath);
    }

    // Determine if the current user's UID/GID should be passed to the sandbox.
    // See shouldUseCurrentUserInSandbox for more details.
    let userFlag = '';
    const finalEntrypoint = entrypoint(workdir, cliArgs);

    if (process.env['GEMINI_CLI_INTEGRATION_TEST'] === 'true') {
      args.push('--user', 'root');
      userFlag = '--user root';
    } else if (await shouldUseCurrentUserInSandbox()) {
      // For the user-creation logic to work, the container must start as root.
      // The entrypoint script then handles dropping privileges to the correct user.
      args.push('--user', 'root');

      const uid = (await execAsync('id -u')).stdout.trim();
      const gid = (await execAsync('id -g')).stdout.trim();

      // Instead of passing --user to the main sandbox container, we let it
      // start as root, then create a user with the host's UID/GID, and
      // finally switch to that user to run the gemini process. This is
      // necessary on Linux to ensure the user exists within the
      // container's /etc/passwd file, which is required by os.userInfo().
      const username = 'gemini';
      const homeDir = getContainerPath(homedir());
      const quotedHomeDir = quote([homeDir]);

      const originalCommand = finalEntrypoint[2];
      const escapedOriginalCommand = originalCommand.replace(/'/g, "'\\''");

      // Use defensive entrypoint logic that checks for useradd availability.
      // This ensures we can support UID/GID mapping on distros that have these
      // tools. If useradd is missing (e.g. on minimal images), we fail explicitly
      // to avoid insecurely falling back to root execution with host mounts.
      const defensiveEntrypoint = [
        `if command -v useradd >/dev/null 2>&1; then`,
        `  (groupadd -g ${gid} -o ${username} 2>/dev/null || true) &&`,
        `  (id ${uid} >/dev/null 2>&1 || useradd -o -u ${uid} -g ${gid} -d ${quotedHomeDir} -s /bin/bash ${username} 2>/dev/null || true) &&`,
        `  USER_NAME=$(id -nu ${uid} 2>/dev/null);`,
        `  if [ -n "$USER_NAME" ]; then`,
        `    su -p "$USER_NAME" -c '${escapedOriginalCommand}';`,
        `  else`,
        `    echo "Error: Failed to map host UID ${uid} to a user in the container." >&2;`,
        `    exit 1;`,
        `  fi`,
        `else`,
        `  echo "Error: 'useradd' not found in container. UID/GID mapping is required for Linux distros like NixOS/Arch to avoid permission issues. Please use a container image that includes standard user management tools (like 'ubuntu' or 'debian')." >&2;`,
        `  exit 1;`,
        `fi`,
      ].join('\n');

      // The entrypoint is always `['bash', '-c', '<command>']`, so we modify the command part.
      finalEntrypoint[2] = defensiveEntrypoint;

      // We still need userFlag for the simpler proxy container, which does not have this issue.
      userFlag = `--user ${uid}:${gid}`;
      // When forcing a UID in the sandbox, $HOME can be reset to '/', so we copy $HOME as well.
      args.push('--env', `HOME=${homedir()}`);
    }

    // push container image name
    args.push(image);

    // push container entrypoint (including args)
    args.push(...finalEntrypoint);

    // start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set
    let proxyProcess: ChildProcess | undefined = undefined;
    let sandboxProcess: ChildProcess | undefined = undefined;

    if (proxyCommand) {
      // run proxyCommand in its own container
      // build args array to prevent command injection
      const proxyContainerArgs = [
        'run',
        '--rm',
        '--init',
        '--entrypoint',
        '',
        ...(userFlag ? userFlag.split(' ') : []),
        '--name',
        SANDBOX_PROXY_NAME,
        '--network',
        SANDBOX_PROXY_NAME,
        '-p',
        '8877:8877',
        '-v',
        `${process.cwd()}:${workdir}`,
        '--workdir',
        workdir,
        image,
        // proxyCommand may be a shell string, so parse it into tokens safely
        ...parse(proxyCommand, process.env).filter(
          (f): f is string => typeof f === 'string',
        ),
      ];

      proxyProcess = spawn(command, proxyContainerArgs, {
        stdio: ['ignore', 'pipe', 'pipe'],
        shell: false, // <-- no shell; args are passed directly
        detached: true,
      });
      // install handlers to stop proxy on exit/signal
      stopProxy = () => {
        debugLogger.log('stopping proxy container ...');
        try {
          spawnSync(command, ['rm', '-f', SANDBOX_PROXY_NAME], {
            stdio: 'ignore',
          });
        } catch {
          // ignore
        }
      };
      process.on('exit', stopProxy);
      process.on('SIGINT', stopProxy);
      process.on('SIGTERM', stopProxy);

      // commented out as it disrupts ink rendering
      // proxyProcess.stdout?.on('data', (data) => {
      //   console.info(data.toString());
      // });
      proxyProcess.stderr?.on('data', (data) => {
        debugLogger.debug(`[PROXY STDERR]: ${data.toString().trim()}`);
      });
      proxyProcess.on('close', (code, signal) => {
        if (sandboxProcess?.pid) {
          process.kill(-sandboxProcess.pid, 'SIGTERM');
        }
        throw new FatalSandboxError(
          `Proxy container command '${command} ${proxyContainerArgs.join(' ')}' exited with code ${code}, signal ${signal}`,
        );
      });
      debugLogger.log('waiting for proxy to start ...');
      await execAsync(
        `until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done`,
      );
      // connect proxy container to sandbox network
      // (workaround for older versions of docker that don't support multiple --network args)
      await execAsync(
        `${command} network connect ${SANDBOX_NETWORK_NAME} ${SANDBOX_PROXY_NAME}`,
      );
    }

    // spawn child and let it inherit stdio
    process.stdin.pause();
    sandboxProcess = spawn(command, args, {
      stdio: 'inherit',
    });

    return await new Promise<number>((resolve, reject) => {
      sandboxProcess?.on('error', (err) => {
        coreEvents.emitFeedback('error', 'Sandbox process error', err);
        reject(err);
      });

      sandboxProcess?.on('close', (code, signal) => {
        process.stdin.resume();
        if (code !== 0 && code !== null) {
          debugLogger.log(
            `Sandbox process exited with code: ${code}, signal: ${signal}`,
          );
        }
        resolve(code ?? 1);
      });
    });
  } finally {
    if (stopProxy) {
      stopProxy();
      process.off('exit', stopProxy);
      process.off('SIGINT', stopProxy);
      process.off('SIGTERM', stopProxy);
    }
    patcher.cleanup();
  }
}

// Helper function to start a sandbox using LXC/LXD.
// Unlike Docker/Podman, LXC does not launch a transient container from an
// image. The user creates and manages their own LXC container; Gemini runs
// inside it via `lxc exec`. The container name is stored in config.image
// (default: "gemini-sandbox"). The workspace is bind-mounted into the
// container at the same absolute path.
async function start_lxc_sandbox(
  config: SandboxConfig,
  nodeArgs: string[] = [],
  cliArgs: string[] = [],
): Promise<number> {
  const containerName = config.image || 'gemini-sandbox';
  const workdir = path.resolve(process.cwd());

  debugLogger.log(
    `starting lxc sandbox (container: ${containerName}, workdir: ${workdir}) ...`,
  );

  // Verify the container exists and is running.
  let listOutput: string;
  try {
    const { stdout } = await execFileAsync('lxc', [
      'list',
      containerName,
      '--format=json',
    ]);
    listOutput = stdout.trim();
  } catch (err) {
    throw new FatalSandboxError(
      `Failed to query LXC container '${containerName}': ${err instanceof Error ? err.message : String(err)}. ` +
        `Make sure LXC/LXD is installed and '${containerName}' container exists. ` +
        `Create one with: lxc launch ubuntu:24.04 ${containerName}`,
    );
  }

  let containers: Array<{ name: string; status: string }> = [];
  try {
    const parsed: unknown = JSON.parse(listOutput);
    if (Array.isArray(parsed)) {
      containers = parsed
        .filter(
          (item): item is Record<string, unknown> =>
            item !== null &&
            typeof item === 'object' &&
            'name' in item &&
            'status' in item,
        )
        .map((item) => ({
          name: String(item['name']),
          status: String(item['status']),
        }));
    }
  } catch {
    containers = [];
  }

  const container = containers.find((c) => c.name === containerName);
  if (!container) {
    throw new FatalSandboxError(
      `LXC container '${containerName}' not found. ` +
        `Create one with: lxc launch ubuntu:24.04 ${containerName}`,
    );
  }
  if (container.status.toLowerCase() !== 'running') {
    throw new FatalSandboxError(
      `LXC container '${containerName}' is not running (current status: ${container.status}). ` +
        `Start it with: lxc start ${containerName}`,
    );
  }

  const devicesToRemove: string[] = [];
  const removeDevices = () => {
    for (const deviceName of devicesToRemove) {
      try {
        spawnSync(
          'lxc',
          ['config', 'device', 'remove', containerName, deviceName],
          { timeout: 1000, killSignal: 'SIGKILL', stdio: 'ignore' },
        );
      } catch {
        // Best-effort cleanup; ignore errors on exit.
      }
    }
  };

  try {
    // Bind-mount the working directory into the container at the same path.
    // Using "lxc config device add" is idempotent when the device name matches.
    const workspaceDeviceName = `gemini-workspace-${randomBytes(4).toString(
      'hex',
    )}`;
    devicesToRemove.push(workspaceDeviceName);

    try {
      await execFileAsync('lxc', [
        'config',
        'device',
        'add',
        containerName,
        workspaceDeviceName,
        'disk',
        `source=${workdir}`,
        `path=${workdir}`,
      ]);
      debugLogger.log(
        `mounted workspace '${workdir}' into container as device '${workspaceDeviceName}'`,
      );
    } catch (err) {
      throw new FatalSandboxError(
        `Failed to mount workspace into LXC container '${containerName}': ${err instanceof Error ? err.message : String(err)}`,
      );
    }

    // Add custom allowed paths from config
    if (config.allowedPaths) {
      for (const hostPath of config.allowedPaths) {
        if (hostPath && path.isAbsolute(hostPath) && fs.existsSync(hostPath)) {
          const allowedDeviceName = `gemini-allowed-${randomBytes(4).toString(
            'hex',
          )}`;
          devicesToRemove.push(allowedDeviceName);
          try {
            await execFileAsync('lxc', [
              'config',
              'device',
              'add',
              containerName,
              allowedDeviceName,
              'disk',
              `source=${hostPath}`,
              `path=${hostPath}`,
              'readonly=true',
            ]);
            debugLogger.log(
              `mounted allowed path '${hostPath}' into container as device '${allowedDeviceName}' (ro)`,
            );
          } catch (err) {
            debugLogger.warn(
              `Failed to mount allowed path '${hostPath}' into LXC container: ${err instanceof Error ? err.message : String(err)}`,
            );
          }
        }
      }
    }

    // Remove the devices from the container when the process exits.
    // Only the 'exit' event is needed — the CLI's cleanup.ts already handles
    // SIGINT and SIGTERM by calling process.exit(), which fires 'exit'.
    process.on('exit', removeDevices);

    // Build the environment variable arguments for `lxc exec`.
    const envArgs: string[] = [];
    const envVarsToForward: Record<string, string | undefined> = {
      GEMINI_API_KEY: process.env['GEMINI_API_KEY'],
      GOOGLE_API_KEY: process.env['GOOGLE_API_KEY'],
      GOOGLE_GEMINI_BASE_URL: process.env['GOOGLE_GEMINI_BASE_URL'],
      GOOGLE_VERTEX_BASE_URL: process.env['GOOGLE_VERTEX_BASE_URL'],
      GOOGLE_GENAI_USE_VERTEXAI: process.env['GOOGLE_GENAI_USE_VERTEXAI'],
      GOOGLE_GENAI_USE_GCA: process.env['GOOGLE_GENAI_USE_GCA'],
      GOOGLE_CLOUD_PROJECT: process.env['GOOGLE_CLOUD_PROJECT'],
      GOOGLE_CLOUD_LOCATION: process.env['GOOGLE_CLOUD_LOCATION'],
      GEMINI_MODEL: process.env['GEMINI_MODEL'],
      TERM: process.env['TERM'],
      COLORTERM: process.env['COLORTERM'],
      GEMINI_CLI_IDE_SERVER_PORT: process.env['GEMINI_CLI_IDE_SERVER_PORT'],
      GEMINI_CLI_IDE_WORKSPACE_PATH:
        process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'],
      TERM_PROGRAM: process.env['TERM_PROGRAM'],
    };
    for (const [key, value] of Object.entries(envVarsToForward)) {
      if (value) {
        envArgs.push('--env', `${key}=${value}`);
      }
    }

    // Forward SANDBOX_ENV key=value pairs
    if (process.env['SANDBOX_ENV']) {
      for (let env of process.env['SANDBOX_ENV'].split(',')) {
        if ((env = env.trim())) {
          if (env.includes('=')) {
            envArgs.push('--env', env);
          } else {
            throw new FatalSandboxError(
              'SANDBOX_ENV must be a comma-separated list of key=value pairs',
            );
          }
        }
      }
    }

    // Forward NODE_OPTIONS (e.g. from --inspect flags)
    const existingNodeOptions = process.env['NODE_OPTIONS'] || '';
    const allNodeOptions = [
      ...(existingNodeOptions ? [existingNodeOptions] : []),
      ...nodeArgs,
    ].join(' ');
    if (allNodeOptions.length > 0) {
      envArgs.push('--env', `NODE_OPTIONS=${allNodeOptions}`);
    }

    // Mark that we're running inside an LXC sandbox.
    envArgs.push('--env', `SANDBOX=${containerName}`);

    // Build the command entrypoint (same logic as Docker path).
    const finalEntrypoint = entrypoint(workdir, cliArgs);

    // Build the full lxc exec command args.
    const args = [
      'exec',
      containerName,
      '--cwd',
      workdir,
      ...envArgs,
      '--',
      ...finalEntrypoint,
    ];

    debugLogger.log(`lxc exec args: ${args.join(' ')}`);

    process.stdin.pause();
    const sandboxProcess = spawn('lxc', args, {
      stdio: 'inherit',
    });

    return await new Promise<number>((resolve, reject) => {
      sandboxProcess.on('error', (err) => {
        coreEvents.emitFeedback('error', 'LXC sandbox process error', err);
        reject(err);
      });

      sandboxProcess.on('close', (code, signal) => {
        process.stdin.resume();
        if (code !== 0 && code !== null) {
          debugLogger.log(
            `LXC sandbox process exited with code: ${code}, signal: ${signal}`,
          );
        }
        resolve(code ?? 1);
      });
    });
  } finally {
    process.off('exit', removeDevices);
    removeDevices();
  }
}

// Helper functions to ensure sandbox image is present
async function imageExists(sandbox: string, image: string): Promise<boolean> {
  return new Promise((resolve) => {
    const args = ['images', '-q', image];
    const checkProcess = spawn(sandbox, args);

    let stdoutData = '';
    if (checkProcess.stdout) {
      checkProcess.stdout.on('data', (data) => {
        stdoutData += data.toString();
      });
    }

    checkProcess.on('error', (err) => {
      debugLogger.warn(
        `Failed to start '${sandbox}' command for image check: ${err.message}`,
      );
      resolve(false);
    });

    checkProcess.on('close', (code) => {
      // Non-zero code might indicate docker daemon not running, etc.
      // The primary success indicator is non-empty stdoutData.
      if (code !== 0) {
        // console.warn(`'${sandbox} images -q ${image}' exited with code ${code}.`);
      }
      resolve(stdoutData.trim() !== '');
    });
  });
}

async function pullImage(
  sandbox: string,
  image: string,
  cliConfig?: Config,
): Promise<boolean> {
  debugLogger.debug(`Attempting to pull image ${image} using ${sandbox}...`);
  return new Promise((resolve) => {
    const args = ['pull', image];
    const pullProcess = spawn(sandbox, args, { stdio: 'pipe' });

    let stderrData = '';

    const onStdoutData = (data: Buffer) => {
      if (cliConfig?.getDebugMode() || process.env['DEBUG']) {
        debugLogger.log(data.toString().trim()); // Show pull progress
      }
    };

    const onStderrData = (data: Buffer) => {
      stderrData += data.toString();
      // eslint-disable-next-line no-console
      console.error(data.toString().trim()); // Show pull errors/info from the command itself
    };

    const onError = (err: Error) => {
      debugLogger.warn(
        `Failed to start '${sandbox} pull ${image}' command: ${err.message}`,
      );
      cleanup();
      resolve(false);
    };

    const onClose = (code: number | null) => {
      if (code === 0) {
        debugLogger.log(`Successfully pulled image ${image}.`);
        cleanup();
        resolve(true);
      } else {
        debugLogger.warn(
          `Failed to pull image ${image}. '${sandbox} pull ${image}' exited with code ${code}.`,
        );
        if (stderrData.trim()) {
          // Details already printed by the stderr listener above
        }
        cleanup();
        resolve(false);
      }
    };

    const cleanup = () => {
      if (pullProcess.stdout) {
        pullProcess.stdout.removeListener('data', onStdoutData);
      }
      if (pullProcess.stderr) {
        pullProcess.stderr.removeListener('data', onStderrData);
      }
      pullProcess.removeListener('error', onError);
      pullProcess.removeListener('close', onClose);
      if (pullProcess.connected) {
        pullProcess.disconnect();
      }
    };

    if (pullProcess.stdout) {
      pullProcess.stdout.on('data', onStdoutData);
    }
    if (pullProcess.stderr) {
      pullProcess.stderr.on('data', onStderrData);
    }
    pullProcess.on('error', onError);
    pullProcess.on('close', onClose);
  });
}

async function ensureSandboxImageIsPresent(
  sandbox: string,
  image: string,
  cliConfig?: Config,
): Promise<boolean> {
  debugLogger.log(`Checking for sandbox image: ${image}`);
  if (await imageExists(sandbox, image)) {
    debugLogger.log(`Sandbox image ${image} found locally.`);
    return true;
  }

  debugLogger.log(`Sandbox image ${image} not found locally.`);
  if (image === LOCAL_DEV_SANDBOX_IMAGE_NAME) {
    // user needs to build the image themselves
    return false;
  }

  if (await pullImage(sandbox, image, cliConfig)) {
    // After attempting to pull, check again to be certain
    if (await imageExists(sandbox, image)) {
      debugLogger.log(`Sandbox image ${image} is now available after pulling.`);
      return true;
    } else {
      debugLogger.warn(
        `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.`,
      );
      return false;
    }
  }

  coreEvents.emitFeedback(
    'error',
    `Failed to obtain sandbox image ${image} after check and pull attempt.`,
  );
  return false; // Pull command failed or image still not present
}
