diff --git a/packages/adapter-utils/src/sandbox-install-command.test.ts b/packages/adapter-utils/src/sandbox-install-command.test.ts index 454d227e..01c32936 100644 --- a/packages/adapter-utils/src/sandbox-install-command.test.ts +++ b/packages/adapter-utils/src/sandbox-install-command.test.ts @@ -3,9 +3,17 @@ import { buildSandboxNpmInstallCommand } from "./sandbox-install-command.js"; describe("buildSandboxNpmInstallCommand", () => { it("installs globally as root, via sudo when available, and under ~/.local otherwise", () => { - expect(buildSandboxNpmInstallCommand("@google/gemini-cli")).toBe( - 'if [ "$(id -u)" -eq 0 ]; then npm install -g \'@google/gemini-cli\'; elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then sudo -E npm install -g \'@google/gemini-cli\'; else mkdir -p "$HOME/.local" && npm install -g --prefix "$HOME/.local" \'@google/gemini-cli\'; fi', - ); + const command = buildSandboxNpmInstallCommand("@google/gemini-cli"); + expect(command).toContain("if [ \"$(id -u)\" -eq 0 ]; then npm install -g '@google/gemini-cli';"); + expect(command).toContain("sudo -E npm install -g '@google/gemini-cli'"); + expect(command).toContain("npm install -g --prefix \"$HOME/.local\" '@google/gemini-cli'"); + }); + + it("bootstraps npm from a portable Node tarball when missing", () => { + const command = buildSandboxNpmInstallCommand("@google/gemini-cli"); + expect(command).toContain("if ! command -v npm >/dev/null 2>&1; then"); + expect(command).toContain("https://nodejs.org/dist/"); + expect(command).toContain('export PATH="$HOME/.local/bin:$PATH"'); }); it("shell-quotes package names", () => { diff --git a/packages/adapter-utils/src/sandbox-install-command.ts b/packages/adapter-utils/src/sandbox-install-command.ts index ceede21d..d8ef0767 100644 --- a/packages/adapter-utils/src/sandbox-install-command.ts +++ b/packages/adapter-utils/src/sandbox-install-command.ts @@ -2,10 +2,40 @@ function shellSingleQuote(value: string): string { return `'${value.replaceAll("'", `'\"'\"'`)}'`; } +// Bootstrap a usable npm when the sandbox image ships without one (e.g. the +// default exe.dev VM image has sshd + a normal user homedir but no Node +// toolchain). We install a portable Node tarball into $HOME/.local rather +// than using apt-get because the distro-packaged Node is often old enough to +// reject modern JS syntax (regex /v flag, etc.) used by adapter CLIs like +// @google/gemini-cli. The bootstrap also sets PAPERCLIP_NPM_BOOTSTRAPPED=1 +// so the install step knows to skip sudo — sudo would reset PATH via +// secure_path and lose visibility of the freshly-installed npm in +// $HOME/.local/bin. +const ENSURE_NPM_PREAMBLE = + "PAPERCLIP_NPM_BOOTSTRAPPED=; " + + 'if ! command -v npm >/dev/null 2>&1; then ' + + 'NODE_ARCH="$(uname -m)"; ' + + 'case "$NODE_ARCH" in ' + + "x86_64) NODE_ARCH=x64 ;; " + + "aarch64|arm64) NODE_ARCH=arm64 ;; " + + "esac; " + + 'NODE_VERSION="v22.11.0"; ' + + 'NODE_TARBALL="node-${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz"; ' + + 'mkdir -p "$HOME/.local"; ' + + 'curl -fsSL "https://nodejs.org/dist/${NODE_VERSION}/${NODE_TARBALL}" -o "/tmp/${NODE_TARBALL}" && ' + + 'tar -xJf "/tmp/${NODE_TARBALL}" -C "$HOME/.local" --strip-components=1 && ' + + 'rm -f "/tmp/${NODE_TARBALL}" && ' + + 'export PATH="$HOME/.local/bin:$PATH" && ' + + "PAPERCLIP_NPM_BOOTSTRAPPED=1; " + + "fi;"; + export function buildSandboxNpmInstallCommand(packageName: string): string { const quotedPackageName = shellSingleQuote(packageName); return [ - 'if [ "$(id -u)" -eq 0 ]; then', + ENSURE_NPM_PREAMBLE, + 'if [ -n "$PAPERCLIP_NPM_BOOTSTRAPPED" ]; then', + `npm install -g ${quotedPackageName};`, + 'elif [ "$(id -u)" -eq 0 ]; then', `npm install -g ${quotedPackageName};`, 'elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then', `sudo -E npm install -g ${quotedPackageName};`, diff --git a/packages/adapters/gemini-local/src/index.ts b/packages/adapters/gemini-local/src/index.ts index 68ca627d..626266b0 100644 --- a/packages/adapters/gemini-local/src/index.ts +++ b/packages/adapters/gemini-local/src/index.ts @@ -1,9 +1,12 @@ -import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils"; +import { + buildSandboxNpmInstallCommand, + type AdapterModelProfileDefinition, +} from "@paperclipai/adapter-utils"; export const type = "gemini_local"; export const label = "Gemini CLI (local)"; -export const SANDBOX_INSTALL_COMMAND = "npm install -g @google/gemini-cli"; +export const SANDBOX_INSTALL_COMMAND = buildSandboxNpmInstallCommand("@google/gemini-cli"); export const DEFAULT_GEMINI_LOCAL_MODEL = "auto"; diff --git a/packages/adapters/opencode-local/src/index.ts b/packages/adapters/opencode-local/src/index.ts index 3bd30518..6f36e378 100644 --- a/packages/adapters/opencode-local/src/index.ts +++ b/packages/adapters/opencode-local/src/index.ts @@ -8,9 +8,15 @@ export const label = "OpenCode (local)"; // (linux-x64, linux-x64-musl, linux-x64-baseline, linux-x64-baseline-musl) in // parallel even though only one matches the sandbox; on bandwidth-constrained // sandboxes (e.g. Cloudflare) that exceeded the 240s install budget. The -// official installer fetches a single arch-specific binary and adds -// `$HOME/.opencode/bin` to PATH via `~/.bashrc`, which sandbox `sh -lc` -// invocations source. +// official installer fetches a single arch-specific binary into +// `$HOME/.opencode/bin` and tries to add it to PATH via `~/.bashrc`. That +// rc-file path is only sourced by interactive/login shells, so non-login +// `sh -c` probe invocations (used by the runtime PATH check) cannot find the +// binary. We fix that by symlinking the installed binary into a directory on +// the non-login `sh -c` PATH: prefer `/usr/local/bin` (universally on the +// default PATH on Linux distros) when root or passwordless sudo is available, +// otherwise fall back to `$HOME/.local/bin` (which is on the default PATH on +// the exe.dev sandbox image and most modern home-managed Linux images). // // Security tradeoff: this is `curl | bash` without a SHA-256 verification of // the install script. We accept this because: @@ -25,7 +31,18 @@ export const label = "OpenCode (local)"; // shell) and `curl -fsSL` give us fail-fast behavior on HTTP errors. If // OpenCode starts publishing a stable checksum/signature, switch to fetching // a versioned tarball + verifying the digest before exec. -export const SANDBOX_INSTALL_COMMAND = "curl -fsSL https://opencode.ai/install | bash"; +export const SANDBOX_INSTALL_COMMAND = + 'curl -fsSL https://opencode.ai/install | bash && ' + + 'if [ -x "$HOME/.opencode/bin/opencode" ]; then ' + + 'if [ "$(id -u)" -eq 0 ]; then ' + + 'ln -sf "$HOME/.opencode/bin/opencode" /usr/local/bin/opencode; ' + + 'elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then ' + + 'sudo ln -sf "$HOME/.opencode/bin/opencode" /usr/local/bin/opencode; ' + + 'else ' + + 'mkdir -p "$HOME/.local/bin" && ' + + 'ln -sf "$HOME/.opencode/bin/opencode" "$HOME/.local/bin/opencode"; ' + + 'fi; ' + + 'fi'; export const DEFAULT_OPENCODE_LOCAL_MODEL = "openai/gpt-5.2-codex"; diff --git a/server/src/services/environment-execution-target.ts b/server/src/services/environment-execution-target.ts index 2a506d41..33ea7015 100644 --- a/server/src/services/environment-execution-target.ts +++ b/server/src/services/environment-execution-target.ts @@ -46,6 +46,7 @@ export async function resolveEnvironmentExecutionTarget(input: { } const parsed = await resolveEnvironmentDriverConfigForRuntime(input.db, input.companyId, { + id: input.environment.id, driver: input.environment.driver as "sandbox", config: parseObject(input.environment.config), }); @@ -119,6 +120,7 @@ export async function resolveEnvironmentExecutionTarget(input: { } const parsed = await resolveEnvironmentDriverConfigForRuntime(input.db, input.companyId, { + id: input.environment.id, driver: input.environment.driver as "ssh", config: parseObject(input.environment.config), });