diff --git a/packages/adapter-utils/src/execution-target-sandbox.test.ts b/packages/adapter-utils/src/execution-target-sandbox.test.ts index ef56e1ff..92f964bc 100644 --- a/packages/adapter-utils/src/execution-target-sandbox.test.ts +++ b/packages/adapter-utils/src/execution-target-sandbox.test.ts @@ -5,9 +5,12 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { + DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC, adapterExecutionTargetSessionIdentity, adapterExecutionTargetToRemoteSpec, adapterExecutionTargetUsesPaperclipBridge, + ensureAdapterExecutionTargetCommandResolvable, + resolveAdapterExecutionTargetTimeoutSec, runAdapterExecutionTargetProcess, runAdapterExecutionTargetShellCommand, startAdapterExecutionTargetPaperclipBridge, @@ -109,6 +112,89 @@ describe("sandbox adapter execution targets", () => { }); }); + it("applies the remote sandbox fallback when adapter timeoutSec is unset", () => { + const sandboxTarget: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + remoteCwd: "/workspace", + runner: createLocalSandboxRunner(), + }; + + expect(resolveAdapterExecutionTargetTimeoutSec(sandboxTarget, 0)).toBe( + DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC, + ); + expect(resolveAdapterExecutionTargetTimeoutSec(sandboxTarget, 90)).toBe(90); + expect(resolveAdapterExecutionTargetTimeoutSec({ + kind: "remote", + transport: "ssh", + remoteCwd: "/workspace", + spec: { + host: "127.0.0.1", + port: 22, + username: "fixture", + remoteWorkspacePath: "/workspace", + remoteCwd: "/workspace", + privateKey: "KEY", + knownHosts: "host key", + strictHostKeyChecking: true, + }, + }, 0)).toBe(0); + }); + + it("uses the caller timeout override when installing a missing sandbox command", async () => { + const runner = { + execute: vi.fn() + .mockResolvedValueOnce({ + exitCode: 1, + signal: null, + timedOut: false, + stdout: "", + stderr: "", + pid: null, + startedAt: new Date().toISOString(), + }) + .mockResolvedValueOnce({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: "", + stderr: "", + pid: null, + startedAt: new Date().toISOString(), + }) + .mockResolvedValueOnce({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: "/usr/bin/opencode\n", + stderr: "", + pid: null, + startedAt: new Date().toISOString(), + }), + }; + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + remoteCwd: "/workspace", + timeoutMs: 300_000, + runner, + }; + + await ensureAdapterExecutionTargetCommandResolvable( + "opencode", + target, + "/local/workspace", + {}, + { installCommand: "npm install -g opencode", timeoutSec: 1800 }, + ); + + expect(runner.execute).toHaveBeenNthCalledWith(2, expect.objectContaining({ + command: "sh", + args: ["-c", "npm install -g opencode"], + timeoutMs: 1_800_000, + })); + }); + it("runs shell commands through the same runner", async () => { const runner = { execute: vi.fn(async () => ({ @@ -363,6 +449,60 @@ describe("sandbox adapter execution targets", () => { } }); + it("uses the effective adapter timeout when starting the sandbox callback bridge", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-timeout-")); + cleanupDirs.push(rootDir); + const remoteCwd = path.join(rootDir, "workspace"); + const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "codex"); + await mkdir(runtimeRootDir, { recursive: true }); + + const delegateRunner = createLocalSandboxRunner(); + const runner = { + execute: vi.fn(async (input: Parameters[0]) => delegateRunner.execute(input)), + }; + const apiServer = createServer((req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + }); + await new Promise((resolve, reject) => { + apiServer.once("error", reject); + apiServer.listen(0, "127.0.0.1", () => resolve()); + }); + const address = apiServer.address(); + if (!address || typeof address === "string") { + throw new Error("Expected the bridge timeout test API server to listen on a TCP port."); + } + + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "cloudflare", + environmentId: "env-1", + leaseId: "lease-1", + remoteCwd, + runner, + timeoutMs: 30_000, + }; + + const bridge = await startAdapterExecutionTargetPaperclipBridge({ + runId: "run-bridge-timeout", + target, + runtimeRootDir, + adapterKey: "codex", + timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC, + hostApiToken: "real-run-jwt", + hostApiUrl: `http://127.0.0.1:${address.port}`, + }); + try { + expect(bridge).not.toBeNull(); + expect(runner.execute).toHaveBeenCalled(); + expect(runner.execute.mock.calls.some(([input]) => input.timeoutMs === 1_800_000)).toBe(true); + } finally { + await bridge?.stop(); + await new Promise((resolve) => apiServer.close(() => resolve())); + } + }); + it("fails oversized host responses with a 502 before returning them to the sandbox client", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-limit-")); cleanupDirs.push(rootDir); diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index e014dcdd..8b6d5bbb 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -99,6 +99,8 @@ export interface AdapterExecutionTargetPaperclipBridgeHandle { export { sanitizeRemoteExecutionEnv } from "./remote-execution-env.js"; +export const DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC = 1_800; + function parseObject(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) @@ -222,6 +224,26 @@ export function describeAdapterExecutionTarget( return `sandbox environment${target.providerKey ? ` (${target.providerKey})` : ""}`; } +export function resolveAdapterExecutionTargetTimeoutSec( + target: AdapterExecutionTarget | null | undefined, + configuredTimeoutSec: number | null | undefined, +): number { + const normalizedConfiguredTimeoutSec = + typeof configuredTimeoutSec === "number" && Number.isFinite(configuredTimeoutSec) && configuredTimeoutSec > 0 + ? Math.floor(configuredTimeoutSec) + : 0; + if (normalizedConfiguredTimeoutSec > 0) return normalizedConfiguredTimeoutSec; + // Local and SSH adapters preserve the historical "0 means no adapter + // timeout" behavior. Sandbox-backed runs execute through provider RPCs + // that usually apply their own shorter command defaults, so request an + // explicit longer timeout for full adapter runs when the adapter leaves + // timeoutSec unset. + if (target?.kind === "remote" && target.transport === "sandbox") { + return DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC; + } + return 0; +} + function requireSandboxRunner(target: AdapterSandboxExecutionTarget): CommandManagedRuntimeRunner { if (target.runner) return target.runner; throw new Error( @@ -261,10 +283,15 @@ export async function ensureAdapterExecutionTargetCommandResolvable( target: AdapterExecutionTarget | null | undefined, cwd: string, env: NodeJS.ProcessEnv, - options: { installCommand?: string | null } = {}, + options: { installCommand?: string | null; timeoutSec?: number | null } = {}, ) { if (target?.kind === "remote" && target.transport === "sandbox") { - await ensureSandboxCommandResolvable(command, target, options.installCommand?.trim() || null); + await ensureSandboxCommandResolvable( + command, + target, + options.installCommand?.trim() || null, + options.timeoutSec, + ); return; } await ensureCommandResolvable(command, cwd, env, { @@ -295,6 +322,7 @@ async function ensureSandboxCommandResolvable( command: string, target: AdapterSandboxExecutionTarget, installCommand: string | null, + timeoutSec?: number | null, ): Promise { // Probe whether the binary is resolvable inside the sandbox. We previously // short-circuited this for sandbox targets, which let the caller report a @@ -316,12 +344,16 @@ async function ensureSandboxCommandResolvable( let installFailureDetail: string | null = null; if (installCommand) { const runner = requireSandboxRunner(target); + const installTimeoutMs = + typeof timeoutSec === "number" && Number.isFinite(timeoutSec) && timeoutSec > 0 + ? Math.floor(timeoutSec * 1000) + : target.timeoutMs ?? 300_000; try { const installResult = await runner.execute({ command: "sh", args: shellCommandArgs(installCommand), cwd: target.remoteCwd, - timeoutMs: target.timeoutMs ?? 300_000, + timeoutMs: installTimeoutMs, }); if (installResult.timedOut) { installFailureDetail = `install command timed out: ${installCommand}`; @@ -890,6 +922,7 @@ export async function prepareAdapterExecutionTargetRuntime(input: { target: AdapterExecutionTarget | null | undefined; adapterKey: string; workspaceLocalDir: string; + timeoutSec?: number; workspaceRemoteDir?: string; workspaceExclude?: string[]; preserveAbsentOnRestore?: string[]; @@ -934,7 +967,10 @@ export async function prepareAdapterExecutionTargetRuntime(input: { shellCommand: target.shellCommand, leaseId: target.leaseId, remoteCwd: target.remoteCwd, - timeoutMs: target.timeoutMs, + timeoutMs: + input.timeoutSec && input.timeoutSec > 0 + ? input.timeoutSec * 1000 + : target.timeoutMs, }, adapterKey: input.adapterKey, workspaceLocalDir: input.workspaceLocalDir, @@ -1017,6 +1053,7 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { target: AdapterExecutionTarget | null | undefined; runtimeRootDir: string | null | undefined; adapterKey: string; + timeoutSec?: number | null; hostApiToken: string | null | undefined; hostApiUrl?: string | null; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; @@ -1055,6 +1092,10 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { resolveDefaultPaperclipApiUrl(); const shellCommand = adapterExecutionTargetShellCommand(target); const runner = adapterExecutionTargetCommandRunner(target); + const bridgeTimeoutMs = + typeof input.timeoutSec === "number" && Number.isFinite(input.timeoutSec) && input.timeoutSec > 0 + ? Math.trunc(input.timeoutSec * 1000) + : adapterExecutionTargetTimeoutMs(target); await onLog( "stdout", @@ -1068,7 +1109,7 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { const client = createCommandManagedSandboxCallbackBridgeQueueClient({ runner, remoteCwd: target.remoteCwd, - timeoutMs: adapterExecutionTargetTimeoutMs(target), + timeoutMs: bridgeTimeoutMs, shellCommand, }); // PAPERCLIP_BRIDGE_DEBUG opts into verbose stdout logs of every bridge @@ -1123,7 +1164,7 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { queueDir, bridgeToken, bridgeAsset, - timeoutMs: adapterExecutionTargetTimeoutMs(target), + timeoutMs: bridgeTimeoutMs, maxBodyBytes, shellCommand, }); diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index 4ebcb076..fa5b7632 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -60,6 +60,7 @@ export { REDACTED_COMMAND_TEXT_VALUE, redactCommandText, } from "./command-redaction.js"; +export { buildSandboxNpmInstallCommand } from "./sandbox-install-command.js"; export { inferOpenAiCompatibleBiller } from "./billing.js"; // Keep the root adapter-utils entry browser-safe because the UI imports it. // The sandbox callback bridge stays available via its dedicated subpath export. diff --git a/packages/adapter-utils/src/sandbox-install-command.test.ts b/packages/adapter-utils/src/sandbox-install-command.test.ts new file mode 100644 index 00000000..454d227e --- /dev/null +++ b/packages/adapter-utils/src/sandbox-install-command.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +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', + ); + }); + + it("shell-quotes package names", () => { + expect(buildSandboxNpmInstallCommand("odd'pkg")).toContain("'odd'\"'\"'pkg'"); + }); +}); diff --git a/packages/adapter-utils/src/sandbox-install-command.ts b/packages/adapter-utils/src/sandbox-install-command.ts new file mode 100644 index 00000000..ceede21d --- /dev/null +++ b/packages/adapter-utils/src/sandbox-install-command.ts @@ -0,0 +1,16 @@ +function shellSingleQuote(value: string): string { + return `'${value.replaceAll("'", `'\"'\"'`)}'`; +} + +export function buildSandboxNpmInstallCommand(packageName: string): string { + const quotedPackageName = shellSingleQuote(packageName); + return [ + 'if [ "$(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};`, + "else", + `mkdir -p "$HOME/.local" && npm install -g --prefix "$HOME/.local" ${quotedPackageName};`, + "fi", + ].join(" "); +} diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index c69d27f7..828d686f 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -16,6 +16,7 @@ import { ensureAdapterExecutionTargetRuntimeCommandInstalled, prepareAdapterExecutionTargetRuntime, readAdapterExecutionTarget, + resolveAdapterExecutionTargetTimeoutSec, resolveAdapterExecutionTargetCommandForLogs, runAdapterExecutionTargetProcess, runAdapterExecutionTargetShellCommand, @@ -58,6 +59,7 @@ import { prepareClaudeConfigSeed } from "./claude-config.js"; import { resolveClaudeDesiredSkillNames } from "./skills.js"; import { isBedrockModelId } from "./models.js"; import { prepareClaudePromptBundle } from "./prompt-cache.js"; +import { buildClaudeExecutionPermissionArgs } from "./permissions.js"; import { SANDBOX_INSTALL_COMMAND } from "../index.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); @@ -263,7 +265,10 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise typeof entry[1] === "string", ), ); - const timeoutSec = asNumber(config.timeoutSec, 0); + const timeoutSec = resolveAdapterExecutionTargetTimeoutSec( + executionTarget, + asNumber(config.timeoutSec, 0), + ); const graceSec = asNumber(config.graceSec, 20); await ensureAdapterExecutionTargetRuntimeCommandInstalled({ runId, @@ -276,7 +281,10 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise { const args = ["--print", "-", "--output-format", "stream-json", "--verbose"]; if (resumeSessionId) args.push("--resume", resumeSessionId); - if (dangerouslySkipPermissions) args.push("--dangerously-skip-permissions"); + args.push(...buildClaudeExecutionPermissionArgs({ + dangerouslySkipPermissions, + targetIsSandbox: executionTargetIsSandbox, + })); if (chrome) args.push("--chrome"); // For Bedrock: only pass --model when the ID is a Bedrock-native identifier // (e.g. "us.anthropic.*" or ARN). Anthropic-style IDs like "claude-opus-4-6" are invalid @@ -698,6 +712,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise { "-", ]); }); + + it("adds --skip-git-repo-check when requested", () => { + const result = buildCodexExecArgs( + { + model: "gpt-5.3-codex", + }, + { skipGitRepoCheck: true }, + ); + + expect(result.args).toEqual([ + "exec", + "--json", + "--skip-git-repo-check", + "--model", + "gpt-5.3-codex", + "-", + ]); + }); }); diff --git a/packages/adapters/codex-local/src/server/codex-args.ts b/packages/adapters/codex-local/src/server/codex-args.ts index f8081aad..615f4996 100644 --- a/packages/adapters/codex-local/src/server/codex-args.ts +++ b/packages/adapters/codex-local/src/server/codex-args.ts @@ -30,7 +30,10 @@ function formatFastModeSupportedModels(): string { export function buildCodexExecArgs( config: unknown, - options: { resumeSessionId?: string | null } = {}, + options: { + resumeSessionId?: string | null; + skipGitRepoCheck?: boolean; + } = {}, ): BuildCodexExecArgsResult { const record = asRecord(config); const model = asString(record.model, "").trim(); @@ -48,6 +51,7 @@ export function buildCodexExecArgs( const extraArgs = readExtraArgs(record); const args = ["exec", "--json"]; + if (options.skipGitRepoCheck) args.push("--skip-git-repo-check"); if (search) args.unshift("--search"); if (bypass) args.push("--dangerously-bypass-approvals-and-sandbox"); if (model) args.push("--model", model); diff --git a/packages/adapters/codex-local/src/server/execute.remote.test.ts b/packages/adapters/codex-local/src/server/execute.remote.test.ts index 759a5f9e..dc9582de 100644 --- a/packages/adapters/codex-local/src/server/execute.remote.test.ts +++ b/packages/adapters/codex-local/src/server/execute.remote.test.ts @@ -175,6 +175,7 @@ describe("codex remote execution", () => { const call = runChildProcess.mock.calls[0] as unknown as | [string, string, string[], { env: Record; remoteExecution?: { remoteCwd: string } | null }] | undefined; + expect(call?.[2]).not.toContain("--skip-git-repo-check"); expect(call?.[3].env.CODEX_HOME).toBe(`${managedRemoteWorkspace}/.paperclip-runtime/codex/home`); expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe(managedRemoteWorkspace); expect(call?.[3].env.PAPERCLIP_WORKSPACE_WORKTREE_PATH).toBeUndefined(); diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 4ce83201..7a02b058 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -14,6 +14,7 @@ import { ensureAdapterExecutionTargetRuntimeCommandInstalled, prepareAdapterExecutionTargetRuntime, readAdapterExecutionTarget, + resolveAdapterExecutionTargetTimeoutSec, resolveAdapterExecutionTargetCommandForLogs, runAdapterExecutionTargetProcess, startAdapterExecutionTargetPaperclipBridge, @@ -358,6 +359,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise { @@ -369,6 +375,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise preparedExecutionTargetRuntime.restoreWorkspace() : null; @@ -482,6 +491,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { const execArgs = buildCodexExecArgs( forceSaferInvocation ? { ...config, fastMode: false } : config, - { resumeSessionId }, + { + resumeSessionId, + skipGitRepoCheck: executionTargetIsSandbox, + }, ); const args = execArgs.args; const commandNotesWithFastMode = diff --git a/packages/adapters/codex-local/src/server/test.remote.test.ts b/packages/adapters/codex-local/src/server/test.remote.test.ts index ab5b1c49..68705946 100644 --- a/packages/adapters/codex-local/src/server/test.remote.test.ts +++ b/packages/adapters/codex-local/src/server/test.remote.test.ts @@ -83,6 +83,7 @@ import { testEnvironment } from "./test.js"; describe("codex remote environment diagnostics", () => { afterEach(() => { vi.clearAllMocks(); + delete process.env.OPENAI_API_KEY; }); it("stages managed CODEX_HOME in an isolated runtime dir and keeps the probe cwd on the original remote workspace", async () => { @@ -149,4 +150,45 @@ describe("codex remote environment diagnostics", () => { }); expect(restoreWorkspace).toHaveBeenCalledTimes(1); }); + + it("avoids /tmp CODEX_HOME for remote API-key hello probes", async () => { + const remoteTarget: AdapterExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "cloudflare", + remoteCwd: "/remote/workspace", + runner: { + execute: async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: "", + stderr: "", + pid: null, + startedAt: new Date().toISOString(), + }), + }, + }; + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "codex_local", + config: { + command: "codex", + env: { + OPENAI_API_KEY: "sk-test", + }, + }, + executionTarget: remoteTarget, + environmentName: "QA Cloudflare", + }); + + expect(result.status).toBe("pass"); + const probeCall = runAdapterExecutionTargetProcess.mock.calls[0] as unknown as + | [string, AdapterExecutionTarget, string, string[], { cwd: string; env: Record }] + | undefined; + expect(probeCall?.[4].env.CODEX_HOME).toContain("/remote/workspace/.paperclip-runtime/codex/probe-home-codex-envtest-"); + expect(probeCall?.[4].env.CODEX_HOME?.startsWith("/tmp/")).toBe(false); + expect(probeCall?.[3]).toContain("--skip-git-repo-check"); + }); }); diff --git a/packages/adapters/codex-local/src/server/test.ts b/packages/adapters/codex-local/src/server/test.ts index 03341c9a..5c7e77db 100644 --- a/packages/adapters/codex-local/src/server/test.ts +++ b/packages/adapters/codex-local/src/server/test.ts @@ -127,7 +127,7 @@ async function prepareCodexHelloProbe(input: { if (input.probeApiKey) { const probeHome = input.targetIsRemote - ? `/tmp/paperclip-codex-probe-${input.runId}` + ? path.posix.join(input.cwd, ".paperclip-runtime", "codex", `probe-home-${input.runId}`) : path.join(os.tmpdir(), `paperclip-codex-probe-${input.runId}`); return { command: "sh", @@ -162,6 +162,7 @@ export async function testEnvironment( const command = asString(config.command, "codex"); const target = ctx.executionTarget ?? null; const targetIsRemote = target?.kind === "remote"; + const targetIsSandbox = target?.kind === "remote" && target.transport === "sandbox"; const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd()); const targetLabel = targetIsRemote ? ctx.environmentName ?? describeAdapterExecutionTarget(target) @@ -271,7 +272,10 @@ export async function testEnvironment( hint: "Use the `codex` CLI command to run the automatic login and installation probe.", }); } else { - const execArgs = buildCodexExecArgs({ ...config, fastMode: false }); + const execArgs = buildCodexExecArgs( + { ...config, fastMode: false }, + { skipGitRepoCheck: targetIsSandbox }, + ); const args = execArgs.args; if (execArgs.fastModeIgnoredReason) { checks.push({ @@ -281,6 +285,14 @@ export async function testEnvironment( hint: "Switch the agent model to GPT-5.4 or enter a manual model ID to enable Codex Fast mode.", }); } + if (targetIsSandbox) { + checks.push({ + code: "codex_git_repo_check_skipped", + level: "info", + message: "Added --skip-git-repo-check for sandbox hello probes.", + hint: "Codex requires an explicit trust bypass in headless remote sandbox workspaces.", + }); + } // Codex CLI (>= 0.122) ignores the OPENAI_API_KEY env var and only reads // credentials from $CODEX_HOME/auth.json. When we have a key available, diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index d04cc73b..800a499f 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -17,6 +17,7 @@ import { prepareAdapterExecutionTargetRuntime, readAdapterExecutionTarget, readAdapterExecutionTargetHomeDir, + resolveAdapterExecutionTargetTimeoutSec, resolveAdapterExecutionTargetCommandForLogs, runAdapterExecutionTargetProcess, runAdapterExecutionTargetShellCommand, @@ -304,7 +305,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise { ]); expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310"); expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); + expect(call?.[3].env.GEMINI_CLI_TRUST_WORKSPACE).toBe("true"); expect(call?.[3].remoteExecution?.remoteCwd).toBe(managedRemoteWorkspace); expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1); expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts index 8ee2a393..df20937e 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -18,6 +18,7 @@ import { prepareAdapterExecutionTargetRuntime, readAdapterExecutionTarget, readAdapterExecutionTargetHomeDir, + resolveAdapterExecutionTargetTimeoutSec, resolveAdapterExecutionTargetCommandForLogs, runAdapterExecutionTargetProcess, runAdapterExecutionTargetShellCommand, @@ -261,6 +262,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof entry[1] === "string", ), ); - const timeoutSec = asNumber(config.timeoutSec, 0); + const timeoutSec = resolveAdapterExecutionTargetTimeoutSec( + executionTarget, + asNumber(config.timeoutSec, 0), + ); const graceSec = asNumber(config.graceSec, 20); await ensureAdapterExecutionTargetRuntimeCommandInstalled({ runId, @@ -288,7 +295,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise { const notes: string[] = ["Prompt is passed to Gemini via --prompt for non-interactive execution."]; notes.push("Added --approval-mode yolo for unattended execution."); + if (executionTargetIsRemote) { + notes.push("Set GEMINI_CLI_TRUST_WORKSPACE=true for remote headless execution."); + } if (!instructionsFilePath) return notes; if (instructionsPrefix.length > 0) { notes.push( diff --git a/packages/adapters/gemini-local/src/server/test.ts b/packages/adapters/gemini-local/src/server/test.ts index 8c8bf007..555355f6 100644 --- a/packages/adapters/gemini-local/src/server/test.ts +++ b/packages/adapters/gemini-local/src/server/test.ts @@ -94,6 +94,9 @@ export async function testEnvironment( for (const [key, value] of Object.entries(envConfig)) { if (typeof value === "string") env[key] = value; } + if (targetIsRemote && typeof env.GEMINI_CLI_TRUST_WORKSPACE !== "string") { + env.GEMINI_CLI_TRUST_WORKSPACE = "true"; + } const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); const installCheck = await maybeRunSandboxInstallCommand({ runId, diff --git a/packages/adapters/opencode-local/src/index.ts b/packages/adapters/opencode-local/src/index.ts index 7328e5d3..3bd30518 100644 --- a/packages/adapters/opencode-local/src/index.ts +++ b/packages/adapters/opencode-local/src/index.ts @@ -3,7 +3,29 @@ import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils"; export const type = "opencode_local"; export const label = "OpenCode (local)"; -export const SANDBOX_INSTALL_COMMAND = "npm install -g opencode-ai"; +// Use OpenCode's official installer instead of `npm install -g opencode-ai`. +// The npm package reifies four large Linux x64 prebuilt-binary subpackages +// (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. +// +// Security tradeoff: this is `curl | bash` without a SHA-256 verification of +// the install script. We accept this because: +// 1. The install runs inside an isolated, ephemeral sandbox — blast radius +// is bounded to that sandbox's secrets and disk. +// 2. The prior `npm install -g opencode-ai` is also unverified code +// execution from a third-party registry; this is not strictly worse. +// 3. OpenCode does not publish per-release SHA-256 checksums in a stable +// location, and pinning a version + hash here would require manual +// version bumps on every OpenCode release. +// The `set -e` (implied by Bash's default with `-fsSL` upstream of a piped +// 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 DEFAULT_OPENCODE_LOCAL_MODEL = "openai/gpt-5.2-codex"; diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 847a331a..72702dbd 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -17,6 +17,7 @@ import { prepareAdapterExecutionTargetRuntime, readAdapterExecutionTarget, readAdapterExecutionTargetHomeDir, + resolveAdapterExecutionTargetTimeoutSec, resolveAdapterExecutionTargetCommandForLogs, runAdapterExecutionTargetProcess, runAdapterExecutionTargetShellCommand, @@ -76,6 +77,7 @@ function resolveOpenCodeBiller(env: Record, provider: string | n } const REMOTE_OPENCODE_MODELS_PROBE_DEFAULT_TIMEOUT_SEC = 20; +const REMOTE_OPENCODE_MODELS_PROBE_SANDBOX_TIMEOUT_SEC = 120; async function ensureRemoteOpenCodeModelConfiguredAndAvailable(input: { runId: string; @@ -88,9 +90,13 @@ async function ensureRemoteOpenCodeModelConfiguredAndAvailable(input: { graceSec: number; }) { const model = requireOpenCodeModelId(input.model); + const defaultProbeTimeoutSec = + input.executionTarget.kind === "remote" && input.executionTarget.transport === "sandbox" + ? REMOTE_OPENCODE_MODELS_PROBE_SANDBOX_TIMEOUT_SEC + : REMOTE_OPENCODE_MODELS_PROBE_DEFAULT_TIMEOUT_SEC; const probeTimeoutSec = input.timeoutSec > 0 - ? Math.min(input.timeoutSec, REMOTE_OPENCODE_MODELS_PROBE_DEFAULT_TIMEOUT_SEC) - : REMOTE_OPENCODE_MODELS_PROBE_DEFAULT_TIMEOUT_SEC; + ? Math.min(input.timeoutSec, defaultProbeTimeoutSec) + : defaultProbeTimeoutSec; const probe = await runAdapterExecutionTargetProcess( input.runId, input.executionTarget, @@ -300,7 +306,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof entry[1] === "string", ), ); - const timeoutSec = asNumber(config.timeoutSec, 0); + const timeoutSec = resolveAdapterExecutionTargetTimeoutSec( + executionTarget, + asNumber(config.timeoutSec, 0), + ); const graceSec = asNumber(config.graceSec, 20); await ensureAdapterExecutionTargetRuntimeCommandInstalled({ runId, @@ -313,7 +322,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise { + const restoreWorkspace = vi.fn(async () => {}); + return { + ensureAdapterExecutionTargetDirectory: vi.fn(async () => {}), + ensureAdapterExecutionTargetCommandResolvable: vi.fn(async () => {}), + maybeRunSandboxInstallCommand: vi.fn(async () => null), + runAdapterExecutionTargetProcess: vi.fn(async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: [ + JSON.stringify({ type: "step_start", sessionID: "session-1" }), + JSON.stringify({ type: "text", sessionID: "session-1", part: { text: "hello" } }), + JSON.stringify({ + type: "step_finish", + sessionID: "session-1", + part: { cost: 0.001, tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } } }, + }), + ].join("\n"), + stderr: "", + pid: 123, + startedAt: new Date().toISOString(), + })), + describeAdapterExecutionTarget: vi.fn(() => "QA Cloudflare"), + resolveAdapterExecutionTargetCwd: vi.fn((target, configuredCwd, fallbackCwd) => { + if (typeof configuredCwd === "string" && configuredCwd.trim().length > 0) return configuredCwd; + if (target && typeof target === "object" && "remoteCwd" in target && typeof target.remoteCwd === "string") { + return target.remoteCwd; + } + return fallbackCwd; + }), + prepareAdapterExecutionTargetRuntime: vi.fn(async () => ({ + target: null, + workspaceRemoteDir: "/remote/workspace/.paperclip-runtime/runs/test/workspace", + runtimeRootDir: "/remote/workspace/.paperclip-runtime/runs/test/workspace/.paperclip-runtime/opencode", + assetDirs: { + xdgConfig: "/remote/workspace/.paperclip-runtime/runs/test/workspace/.paperclip-runtime/opencode/xdgConfig", + }, + restoreWorkspace, + })), + }; +}); + +vi.mock("@paperclipai/adapter-utils/execution-target", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/execution-target", + ); + return { + ...actual, + ensureAdapterExecutionTargetDirectory, + ensureAdapterExecutionTargetCommandResolvable, + maybeRunSandboxInstallCommand, + runAdapterExecutionTargetProcess, + describeAdapterExecutionTarget, + resolveAdapterExecutionTargetCwd, + prepareAdapterExecutionTargetRuntime, + }; +}); + +import { testEnvironment } from "./test.js"; + +describe("opencode remote environment diagnostics", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("stages remote runtime config assets for sandbox hello probes", async () => { + const remoteTarget: AdapterExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "cloudflare", + remoteCwd: "/remote/workspace", + runner: { + execute: async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: "", + stderr: "", + pid: null, + startedAt: new Date().toISOString(), + }), + }, + }; + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "opencode_local", + config: { + command: "opencode", + model: "anthropic/claude-sonnet-4-5", + }, + executionTarget: remoteTarget, + environmentName: "QA Cloudflare", + }); + + expect(result.status).toBe("pass"); + expect(prepareAdapterExecutionTargetRuntime).toHaveBeenCalledTimes(1); + const runtimeCalls = prepareAdapterExecutionTargetRuntime.mock.calls as unknown as Array< + [{ adapterKey: string; assets?: Array<{ key: string; localDir: string }> }] + >; + const runtimeInput = runtimeCalls[0]?.[0]; + expect(runtimeInput?.adapterKey).toBe("opencode"); + expect(runtimeInput?.assets).toEqual([ + expect.objectContaining({ + key: "xdgConfig", + }), + ]); + + const probeCall = runAdapterExecutionTargetProcess.mock.calls[0] as unknown as + | [string, AdapterExecutionTarget, string, string[], { cwd: string; env: Record }] + | undefined; + expect(probeCall?.[4].cwd).toBe("/remote/workspace/.paperclip-runtime/runs/test/workspace"); + expect(probeCall?.[4].env.XDG_CONFIG_HOME).toBe( + "/remote/workspace/.paperclip-runtime/runs/test/workspace/.paperclip-runtime/opencode/xdgConfig", + ); + }); +}); diff --git a/packages/adapters/opencode-local/src/server/test.ts b/packages/adapters/opencode-local/src/server/test.ts index e0959d5f..7335fda1 100644 --- a/packages/adapters/opencode-local/src/server/test.ts +++ b/packages/adapters/opencode-local/src/server/test.ts @@ -1,8 +1,12 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import type { AdapterEnvironmentCheck, AdapterEnvironmentTestContext, AdapterEnvironmentTestResult, } from "@paperclipai/adapter-utils"; +import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; import { asBoolean, asString, @@ -17,6 +21,8 @@ import { runAdapterExecutionTargetProcess, describeAdapterExecutionTarget, resolveAdapterExecutionTargetCwd, + prepareAdapterExecutionTargetRuntime, + overrideAdapterExecutionTargetRemoteCwd, } from "@paperclipai/adapter-utils/execution-target"; import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable } from "./models.js"; import { parseOpenCodeJsonl } from "./parse.js"; @@ -118,7 +124,9 @@ export async function testEnvironment( // Prevent OpenCode from writing an opencode.json into the working directory. env.OPENCODE_DISABLE_PROJECT_CONFIG = "true"; - const preparedRuntimeConfig = await prepareOpenCodeRuntimeConfig({ env, config, targetIsRemote }); + const preparedRuntimeConfig = await prepareOpenCodeRuntimeConfig({ env, config }); + const localRuntimeConfigHome = + preparedRuntimeConfig.notes.length > 0 ? preparedRuntimeConfig.env.XDG_CONFIG_HOME : ""; if (asBoolean(config.dangerouslySkipPermissions, true)) { checks.push({ code: "opencode_headless_permissions_enabled", @@ -126,7 +134,43 @@ export async function testEnvironment( message: "Headless OpenCode external-directory permissions are auto-approved for unattended runs.", }); } + let restoreWorkspace: (() => Promise) | null = null; + // Declared outside `try` so a failure inside `prepareAdapterExecutionTargetRuntime` + // still has the path available for cleanup in `finally` — otherwise the + // `fs.mkdtemp` directory leaks on the early-throw path. + let preparedRuntimeWorkspaceLocalDir: string | null = null; try { + let runtimeTarget: AdapterExecutionTarget | null = target ?? null; + let runtimeCwd = cwd; + if (targetIsRemote) { + preparedRuntimeWorkspaceLocalDir = await fs.mkdtemp(path.join(os.tmpdir(), `paperclip-opencode-envtest-${runId}-`)); + const preparedExecutionTargetRuntime = await prepareAdapterExecutionTargetRuntime({ + runId, + target, + adapterKey: "opencode", + workspaceLocalDir: preparedRuntimeWorkspaceLocalDir, + workspaceRemoteDir: cwd, + installCommand: SANDBOX_INSTALL_COMMAND, + detectCommand: command, + assets: localRuntimeConfigHome + ? [{ + key: "xdgConfig", + localDir: localRuntimeConfigHome, + }] + : [], + }); + restoreWorkspace = async () => { + await preparedExecutionTargetRuntime.restoreWorkspace().catch(() => {}); + if (preparedRuntimeWorkspaceLocalDir) { + await fs.rm(preparedRuntimeWorkspaceLocalDir, { recursive: true, force: true }).catch(() => {}); + } + }; + runtimeCwd = preparedExecutionTargetRuntime.workspaceRemoteDir ?? runtimeCwd; + runtimeTarget = overrideAdapterExecutionTargetRemoteCwd(target ?? null, runtimeCwd) ?? null; + if (localRuntimeConfigHome && preparedExecutionTargetRuntime.assetDirs.xdgConfig) { + preparedRuntimeConfig.env.XDG_CONFIG_HOME = preparedExecutionTargetRuntime.assetDirs.xdgConfig; + } + } const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...preparedRuntimeConfig.env })); const cwdInvalid = checks.some((check) => check.code === "opencode_cwd_invalid"); @@ -143,12 +187,12 @@ export async function testEnvironment( target, adapterKey: "opencode", installCommand: SANDBOX_INSTALL_COMMAND, - detectCommand: command, + detectCommand: command, env, }); if (installCheck) checks.push(installCheck); try { - await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv); + await ensureAdapterExecutionTargetCommandResolvable(command, runtimeTarget, runtimeCwd, runtimeEnv); checks.push({ code: "opencode_command_resolvable", level: "info", @@ -293,11 +337,11 @@ export async function testEnvironment( try { const probe = await runAdapterExecutionTargetProcess( runId, - target, + runtimeTarget, command, args, { - cwd, + cwd: runtimeCwd, env: runtimeEnv, timeoutSec: 60, graceSec: 5, @@ -369,6 +413,12 @@ export async function testEnvironment( } } } finally { + await restoreWorkspace?.(); + if (!restoreWorkspace && preparedRuntimeWorkspaceLocalDir) { + // Reached when `prepareAdapterExecutionTargetRuntime` threw before + // assigning `restoreWorkspace`: clean up the temp dir directly. + await fs.rm(preparedRuntimeWorkspaceLocalDir, { recursive: true, force: true }).catch(() => {}); + } await preparedRuntimeConfig.cleanup(); } diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index 21d3f61c..6bcb22ae 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -17,6 +17,7 @@ import { ensureAdapterExecutionTargetRuntimeCommandInstalled, prepareAdapterExecutionTargetRuntime, readAdapterExecutionTarget, + resolveAdapterExecutionTargetTimeoutSec, resolveAdapterExecutionTargetCommandForLogs, runAdapterExecutionTargetProcess, runAdapterExecutionTargetShellCommand, @@ -345,7 +346,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof entry[1] === "string", ), ); - const timeoutSec = asNumber(config.timeoutSec, 0); + const timeoutSec = resolveAdapterExecutionTargetTimeoutSec( + executionTarget, + asNumber(config.timeoutSec, 0), + ); const graceSec = asNumber(config.graceSec, 20); await ensureAdapterExecutionTargetRuntimeCommandInstalled({ runId, @@ -358,7 +362,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise @@ -232,6 +233,34 @@ describe("server adapter registry", () => { await expect(listAdapterModelProfiles("pi_local")).resolves.toEqual([]); }); + it("wraps built-in npm runtime installs with the sandbox-aware install helper", () => { + const expectedClaudeInstall = `if ! command -v 'claude' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@anthropic-ai/claude-code")}; fi`; + const expectedCodexInstall = `if ! command -v 'codex' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@openai/codex")}; fi`; + const expectedGeminiInstall = `if ! command -v 'gemini' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@google/gemini-cli")}; fi`; + const expectedOpenCodeInstall = `if ! command -v 'opencode' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("opencode-ai")}; fi`; + + expect(findActiveServerAdapter("claude_local")?.getRuntimeCommandSpec?.({})).toEqual({ + command: "claude", + detectCommand: "claude", + installCommand: expectedClaudeInstall, + }); + expect(findActiveServerAdapter("codex_local")?.getRuntimeCommandSpec?.({})).toEqual({ + command: "codex", + detectCommand: "codex", + installCommand: expectedCodexInstall, + }); + expect(findActiveServerAdapter("gemini_local")?.getRuntimeCommandSpec?.({})).toEqual({ + command: "gemini", + detectCommand: "gemini", + installCommand: expectedGeminiInstall, + }); + expect(findActiveServerAdapter("opencode_local")?.getRuntimeCommandSpec?.({})).toEqual({ + command: "opencode", + detectCommand: "opencode", + installCommand: expectedOpenCodeInstall, + }); + }); + it("switches active adapter behavior back to the builtin when an override is paused", async () => { const builtIn = findServerAdapter("claude_local"); expect(builtIn).not.toBeNull(); diff --git a/server/src/__tests__/claude-local-adapter-environment.test.ts b/server/src/__tests__/claude-local-adapter-environment.test.ts index 71eaa125..da800e4d 100644 --- a/server/src/__tests__/claude-local-adapter-environment.test.ts +++ b/server/src/__tests__/claude-local-adapter-environment.test.ts @@ -218,4 +218,64 @@ describe("claude_local environment diagnostics", () => { ).toBe(true); expect(result.checks.some((check) => check.code === "claude_cwd_invalid")).toBe(false); }); + + it("uses --allowedTools instead of --dangerously-skip-permissions for sandbox hello probes", async () => { + const executeCalls: Array<{ command: string; args?: string[] }> = []; + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "claude_local", + config: { + command: "claude", + }, + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "cloudflare", + remoteCwd: "/workspace/paperclip", + runner: { + execute: async (input) => { + executeCalls.push({ command: input.command, args: input.args }); + if (input.command === "claude") { + return { + exitCode: 0, + signal: null, + timedOut: false, + stdout: [ + JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "hello" }] } }), + JSON.stringify({ + type: "result", + result: "hello", + usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 }, + }), + ].join("\n"), + stderr: "", + pid: null, + startedAt: new Date().toISOString(), + }; + } + return { + exitCode: 0, + signal: null, + timedOut: false, + stdout: "", + stderr: "", + pid: null, + startedAt: new Date().toISOString(), + }; + }, + }, + }, + environmentName: "QA Cloudflare", + }); + + expect(result.checks.some((check) => check.code === "claude_hello_probe_passed")).toBe(true); + const probeCall = executeCalls.find((call) => call.command === "claude"); + expect(probeCall?.args).not.toContain("--dangerously-skip-permissions"); + expect(probeCall?.args).not.toContain("--permission-mode"); + // Sandbox probes pass `--allowedTools` so any tool invocation triggered + // by the probe prompt cannot stall waiting for an interactive permission + // approval that no human is present to answer. + expect(probeCall?.args).toContain("--allowedTools"); + }); }); diff --git a/server/src/__tests__/claude-local-execute.test.ts b/server/src/__tests__/claude-local-execute.test.ts index 73eeb825..60755a13 100644 --- a/server/src/__tests__/claude-local-execute.test.ts +++ b/server/src/__tests__/claude-local-execute.test.ts @@ -636,6 +636,11 @@ describe("claude execute", () => { expect(result.exitCode).toBe(0); const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload; + expect(capture.argv).toContain("--allowedTools"); + expect(capture.argv).toContain( + "Task AskUserQuestion Bash(*) CronCreate CronDelete CronList Edit EnterPlanMode EnterWorktree ExitPlanMode ExitWorktree Glob Grep Monitor NotebookEdit PushNotification Read RemoteTrigger ScheduleWakeup Skill TaskOutput TaskStop TodoWrite ToolSearch WebFetch WebSearch Write", + ); + expect(capture.argv).not.toContain("--dangerously-skip-permissions"); expect(capture.claudeConfigDir).toBe(path.join(remoteWorkspace, ".paperclip-runtime", "claude", "config")); expect(capture.claudeConfigEntries).toContain("settings.json"); expect(capture.paperclipApiUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); diff --git a/server/src/__tests__/fixtures/plugin-worker-delayed.cjs b/server/src/__tests__/fixtures/plugin-worker-delayed.cjs new file mode 100644 index 00000000..8aa7e4ef --- /dev/null +++ b/server/src/__tests__/fixtures/plugin-worker-delayed.cjs @@ -0,0 +1,65 @@ +const readline = require("node:readline"); + +function send(message) { + process.stdout.write(`${JSON.stringify(message)}\n`); +} + +const rl = readline.createInterface({ + input: process.stdin, + crlfDelay: Infinity, +}); + +rl.on("line", (line) => { + if (!line.trim()) return; + const message = JSON.parse(line); + const method = message && typeof message.method === "string" ? message.method : null; + + if (method === "initialize") { + send({ + jsonrpc: "2.0", + id: message.id, + result: { + ok: true, + supportedMethods: ["environmentExecute"], + }, + }); + return; + } + + if (method === "environmentExecute") { + const delayMs = Number(message.params?.delayMs ?? 0); + setTimeout(() => { + send({ + jsonrpc: "2.0", + id: message.id, + result: { + exitCode: 0, + signal: null, + timedOut: false, + stdout: "ok\n", + stderr: "", + }, + }); + }, delayMs); + return; + } + + if (method === "shutdown") { + send({ + jsonrpc: "2.0", + id: message.id, + result: {}, + }); + setImmediate(() => process.exit(0)); + return; + } + + send({ + jsonrpc: "2.0", + id: message.id, + error: { + code: -32601, + message: `Unhandled method: ${method}`, + }, + }); +}); diff --git a/server/src/__tests__/gemini-local-adapter-environment.test.ts b/server/src/__tests__/gemini-local-adapter-environment.test.ts index 0aa49554..bb187f2b 100644 --- a/server/src/__tests__/gemini-local-adapter-environment.test.ts +++ b/server/src/__tests__/gemini-local-adapter-environment.test.ts @@ -131,4 +131,57 @@ describe("gemini_local environment diagnostics", () => { expect(result.checks.some((check) => check.code === "gemini_hello_probe_quota_exhausted")).toBe(true); await fs.rm(root, { recursive: true, force: true }); }); + + it("trusts remote sandbox workspaces during the hello probe", async () => { + let probeEnv: Record | undefined; + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "gemini_local", + config: { + command: "gemini", + }, + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "cloudflare", + remoteCwd: "/workspace/paperclip", + runner: { + execute: async (input) => { + if (input.command === "gemini") { + probeEnv = input.env; + return { + exitCode: 0, + signal: null, + timedOut: false, + stdout: [ + JSON.stringify({ + type: "assistant", + message: { content: [{ type: "output_text", text: "hello" }] }, + }), + JSON.stringify({ type: "result", subtype: "success", result: "hello" }), + ].join("\n"), + stderr: "", + pid: null, + startedAt: new Date().toISOString(), + }; + } + return { + exitCode: 0, + signal: null, + timedOut: false, + stdout: "", + stderr: "", + pid: null, + startedAt: new Date().toISOString(), + }; + }, + }, + }, + environmentName: "QA Cloudflare", + }); + + expect(result.checks.some((check) => check.code === "gemini_hello_probe_passed")).toBe(true); + expect(probeEnv?.GEMINI_CLI_TRUST_WORKSPACE).toBe("true"); + }); }); diff --git a/server/src/__tests__/plugin-worker-manager.test.ts b/server/src/__tests__/plugin-worker-manager.test.ts index c364fce4..4f578fda 100644 --- a/server/src/__tests__/plugin-worker-manager.test.ts +++ b/server/src/__tests__/plugin-worker-manager.test.ts @@ -13,6 +13,7 @@ import { } from "../services/plugin-worker-manager.js"; const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures"); +const DELAYED_WORKER_ENTRYPOINT = path.join(FIXTURES_DIR, "plugin-worker-delayed.cjs"); const TERMINATED_WORKER_ENTRYPOINT = path.join(FIXTURES_DIR, "plugin-worker-terminated.cjs"); const TEST_MANIFEST: PaperclipPluginManifestV1 = { @@ -67,6 +68,73 @@ describe("plugin-worker-manager stderr failure context", () => { expect(excerpt.length).toBeLessThanOrEqual(8_000); }); + it("times out environmentExecute calls using the handle default when no override is provided", async () => { + const handle = createPluginWorkerHandle("test.plugin", { + entrypointPath: DELAYED_WORKER_ENTRYPOINT, + manifest: TEST_MANIFEST, + config: {}, + instanceInfo: { + instanceId: "instance-1", + hostVersion: "1.0.0", + }, + apiVersion: 1, + hostHandlers: {}, + rpcTimeoutMs: 10, + }); + + try { + await handle.start(); + + await expect(handle.call("environmentExecute", { + driverKey: "e2b", + companyId: "company-1", + environmentId: "environment-1", + config: {}, + lease: { providerLeaseId: "lease-1" }, + command: "echo", + delayMs: 50, + } as HostToWorkerMethods["environmentExecute"][0])).rejects.toMatchObject({ + message: expect.stringContaining("timed out after 10ms"), + }); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("honors per-call timeout overrides for environmentExecute", async () => { + const handle = createPluginWorkerHandle("test.plugin", { + entrypointPath: DELAYED_WORKER_ENTRYPOINT, + manifest: TEST_MANIFEST, + config: {}, + instanceInfo: { + instanceId: "instance-1", + hostVersion: "1.0.0", + }, + apiVersion: 1, + hostHandlers: {}, + rpcTimeoutMs: 10, + }); + + try { + await handle.start(); + + await expect(handle.call("environmentExecute", { + driverKey: "e2b", + companyId: "company-1", + environmentId: "environment-1", + config: {}, + lease: { providerLeaseId: "lease-1" }, + command: "echo", + delayMs: 50, + } as HostToWorkerMethods["environmentExecute"][0], 100)).resolves.toMatchObject({ + exitCode: 0, + stdout: "ok\n", + }); + } finally { + await handle.stop().catch(() => undefined); + } + }); + it("does not emit an unhandled rejection when a plugin responds with terminated before callers attach handlers", async () => { const unhandledRejection = vi.fn(); process.on("unhandledRejection", unhandledRejection); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 425428f7..32a748b6 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -4,7 +4,10 @@ import type { AdapterRuntimeCommandSpec, ServerAdapterModule, } from "./types.js"; -import { getAdapterSessionManagement } from "@paperclipai/adapter-utils"; +import { + buildSandboxNpmInstallCommand, + getAdapterSessionManagement, +} from "@paperclipai/adapter-utils"; import { execute as acpxExecute, testEnvironment as acpxTestEnvironment, @@ -148,11 +151,12 @@ function buildNpmRuntimeCommandSpec( ): AdapterRuntimeCommandSpec { const command = readConfiguredCommand(config, fallbackCommand); const canSelfInstall = !hasPathSeparator(command) && command === fallbackCommand; + const installLine = buildSandboxNpmInstallCommand(packageName); return { command, detectCommand: command, installCommand: canSelfInstall - ? `if ! command -v ${shellQuote(command)} >/dev/null 2>&1; then npm install -g ${shellQuote(packageName)}; fi` + ? `if ! command -v ${shellQuote(command)} >/dev/null 2>&1; then ${installLine}; fi` : null, }; }