Dev -> Local #14

Merged
cpfarhood merged 40 commits from dev into local 2026-05-16 14:16:30 +00:00
5 changed files with 70 additions and 10 deletions
Showing only changes of commit ad0bb57350 - Show all commits
@@ -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", () => {
@@ -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};`,
+5 -2
View File
@@ -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";
+21 -4
View File
@@ -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";
@@ -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),
});