Dev -> Local #14
@@ -3,9 +3,17 @@ import { buildSandboxNpmInstallCommand } from "./sandbox-install-command.js";
|
|||||||
|
|
||||||
describe("buildSandboxNpmInstallCommand", () => {
|
describe("buildSandboxNpmInstallCommand", () => {
|
||||||
it("installs globally as root, via sudo when available, and under ~/.local otherwise", () => {
|
it("installs globally as root, via sudo when available, and under ~/.local otherwise", () => {
|
||||||
expect(buildSandboxNpmInstallCommand("@google/gemini-cli")).toBe(
|
const command = buildSandboxNpmInstallCommand("@google/gemini-cli");
|
||||||
'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',
|
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", () => {
|
it("shell-quotes package names", () => {
|
||||||
|
|||||||
@@ -2,10 +2,40 @@ function shellSingleQuote(value: string): string {
|
|||||||
return `'${value.replaceAll("'", `'\"'\"'`)}'`;
|
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 {
|
export function buildSandboxNpmInstallCommand(packageName: string): string {
|
||||||
const quotedPackageName = shellSingleQuote(packageName);
|
const quotedPackageName = shellSingleQuote(packageName);
|
||||||
return [
|
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};`,
|
`npm install -g ${quotedPackageName};`,
|
||||||
'elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then',
|
'elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then',
|
||||||
`sudo -E npm install -g ${quotedPackageName};`,
|
`sudo -E npm install -g ${quotedPackageName};`,
|
||||||
|
|||||||
@@ -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 type = "gemini_local";
|
||||||
export const label = "Gemini CLI (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";
|
export const DEFAULT_GEMINI_LOCAL_MODEL = "auto";
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,15 @@ export const label = "OpenCode (local)";
|
|||||||
// (linux-x64, linux-x64-musl, linux-x64-baseline, linux-x64-baseline-musl) in
|
// (linux-x64, linux-x64-musl, linux-x64-baseline, linux-x64-baseline-musl) in
|
||||||
// parallel even though only one matches the sandbox; on bandwidth-constrained
|
// parallel even though only one matches the sandbox; on bandwidth-constrained
|
||||||
// sandboxes (e.g. Cloudflare) that exceeded the 240s install budget. The
|
// sandboxes (e.g. Cloudflare) that exceeded the 240s install budget. The
|
||||||
// official installer fetches a single arch-specific binary and adds
|
// official installer fetches a single arch-specific binary into
|
||||||
// `$HOME/.opencode/bin` to PATH via `~/.bashrc`, which sandbox `sh -lc`
|
// `$HOME/.opencode/bin` and tries to add it to PATH via `~/.bashrc`. That
|
||||||
// invocations source.
|
// 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
|
// Security tradeoff: this is `curl | bash` without a SHA-256 verification of
|
||||||
// the install script. We accept this because:
|
// 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
|
// shell) and `curl -fsSL` give us fail-fast behavior on HTTP errors. If
|
||||||
// OpenCode starts publishing a stable checksum/signature, switch to fetching
|
// OpenCode starts publishing a stable checksum/signature, switch to fetching
|
||||||
// a versioned tarball + verifying the digest before exec.
|
// 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";
|
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, {
|
const parsed = await resolveEnvironmentDriverConfigForRuntime(input.db, input.companyId, {
|
||||||
|
id: input.environment.id,
|
||||||
driver: input.environment.driver as "sandbox",
|
driver: input.environment.driver as "sandbox",
|
||||||
config: parseObject(input.environment.config),
|
config: parseObject(input.environment.config),
|
||||||
});
|
});
|
||||||
@@ -119,6 +120,7 @@ export async function resolveEnvironmentExecutionTarget(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parsed = await resolveEnvironmentDriverConfigForRuntime(input.db, input.companyId, {
|
const parsed = await resolveEnvironmentDriverConfigForRuntime(input.db, input.companyId, {
|
||||||
|
id: input.environment.id,
|
||||||
driver: input.environment.driver as "ssh",
|
driver: input.environment.driver as "ssh",
|
||||||
config: parseObject(input.environment.config),
|
config: parseObject(input.environment.config),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user