Harden remote sandbox runtime probes, timeouts, and installs (#5685)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Each agent runs inside a sandbox environment so its CLI is isolated
from the host
> - Sandbox-backed adapter runs go through a small set of shared helpers
— `ensureAdapterExecutionTargetCommandResolvable`, the sandbox callback
bridge runner, and per-adapter `SANDBOX_INSTALL_COMMAND` strings
> - When standing up new sandbox provider plugins, the existing helpers
timed out, missed install fallbacks, or leaned on assumptions that only
held for E2B
> - Local adapters (`claude-local`, `codex-local`, `gemini-local`,
`opencode-local`) needed slightly hardened probes so they could install
themselves and validate inside *any* remote sandbox transport, not just
E2B
> - This pull request bundles those runtime fixes so future sandbox
provider plugins inherit a working baseline
> - The benefit is that adding a new sandbox provider plugin no longer
requires touching adapter-utils or each local-adapter probe — the
supporting infra is already correct

## What Changed

- `packages/adapter-utils/src/execution-target.ts`: introduce
`DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC = 1800` and
`resolveAdapterExecutionTargetTimeoutSec(...)`. Local and SSH adapters
keep the historical "0 means no adapter timeout" behavior;
sandbox-backed runs without an explicit `timeoutSec` get an explicit
30-minute default so remote installs and warm-up don't time out at the
per-RPC default. Plumbed `timeoutSec` through
`ensureAdapterExecutionTargetCommandResolvable` so install probes inside
a sandbox honor adapter-level overrides instead of the bridge's 5-minute
default.
- `packages/adapters/opencode-local/src/index.ts`: switch
`SANDBOX_INSTALL_COMMAND` from `npm install -g opencode-ai` to `curl
-fsSL https://opencode.ai/install | bash`. The npm package reifies four
large prebuilt-binary subpackages in parallel even though only one
matches the host arch; on bandwidth-constrained sandboxes that blew
through the 240s install budget. The official installer fetches one
arch-specific binary and adds `$HOME/.opencode/bin` to PATH via
`~/.bashrc`, which the sandbox-callback-bridge login-shell script
already sources.
- `packages/adapters/{claude,codex,gemini,opencode}-local/`: harden
remote-target probes — pass `--skip-git-repo-check` for Codex when
probing outside a repo, normalize permission flags for Claude, and add
`*.remote.test.ts` coverage that exercises the remote-sandbox path
explicitly for each adapter.
- `packages/adapter-utils/src/sandbox-install-command.{ts,test.ts}`
(new): add `buildSandboxNpmInstallCommand` helper.
`server/src/adapters/registry.ts` + new
`server/src/__tests__/adapter-registry.test.ts`: wire adapter install
commands so they fall back to a writable `$HOME/.local` prefix when
global install isn't available.
- `server/src/__tests__/plugin-worker-manager.test.ts` + new
`server/src/__tests__/fixtures/plugin-worker-delayed.cjs`: pin per-call
timeout overrides so plugin worker exec calls honor the caller's timeout
instead of the worker's default.

## Verification

- `pnpm typecheck`
- `pnpm exec vitest run --no-coverage
packages/adapter-utils/src/execution-target-sandbox.test.ts
packages/adapter-utils/src/sandbox-install-command.test.ts`
- `pnpm exec vitest run --no-coverage
server/src/__tests__/plugin-worker-manager.test.ts
server/src/__tests__/adapter-registry.test.ts
server/src/__tests__/claude-local-adapter-environment.test.ts
server/src/__tests__/claude-local-execute.test.ts
server/src/__tests__/gemini-local-adapter-environment.test.ts`
- `pnpm exec vitest run --no-coverage
packages/adapters/codex-local/src/server/test.remote.test.ts
packages/adapters/opencode-local/src/server/test.remote.test.ts
packages/adapters/codex-local/src/server/codex-args.test.ts
packages/adapters/codex-local/src/server/execute.remote.test.ts
packages/adapters/gemini-local/src/server/execute.remote.test.ts`

All passing locally.

## Risks

- Touches shared `adapter-utils` and several `*-local` adapters. The
30-minute default applies only when both (a) the target is
`remote+sandbox` and (b) no `timeoutSec` is configured — local + SSH
paths are unchanged. New test coverage was added alongside each behavior
change to pin the contracts.
- Switching OpenCode's install command to the official installer is a
behavior change for any operator running OpenCode inside a remote
sandbox. Local installs are unaffected (the `SANDBOX_INSTALL_COMMAND`
only runs when an adapter is being installed inside a sandbox).
- Low risk overall — no migrations, no API surface change.

## Model Used

- Provider: Anthropic
- Model: Claude Opus 4.7 (1M context)
- Capabilities used: extended reasoning, tool use (Read/Edit/Bash/Grep),
no code execution beyond local repo commands

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots — N/A, no UI change
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin Foley 2026-05-11 00:31:54 -07:00 committed by GitHub
parent 6e4fa78d86
commit b24c6909e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 938 additions and 36 deletions

View file

@ -203,6 +203,7 @@ describe("gemini remote execution", () => {
]);
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);

View file

@ -18,6 +18,7 @@ import {
prepareAdapterExecutionTargetRuntime,
readAdapterExecutionTarget,
readAdapterExecutionTargetHomeDir,
resolveAdapterExecutionTargetTimeoutSec,
resolveAdapterExecutionTargetCommandForLogs,
runAdapterExecutionTargetProcess,
runAdapterExecutionTargetShellCommand,
@ -261,6 +262,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
executionTargetIsRemote,
executionCwd: effectiveExecutionCwd,
});
if (executionTargetIsRemote && typeof env.GEMINI_CLI_TRUST_WORKSPACE !== "string") {
env.GEMINI_CLI_TRUST_WORKSPACE = "true";
}
if (!hasExplicitApiKey && authToken) {
env.PAPERCLIP_API_KEY = authToken;
}
@ -275,7 +279,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
(entry): entry is [string, string] => 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<AdapterExec
graceSec,
onLog,
});
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv, { installCommand: SANDBOX_INSTALL_COMMAND });
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv, {
installCommand: SANDBOX_INSTALL_COMMAND,
timeoutSec,
});
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
let loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
@ -318,6 +328,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
runId,
target: executionTarget,
adapterKey: "gemini",
timeoutSec,
workspaceLocalDir: cwd,
installCommand: SANDBOX_INSTALL_COMMAND,
detectCommand: command,
@ -380,6 +391,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
target: runtimeExecutionTarget,
runtimeRootDir: remoteRuntimeRootDir,
adapterKey: "gemini",
timeoutSec,
hostApiToken: env.PAPERCLIP_API_KEY,
onLog,
});
@ -435,6 +447,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const commandNotes = (() => {
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(

View file

@ -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,