From 9cde4e128c4f530463408775f7ea462ecc3e234c Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Fri, 10 Jul 2026 17:13:53 -0700 Subject: [PATCH] feat: run ACP sessions in sandbox execution targets (#9390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agent adapters (Claude, Codex, Gemini) default to the ACP engine lane, which needs a live bidirectional stdio session with the agent process > - Sandbox execution targets only exposed one-shot command execution, so every ACP-capable adapter refused remote targets and fell back to the CLI lane with a "supports only the local Paperclip host" warning > - Running agents in sandboxes is a core deployment mode, and losing ACP there means losing streaming updates, structured events, and default-lane parity with local runs > - This pull request adds a provider-agnostic process-session bridge that relays the ACP stdio session into the sandbox over the existing sandbox runner contract, and updates the adapters to use it > - The benefit is that the default ACP lane now behaves the same on the local host and in any sandbox provider, with CLI fallback reserved for targets that genuinely cannot host a bidirectional session ## Linked Issues or Issue Description No existing public issue covers this; inline description following the feature request template: **Problem or motivation** Configuring an ACP-capable adapter (e.g. Claude) with a sandbox environment made every run fall back to the CLI lane with the warning "Claude ACP currently supports only the local Paperclip host, but this run targets a remote environment." The ACP engine only knew how to spawn a local subprocess, while sandbox providers only expose one-shot command execution — so there was no way to hold the bidirectional stdio session ACP requires. **Proposed solution** Add a process-session bridge in `adapter-utils`: a local ACPX-spawnable proxy script connects to a token-authenticated loopback TCP server, which relays JSON-framed stdin/stdout/stderr events to and from a small relay script executed inside the sandbox via the provider's ordinary runner. Claude/Codex/Gemini adapters now treat sandbox targets with a runner as ACP-capable, resolve agent commands against the remote target, and fall back to CLI only when the sandbox exposes no bidirectional path. The sandbox callback bridge injects a run-scoped API endpoint and bridge token so the agent inside the sandbox can reach Paperclip (including work-product handoffs) without ever receiving the host run JWT. **Alternatives considered** A provider-specific lane was prototyped first: Daytona minting SSH access metadata at lease time, converted into an SSH execution target. It was dropped because it only worked for providers able to advertise SSH, added per-provider surface area, and left every other sandbox provider on the CLI fallback. The merged design rides the one-shot runner contract all providers already implement; a regression test pins that sandbox targets stay on the bridge lane even when lease metadata advertises SSH access. **Roadmap alignment** Directly advances the "Cloud / Sandbox agents" roadmap item — agents running in remote and sandboxed environments keep the same control-plane behavior as local ones. No overlap with other planned core work. ## What Changed - `packages/adapter-utils/src/execution-target.ts`: new `startAdapterExecutionTargetProcessSessionBridge()` plus helpers — writes a token-authenticated local proxy script (spawnable by ACPX) and a remote relay script synced into the sandbox, with a loopback TCP server streaming JSON-framed stdio between them; events emitted before the ACP client attaches are buffered so none are lost. - `packages/adapter-utils/src/acpx-engine/execute.ts`: the ACP engine can execute against remote sandbox targets through the bridge instead of requiring a local subprocess, including remote cwd/env shaping. - `packages/adapter-utils/src/sandbox-callback-bridge.ts`: sandbox-scoped API bridging extended to allow work-product handoffs; the sandbox payload env carries a bridge token, never the host run JWT. - `packages/adapters/claude-local`, `codex-local`, `gemini-local` (`src/server/acp.ts`): default-lane selection no longer rejects all remote targets; command resolution is remote-aware (`ensureAdapterExecutionTargetCommandResolvable`, `resolveAdapterExecutionTargetCwd`); the fallback reason is now scoped to sandboxes that expose only one-shot execution. - `server/src/__tests__/environment-execution-target.test.ts`: pins that sandbox targets resolve to the bridge lane, including when lease metadata advertises SSH access. - Non-sandbox remote targets (e.g. SSH) keep the CLI lane: the ACP engine's remote transport is sandbox-only, so default-lane selection falls back for those targets across all three adapters, and tests covering CLI-specific remote behavior pin `engine: "cli"` explicitly. - The bridge authenticates loopback connections before they can own the session or receive buffered output (token required, idle unauthenticated peers dropped), and remote event writes are serialized so the exit event always lands after stdout/stderr have drained. - Daytona plugin: formatting-only residue from the earlier iteration; no functional change. ## Verification - `vitest run` over the touched suites — `packages/adapter-utils/src/acpx-engine/execute.test.ts`, `packages/adapter-utils/src/execution-target-sandbox.test.ts`, `packages/adapter-utils/src/sandbox-callback-bridge.test.ts`, the three adapter `acp.test.ts` files, and `server/src/__tests__/environment-execution-target.test.ts` — 102 tests pass. - End to end: with a Claude agent configured on a Daytona sandbox environment, the primary-model test now selects the default ACP lane (no fallback warning), and the full round trip (wake → sandbox execution → API bridge → comment post) was exercised twice from inside a live sandbox. ## Risks - Behavioral shift: adapters that previously always fell back to CLI on sandbox targets now default to ACP there; `engine=cli` still pins the CLI lane explicitly. - The bridge relays stdio as JSON lines over loopback TCP guarded by a per-session random token; the remote relay runs inside the sandbox under the provider's runner. Providers with slow one-shot execution will see higher session startup latency — the CLI fallback remains for genuinely incapable targets. - No schema or migration changes. ## Model Used - Claude Fable 5 (`claude-fable-5`, Anthropic) — extended thinking enabled, agentic tool use via the Claude Agent SDK harness; implementation iterated with local Vitest verification. ## 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 searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes (no shipped docs describe the old local-only ACP limitation) - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Cody Co-authored-by: Cody --- .../src/acpx-engine/execute.test.ts | 145 ++++++ .../adapter-utils/src/acpx-engine/execute.ts | 92 +++- .../src/execution-target-sandbox.test.ts | 337 +++++++++++++- .../adapter-utils/src/execution-target.ts | 415 ++++++++++++++++++ .../src/sandbox-callback-bridge.test.ts | 4 + .../src/sandbox-callback-bridge.ts | 5 + .../claude-local/src/server/acp.test.ts | 73 +++ .../adapters/claude-local/src/server/acp.ts | 70 ++- .../src/server/test.probe.test.ts | 8 +- .../codex-local/src/server/acp.test.ts | 73 +++ .../adapters/codex-local/src/server/acp.ts | 70 ++- .../gemini-local/src/server/acp.test.ts | 55 ++- .../adapters/gemini-local/src/server/acp.ts | 57 ++- .../src/server/execute.remote.test.ts | 3 + .../sandbox-providers/daytona/src/plugin.ts | 18 +- .../environment-execution-target.test.ts | 43 ++ 16 files changed, 1417 insertions(+), 51 deletions(-) diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index a953021dca..bdadebda32 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -13,6 +13,7 @@ import { parseGeminiVersionParts, rewriteGeminiAcpFlagForVersion, } from "./execute.js"; +import { runChildProcess } from "../server-utils.js"; const execFileAsync = promisify(execFile); @@ -50,6 +51,44 @@ async function createSkill(root: string, name: string, body = `---\nrequired: fa }; } +function createLocalSandboxRunner( + onExecute?: (input: { + command: string; + args?: string[]; + cwd?: string; + env?: Record; + }) => void, +) { + let counter = 0; + return { + execute: async (input: { + command: string; + args?: string[]; + cwd?: string; + env?: Record; + stdin?: string; + timeoutMs?: number; + onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; + onSpawn?: (meta: { pid: number; startedAt: string }) => Promise; + }) => { + counter += 1; + onExecute?.(input); + const command = input.command === "bash" ? "/bin/bash" : input.command; + return await runChildProcess(`acpx-sandbox-run-${counter}`, command, input.args ?? [], { + cwd: input.cwd ?? process.cwd(), + env: input.env ?? {}, + stdin: input.stdin, + timeoutSec: Math.max(1, Math.ceil((input.timeoutMs ?? 30_000) / 1000)), + graceSec: 5, + onLog: input.onLog ?? (async () => {}), + onSpawn: input.onSpawn + ? async (meta) => input.onSpawn?.({ pid: meta.pid, startedAt: meta.startedAt }) + : undefined, + }); + }, + }; +} + function buildRuntime() { return { ensureSession: async () => ({ @@ -158,6 +197,61 @@ describe("shared ACPX engine runtime behavior", () => { expect(prompt).not.toContain("$PAPERCLIP_API_BASE/api/issues/$PAPERCLIP_TASK_ID"); }); + it("emits ACP text deltas as stdout transcript records", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const logs: Array<{ stream: string; text: string }> = []; + const execute = createAcpxEngineExecutor({ + createRuntime: () => ({ + ensureSession: async () => ({ + backendSessionId: "backend-session", + agentSessionId: "agent-session", + runtimeSessionName: "runtime-session", + }), + startTurn: () => ({ + events: (async function* () { + yield { + type: "text_delta", + text: "streamed hello", + stream: "output", + tag: "agent_message_chunk", + }; + yield { type: "done", stopReason: "end_turn" }; + })(), + result: Promise.resolve({ status: "completed", stopReason: "end_turn" }), + cancel: async () => {}, + }), + close: async () => {}, + }) as never, + }); + + const result = await execute({ + runId: "run-streaming-text-delta", + agent: { + id: "agent-1", + companyId: "company-1", + }, + runtime: {}, + config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir }, + context: {}, + onLog: async (stream: "stdout" | "stderr", text: string) => { + logs.push({ stream, text }); + }, + onMeta: async () => {}, + } as never); + + expect(result.exitCode).toBe(0); + expect(logs).toContainEqual({ + stream: "stdout", + text: `${JSON.stringify({ + type: "acpx.text_delta", + text: "streamed hello", + channel: "output", + tag: "agent_message_chunk", + })}\n`, + }); + }); + it.skipIf(process.platform === "win32")("materializes ACPX Claude skills without symlinked descendants", async () => { const root = await makeTempRoot(); const skillRoot = path.join(root, "skills"); @@ -558,6 +652,57 @@ describe("shared ACPX engine runtime behavior", () => { expect(wrapper).toContain("exec node ./fake-acp.js"); }); + it("starts sandbox ACP process sessions in the remote execution cwd", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const localCwd = path.join(root, "worktree"); + const remoteCwd = path.join(root, "remote-workspace"); + await fs.mkdir(localCwd, { recursive: true }); + await fs.mkdir(remoteCwd, { recursive: true }); + + let sessionPayload: Record | null = null; + const runner = createLocalSandboxRunner( + (input: { args?: string[]; env?: Record }) => { + if (input.env?.PAPERCLIP_SANDBOX_EXEC_CHANNEL === "bridge") { + const script = input.args?.[1] ?? ""; + const match = script.match(/PAPERCLIP_PROCESS_SESSION_COMMAND_B64='([^']+)'/); + if (match) { + sessionPayload = JSON.parse(Buffer.from(match[1]!, "base64").toString("utf8")) as Record; + } + } + }, + ); + + await runExecutor( + { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd }, + { + authToken: "real-run-jwt", + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "fake-plugin", + remoteCwd, + runner, + }, + }, + ); + + expect(sessionPayload).toMatchObject({ + command: "sh", + args: ["-lc", "exec node ./fake-acp.js"], + cwd: remoteCwd, + }); + const payloadEnv = ((sessionPayload as Record | null)?.env ?? {}) as Record; + expect(payloadEnv).toMatchObject({ + PAPERCLIP_API_BRIDGE_MODE: "queue_v1", + }); + expect(String(payloadEnv.PAPERCLIP_API_URL ?? "")).toMatch( + /^http:\/\/127\.0\.0\.1:\d+$/, + ); + expect(payloadEnv.PAPERCLIP_API_KEY).toBeTruthy(); + expect(payloadEnv.PAPERCLIP_API_KEY).not.toBe("real-run-jwt"); + }); + it.skipIf(process.platform === "win32")("drops benign ACP nes/close cleanup stderr but keeps it in the run log", async () => { const root = await makeTempRoot(); const stateDir = path.join(root, "state"); diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index 6d4a849d9f..32dbac0877 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -12,6 +12,10 @@ import { formatAdapterExecutionTimeoutStartLogLine, readAdapterExecutionTarget, resolveAdapterExecutionTargetTimeout, + startAdapterExecutionTargetPaperclipBridge, + startAdapterExecutionTargetProcessSessionBridge, + type AdapterExecutionTargetPaperclipBridgeHandle, + type AdapterExecutionTargetProcessSessionBridgeHandle, type AdapterExecutionTargetTimeoutResolution, } from "@paperclipai/adapter-utils/execution-target"; import { @@ -112,6 +116,8 @@ interface AcpxPreparedRuntime { fingerprint: string; agentCommand: string | null; agentRegistry: AcpAgentRegistry; + processSessionBridge: AdapterExecutionTargetProcessSessionBridgeHandle | null; + paperclipBridge: AdapterExecutionTargetPaperclipBridgeHandle | null; remoteExecutionIdentity: Record | null; skillPromptInstructions: string; skillsIdentity: Record; @@ -180,12 +186,20 @@ interface BuiltInAgentCommand { shellCommand: string; } -async function resolveBuiltInAgentCommand(agent: string, packageRootDir: string): Promise { +async function resolveBuiltInAgentCommand(input: { + agent: string; + packageRootDir: string; + executionTargetIsRemote: boolean; +}): Promise { + const { agent, packageRootDir, executionTargetIsRemote } = input; if (agent === "gemini") { return { command: "gemini --acp", shellCommand: "gemini --acp" }; } const binName = agent === "claude" ? "claude-agent-acp" : agent === "codex" ? "codex-acp" : null; if (!binName) return null; + if (executionTargetIsRemote) { + return { command: binName, shellCommand: binName }; + } const resolved = (await findAncestorBin(packageRootDir, binName)) ?? binName; return { command: resolved, shellCommand: shellQuote(resolved) }; } @@ -1062,7 +1076,11 @@ async function buildRuntime(input: { } const configuredCommand = asString(config.agentCommand, "").trim(); - const builtInCommand = await resolveBuiltInAgentCommand(acpxAgent, input.engine.packageRootDir); + const builtInCommand = await resolveBuiltInAgentCommand({ + agent: acpxAgent, + packageRootDir: input.engine.packageRootDir, + executionTargetIsRemote, + }); let agentCommand = configuredCommand || builtInCommand?.command || null; let agentCommandShell = configuredCommand || builtInCommand?.shellCommand || ""; if (acpxAgent === "gemini" && agentCommandShell) { @@ -1087,7 +1105,58 @@ async function buildRuntime(input: { }) : null; const wrapperPath = wrapper?.wrapperPath ?? null; - const overrides = wrapperPath ? { [acpxAgent]: wrapperPath } : undefined; + let paperclipBridge: AdapterExecutionTargetPaperclipBridgeHandle | null = null; + if ( + executionTarget?.kind === "remote" && + executionTarget.transport === "sandbox" && + Boolean(executionTarget.runner) && + agentCommandShell + ) { + paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({ + runId, + target: { ...executionTarget, streamRunLogs: false }, + runtimeRootDir: null, + adapterKey: input.engine.adapterType, + timeoutSec, + hostApiToken: env.PAPERCLIP_API_KEY, + onLog: input.ctx.onLog, + }); + if (paperclipBridge) { + Object.assign(env, paperclipBridge.env); + await input.ctx.onLog("stdout", "[paperclip] Sandbox ACP API callback bridge enabled for this run.\n"); + } + } + const runtimeEnv = Object.fromEntries( + Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + let processSessionBridge: AdapterExecutionTargetProcessSessionBridgeHandle | null = null; + try { + processSessionBridge = + executionTarget?.kind === "remote" && + executionTarget.transport === "sandbox" && + Boolean(executionTarget.runner) && + agentCommandShell + ? await startAdapterExecutionTargetProcessSessionBridge({ + runId, + target: executionTarget, + runtimeRootDir: null, + adapterKey: input.engine.adapterType, + command: "sh", + args: ["-lc", `exec ${agentCommandShell}`], + cwd: effectiveExecutionCwd, + env: runtimeEnv, + timeoutSec, + onLog: input.ctx.onLog, + }) + : null; + } catch (err) { + await paperclipBridge?.stop().catch(() => {}); + throw err; + } + const overrideCommand = processSessionBridge?.agentCommand ?? wrapperPath; + const overrides = overrideCommand ? { [acpxAgent]: overrideCommand } : undefined; const agentRegistry = createAgentRegistry({ overrides }); const fingerprint = shortHash({ acpxAgent, @@ -1113,7 +1182,6 @@ async function buildRuntime(input: { }); const taskKey = asString(input.ctx.runtime.taskKey, "") || wakeTaskId || workspaceId || "default"; const sessionKey = `paperclip:${agent.companyId}:${agent.id}:${taskKey}:${fingerprint}`; - const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); const loggedEnv = buildInvocationEnvForLogs(env, { runtimeEnv, includeRuntimeKeys: ["HOME"], @@ -1141,6 +1209,8 @@ async function buildRuntime(input: { fingerprint, agentCommand, agentRegistry, + processSessionBridge, + paperclipBridge, remoteExecutionIdentity, skillPromptInstructions, skillsIdentity: { @@ -1202,6 +1272,13 @@ async function applySessionConfigOptions(input: { } } +async function cleanupRemoteBridges(prepared: AcpxPreparedRuntime): Promise { + await Promise.allSettled([ + prepared.processSessionBridge?.stop(), + prepared.paperclipBridge?.stop(), + ]); +} + function renderPaperclipEnvNote(env: Record): string { const paperclipKeys = Object.keys(env) .filter((key) => key.startsWith("PAPERCLIP_")) @@ -1692,6 +1769,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { err, phase: "ensure_session", }); + await cleanupRemoteBridges(prepared); return { exitCode: 1, signal: null, @@ -1707,6 +1785,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { } if (!handle) { + await cleanupRemoteBridges(prepared); return { exitCode: 1, signal: null, @@ -1744,6 +1823,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { clearWarmHandleTimer(existing); warmHandles.delete(prepared.sessionKey); } + await cleanupRemoteBridges(prepared); return { exitCode: 1, signal: null, @@ -1856,7 +1936,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { discardPersistentState: terminal.status === "cancelled" || timedOut, }).catch(() => {}); } - } else if (prepared.mode === "persistent" && warmIdleMs > 0) { + } else if (prepared.mode === "persistent" && warmIdleMs > 0 && !prepared.processSessionBridge) { const existing = warmHandles.get(prepared.sessionKey); if (existing && !warmHandleMatches(existing, runtime, sessionHandle)) { await runtime.close({ @@ -1908,6 +1988,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { stopReason: terminalStopReason, message: errorMessage, }); + await cleanupRemoteBridges(prepared); return { exitCode: terminal.status === "completed" ? 0 : 1, signal: timedOut ? "SIGTERM" : null, @@ -1959,6 +2040,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { phase: "turn", messageOverride, }); + await cleanupRemoteBridges(prepared); return { exitCode: 1, signal: timedOut ? "SIGTERM" : null, diff --git a/packages/adapter-utils/src/execution-target-sandbox.test.ts b/packages/adapter-utils/src/execution-target-sandbox.test.ts index a28ca2d150..657a506557 100644 --- a/packages/adapter-utils/src/execution-target-sandbox.test.ts +++ b/packages/adapter-utils/src/execution-target-sandbox.test.ts @@ -1,7 +1,10 @@ import { createServer } from "node:http"; -import { mkdir, mkdtemp, readdir, readFile, rm } from "node:fs/promises"; +import net from "node:net"; +import { execFile, spawn } from "node:child_process"; +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { promisify } from "node:util"; import { afterEach, describe, expect, it, vi } from "vitest"; import { @@ -16,6 +19,7 @@ import { resolveAdapterExecutionTargetTimeoutSec, runAdapterExecutionTargetProcess, runAdapterExecutionTargetShellCommand, + startAdapterExecutionTargetProcessSessionBridge, startAdapterExecutionTargetPaperclipBridge, type AdapterSandboxExecutionTarget, } from "./execution-target.js"; @@ -23,6 +27,8 @@ import { createSandboxRunLogTailFactory } from "./sandbox-run-log-stream.js"; import { runChildProcess } from "./server-utils.js"; import { shellQuote } from "./ssh.js"; +const execFileAsync = promisify(execFile); + describe("sandbox adapter execution targets", () => { const cleanupDirs: string[] = []; @@ -90,8 +96,8 @@ describe("sandbox adapter execution targets", () => { ].join("\n"); } - async function waitForCondition(predicate: () => boolean, message: string): Promise { - const deadline = Date.now() + 1000; + async function waitForCondition(predicate: () => boolean, message: string, timeoutMs = 1000): Promise { + const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (predicate()) return; await new Promise((resolve) => setTimeout(resolve, 5)); @@ -99,6 +105,36 @@ describe("sandbox adapter execution targets", () => { throw new Error(message); } + async function runProxyWithInput(command: string, input: string): Promise<{ stdout: string; stderr: string; code: number | null }> { + const child = spawn(command, [], { stdio: ["pipe", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.stdin.end(input); + const code = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error("Timed out waiting for process session proxy.")); + }, 5000); + child.on("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + child.on("exit", (exitCode) => { + clearTimeout(timeout); + resolve(exitCode); + }); + }); + return { stdout, stderr, code }; + } + function combinedStream( events: Array<{ stream: "stdout" | "stderr"; chunk: string }>, stream: "stdout" | "stderr", @@ -158,6 +194,301 @@ describe("sandbox adapter execution targets", () => { }); }); + it("bridges bidirectional sandbox process sessions through a local ACPX-spawnable proxy", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-")); + cleanupDirs.push(rootDir); + const childPath = path.join(rootDir, "fake-acp-child.mjs"); + await writeFile( + childPath, + [ + "process.stdin.on('data', (chunk) => {", + " process.stdout.write('out:' + chunk.toString());", + " process.stderr.write('err:' + chunk.toString());", + "});", + ].join("\n"), + "utf8", + ); + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "local-test", + remoteCwd: rootDir, + timeoutMs: 30_000, + runner: createLocalSandboxRunner(), + }; + + const bridge = await startAdapterExecutionTargetProcessSessionBridge({ + runId: "run-process-session", + target, + runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), + adapterKey: "acpx", + command: process.execPath, + args: [childPath], + cwd: rootDir, + env: {}, + timeoutSec: 5, + onLog: async () => {}, + }); + expect(bridge).not.toBeNull(); + + try { + const result = await runProxyWithInput(bridge!.agentCommand, "hello\n"); + expect(result.code).toBe(0); + expect(result.stdout).toBe("out:hello\n"); + expect(result.stderr).toBe("err:hello\n"); + } finally { + await bridge?.stop(); + } + }); + + it("buffers sandbox process session output until the local proxy connects", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-buffer-")); + cleanupDirs.push(rootDir); + const childPath = path.join(rootDir, "fast-acp-child.mjs"); + await writeFile( + childPath, + [ + "process.stdout.write('early-out\\n');", + "process.stderr.write('early-err\\n');", + "setTimeout(() => process.exit(0), 20);", + ].join("\n"), + "utf8", + ); + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "local-test", + remoteCwd: rootDir, + timeoutMs: 30_000, + runner: createLocalSandboxRunner(), + }; + + const bridge = await startAdapterExecutionTargetProcessSessionBridge({ + runId: "run-process-session-buffer", + target, + runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), + adapterKey: "acpx", + command: process.execPath, + args: [childPath], + cwd: rootDir, + env: {}, + timeoutSec: 5, + onLog: async () => {}, + }); + expect(bridge).not.toBeNull(); + + try { + await new Promise((resolve) => setTimeout(resolve, 300)); + const result = await runProxyWithInput(bridge!.agentCommand, ""); + expect(result.code).toBe(0); + expect(result.stdout).toBe("early-out\n"); + expect(result.stderr).toBe("early-err\n"); + } finally { + await bridge?.stop(); + } + }); + + it("delivers full output when the sandbox child exits immediately after writing", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-fast-exit-")); + cleanupDirs.push(rootDir); + const childPath = path.join(rootDir, "instant-exit-acp-child.mjs"); + await writeFile( + childPath, + [ + "process.stdout.write('final-out\\n');", + "process.stderr.write('final-err\\n');", + ].join("\n"), + "utf8", + ); + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "local-test", + remoteCwd: rootDir, + timeoutMs: 30_000, + runner: createLocalSandboxRunner(), + }; + + const bridge = await startAdapterExecutionTargetProcessSessionBridge({ + runId: "run-process-session-fast-exit", + target, + runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), + adapterKey: "acpx", + command: process.execPath, + args: [childPath], + cwd: rootDir, + env: {}, + timeoutSec: 5, + onLog: async () => {}, + }); + expect(bridge).not.toBeNull(); + + try { + const result = await runProxyWithInput(bridge!.agentCommand, ""); + expect(result.code).toBe(0); + expect(result.stdout).toBe("final-out\n"); + expect(result.stderr).toBe("final-err\n"); + } finally { + await bridge?.stop(); + } + }); + + it("ignores unauthenticated connections to the process session bridge", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-auth-")); + cleanupDirs.push(rootDir); + const childPath = path.join(rootDir, "guarded-acp-child.mjs"); + await writeFile(childPath, "process.stdout.write('guarded-out\\n');", "utf8"); + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "local-test", + remoteCwd: rootDir, + timeoutMs: 30_000, + runner: createLocalSandboxRunner(), + }; + + const bridge = await startAdapterExecutionTargetProcessSessionBridge({ + runId: "run-process-session-auth", + target, + runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), + adapterKey: "acpx", + command: process.execPath, + args: [childPath], + cwd: rootDir, + env: {}, + timeoutSec: 5, + onLog: async () => {}, + }); + expect(bridge).not.toBeNull(); + + let squatter: net.Socket | null = null; + try { + const proxySource = await readFile(bridge!.agentCommand, "utf8"); + const port = Number(/port: (\d+)/.exec(proxySource)?.[1] ?? Number.NaN); + expect(Number.isFinite(port)).toBe(true); + + // An idle local connection must not claim the session or see buffered output. + const squatterSocket = net.createConnection({ host: "127.0.0.1", port }); + squatter = squatterSocket; + let squatterReceived = ""; + squatterSocket.setEncoding("utf8"); + squatterSocket.on("data", (chunk: string) => { + squatterReceived += chunk; + }); + squatterSocket.on("error", () => undefined); + await new Promise((resolve, reject) => { + squatterSocket.once("connect", () => resolve()); + squatterSocket.once("error", reject); + }); + + // A peer presenting the wrong token is disconnected outright. + const badPeer = net.createConnection({ host: "127.0.0.1", port }); + badPeer.on("error", () => undefined); + const badPeerClosed = new Promise((resolve) => badPeer.once("close", () => resolve())); + badPeer.once("connect", () => badPeer.write(`${JSON.stringify({ token: "wrong-token", type: "stdinEnd" })}\n`)); + await badPeerClosed; + + // The authenticated proxy still attaches and receives the buffered output. + const result = await runProxyWithInput(bridge!.agentCommand, ""); + expect(result.code).toBe(0); + expect(result.stdout).toBe("guarded-out\n"); + expect(squatterReceived).toBe(""); + } finally { + squatter?.destroy(); + await bridge?.stop(); + } + }); + + it("streams sandbox process session output before the remote child exits", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-stream-")); + cleanupDirs.push(rootDir); + const childPath = path.join(rootDir, "streaming-acp-child.mjs"); + await writeFile( + childPath, + [ + "process.stdin.setEncoding('utf8');", + "process.stdin.on('data', (chunk) => {", + " if (chunk.includes('ping')) {", + " process.stdout.write('delta:ping\\n');", + " process.stderr.write('trace:ping\\n');", + " }", + " if (chunk.includes('finish')) process.exit(0);", + "});", + "process.stdin.resume();", + ].join("\n"), + "utf8", + ); + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "local-test", + remoteCwd: rootDir, + timeoutMs: 30_000, + runner: createLocalSandboxRunner(), + }; + + const bridge = await startAdapterExecutionTargetProcessSessionBridge({ + runId: "run-process-session-stream", + target, + runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"), + adapterKey: "acpx", + command: process.execPath, + args: [childPath], + cwd: rootDir, + env: {}, + timeoutSec: 5, + onLog: async () => {}, + }); + expect(bridge).not.toBeNull(); + + const child = spawn(bridge!.agentCommand, [], { stdio: ["pipe", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + let exited = false; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + const exitPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error("Timed out waiting for streaming process session proxy.")); + }, 5000); + child.on("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + child.on("exit", (exitCode) => { + exited = true; + clearTimeout(timeout); + resolve(exitCode); + }); + }); + + try { + child.stdin.write("ping\n"); + await waitForCondition( + () => stdout.includes("delta:ping\n") && stderr.includes("trace:ping\n"), + "Timed out waiting for live process session output.", + 3000, + ); + expect(exited).toBe(false); + + child.stdin.end("finish\n"); + await expect(exitPromise).resolves.toBe(0); + } finally { + if (!exited) { + child.kill("SIGKILL"); + await exitPromise.catch(() => undefined); + } + await bridge?.stop(); + } + }); + it("applies the remote sandbox fallback when adapter timeoutSec is unset", () => { const sandboxTarget: AdapterSandboxExecutionTarget = { kind: "remote", diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index 948b44501f..e264ab7698 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -1,4 +1,8 @@ +import fs from "node:fs/promises"; +import net from "node:net"; +import os from "node:os"; import path from "node:path"; +import { randomUUID } from "node:crypto"; import type { SshRemoteExecutionSpec } from "./ssh.js"; import { prepareCommandManagedRuntime, @@ -125,6 +129,11 @@ export interface AdapterExecutionTargetPaperclipBridgeHandle { stop(): Promise; } +export interface AdapterExecutionTargetProcessSessionBridgeHandle { + agentCommand: string; + stop(): Promise; +} + export { sanitizeRemoteExecutionEnv } from "./remote-execution-env.js"; // 4-hour wall-clock backstop for sandbox-backed adapter runs. This is a @@ -1200,6 +1209,412 @@ async function readBridgeForwardResponseBody(response: Response, maxBodyBytes: n return Buffer.concat(chunks, totalBytes).toString("utf8"); } +const PROCESS_SESSION_PROXY_SCRIPT = "paperclip-process-session-proxy.mjs"; +const PROCESS_SESSION_REMOTE_SCRIPT = "paperclip-process-session-remote.mjs"; +const PROCESS_SESSION_AUTH_TIMEOUT_MS = 5_000; + +function jsonLine(value: unknown): string { + return `${JSON.stringify(value)}\n`; +} + +function splitJsonLines(buffer: string): { lines: string[]; rest: string } { + const parts = buffer.split(/\n/); + return { lines: parts.slice(0, -1), rest: parts.at(-1) ?? "" }; +} + +async function writeProcessSessionProxyScript(dir: string, port: number, token: string): Promise { + await fs.mkdir(dir, { recursive: true }); + const proxyPath = path.join(dir, PROCESS_SESSION_PROXY_SCRIPT); + await fs.writeFile(proxyPath, getProcessSessionProxySource({ port, token }), { mode: 0o700 }); + return proxyPath; +} + +async function syncProcessSessionRemoteScript(input: { + client: ReturnType; + remoteScriptPath: string; +}): Promise { + await input.client.writeTextFile(input.remoteScriptPath, getProcessSessionRemoteSource()); +} + +async function readRemoteJsonFiles(input: { + client: ReturnType; + dir: string; +}): Promise> { + const names = await input.client.listJsonFiles(input.dir); + const out: Array<{ name: string; body: string }> = []; + for (const name of names) { + const filePath = path.posix.join(input.dir, name); + const body = await input.client.readTextFile(filePath); + await input.client.remove(filePath).catch(() => undefined); + out.push({ name, body }); + } + return out; +} + +async function waitForLocalServerListen(server: net.Server): Promise { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Process session bridge did not expose a TCP port."); + } + return address.port; +} + +export async function startAdapterExecutionTargetProcessSessionBridge(input: { + runId: string; + target: AdapterExecutionTarget | null | undefined; + runtimeRootDir: string | null | undefined; + adapterKey: string; + command: string; + args: string[]; + cwd: string; + env: Record; + timeoutSec?: number | null; + onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; +}): Promise { + if (!input.target || input.target.kind !== "remote" || input.target.transport !== "sandbox") { + return null; + } + + const target = input.target; + const onLog = input.onLog ?? (async () => {}); + const runner = requireSandboxRunner(target); + const shellCommand = preferredSandboxShell(target); + const timeoutMs = + typeof input.timeoutSec === "number" && Number.isFinite(input.timeoutSec) && input.timeoutSec > 0 + ? Math.trunc(input.timeoutSec * 1000) + : target.timeoutMs ?? undefined; + const bridgeRuntimeDir = path.posix.join( + input.runtimeRootDir?.trim() || path.posix.join(target.remoteCwd, ".paperclip-runtime", input.adapterKey), + "process-sessions", + ); + const sessionId = randomUUID(); + const sessionDir = path.posix.join(bridgeRuntimeDir, sessionId); + const stdinDir = path.posix.join(sessionDir, "stdin"); + const eventsDir = path.posix.join(sessionDir, "events"); + const remoteScriptPath = path.posix.join(bridgeRuntimeDir, PROCESS_SESSION_REMOTE_SCRIPT); + const client = createCommandManagedSandboxCallbackBridgeQueueClient({ + runner, + remoteCwd: target.remoteCwd, + timeoutMs, + shellCommand, + }); + + await client.makeDir(stdinDir); + await client.makeDir(eventsDir); + await syncProcessSessionRemoteScript({ client, remoteScriptPath }); + + const commandPayload = Buffer.from(JSON.stringify({ + command: input.command, + args: input.args, + cwd: input.cwd || target.remoteCwd, + env: sanitizeRemoteExecutionEnv(input.env), + }), "utf8").toString("base64"); + + await onLog("stdout", `[paperclip] Starting ACP process session bridge in sandbox (${target.providerKey ?? "provider"}).\n`); + const startResult = await runner.execute({ + command: shellCommand, + args: shellCommandArgs( + [ + `mkdir -p ${shellQuote(stdinDir)} ${shellQuote(eventsDir)}`, + `PAPERCLIP_PROCESS_SESSION_DIR=${shellQuote(sessionDir)} ` + + `PAPERCLIP_PROCESS_SESSION_COMMAND_B64=${shellQuote(commandPayload)} ` + + `nohup node ${shellQuote(remoteScriptPath)} >/dev/null 2>&1 < /dev/null &`, + "printf '%s\\n' \"$!\"", + ].join("\n"), + ), + cwd: target.remoteCwd, + env: { + PAPERCLIP_SANDBOX_EXEC_CHANNEL: "bridge", + }, + timeoutMs, + }); + if (startResult.timedOut || (startResult.exitCode ?? 1) !== 0) { + throw new Error(`Failed to start sandbox ACP process session bridge: ${startResult.stderr || startResult.stdout}`); + } + + let socket: net.Socket | null = null; + let stopping = false; + let stdinSeq = 0; + let pollTimer: NodeJS.Timeout | null = null; + const pendingRemoteEvents: Array<{ + type?: string; + stream?: "stdout" | "stderr"; + data?: string; + code?: number | null; + signal?: string | null; + message?: string; + }> = []; + const token = createSandboxCallbackBridgeToken(18); + const proxyDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-proxy-")); + + const writeRemoteEventToSocket = (event: (typeof pendingRemoteEvents)[number]) => { + if (!socket) return false; + socket.write(jsonLine(event)); + if (event.type === "exit") { + stopping = true; + socket.end(); + } else if (event.type === "error") { + stopping = true; + socket.destroy(); + } + return true; + }; + + const deliverRemoteEvent = (event: (typeof pendingRemoteEvents)[number]) => { + if (socket) { + writeRemoteEventToSocket(event); + return; + } + pendingRemoteEvents.push(event); + if (event.type === "exit" || event.type === "error") { + stopping = true; + } + }; + + const flushPendingRemoteEvents = () => { + if (!socket) return; + while (pendingRemoteEvents.length > 0 && socket) { + const event = pendingRemoteEvents.shift(); + if (event) writeRemoteEventToSocket(event); + } + }; + + const liveSockets = new Set(); + const server = net.createServer((nextSocket) => { + liveSockets.add(nextSocket); + nextSocket.setEncoding("utf8"); + nextSocket.on("error", () => undefined); + let connectionBuffer = ""; + let authenticated = false; + // Connections own the session (and receive buffered process output) only + // after presenting the bridge token; idle unauthenticated peers are dropped. + const authTimer = setTimeout(() => { + if (!authenticated) nextSocket.destroy(); + }, PROCESS_SESSION_AUTH_TIMEOUT_MS); + authTimer.unref?.(); + nextSocket.on("close", () => { + clearTimeout(authTimer); + liveSockets.delete(nextSocket); + }); + nextSocket.on("data", (chunk) => { + connectionBuffer += chunk; + const split = splitJsonLines(connectionBuffer); + connectionBuffer = split.rest; + for (const line of split.lines) { + if (!line.trim()) continue; + let message: { token?: string; type?: string; data?: string }; + try { + message = JSON.parse(line) as { token?: string; type?: string; data?: string }; + } catch { + nextSocket.destroy(); + return; + } + if (message.token !== token) { + nextSocket.destroy(); + return; + } + if (!authenticated) { + if (socket) { + nextSocket.destroy(); + return; + } + authenticated = true; + clearTimeout(authTimer); + socket = nextSocket; + flushPendingRemoteEvents(); + } + void (async () => { + if (message.type === "stdin" && typeof message.data === "string") { + stdinSeq += 1; + const name = `${String(stdinSeq).padStart(12, "0")}.json`; + await client.writeTextFile(path.posix.join(stdinDir, name), jsonLine({ type: "stdin", data: message.data })); + } else if (message.type === "stdinEnd") { + stdinSeq += 1; + const name = `${String(stdinSeq).padStart(12, "0")}.json`; + await client.writeTextFile(path.posix.join(stdinDir, name), jsonLine({ type: "stdinEnd" })); + } + })().catch((error) => { + nextSocket.write(jsonLine({ type: "error", message: error instanceof Error ? error.message : String(error) })); + nextSocket.destroy(); + }); + } + }); + }); + + const poll = async () => { + if (stopping) return; + try { + const events = await readRemoteJsonFiles({ client, dir: eventsDir }); + for (const event of events) { + const parsed = JSON.parse(event.body) as { + type?: string; + stream?: "stdout" | "stderr"; + data?: string; + code?: number | null; + signal?: string | null; + message?: string; + }; + deliverRemoteEvent(parsed); + if (parsed.type === "exit" || parsed.type === "error") return; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await onLog("stderr", `[paperclip] ACP process session bridge poll failed: ${message}\n`); + deliverRemoteEvent({ type: "error", message }); + return; + } finally { + if (!stopping) { + pollTimer = setTimeout(() => void poll(), 100); + pollTimer.unref?.(); + } + } + }; + + const port = await waitForLocalServerListen(server); + const agentCommand = await writeProcessSessionProxyScript(proxyDir, port, token); + pollTimer = setTimeout(() => void poll(), 100); + pollTimer.unref?.(); + + return { + agentCommand, + stop: async () => { + stopping = true; + if (pollTimer) clearTimeout(pollTimer); + for (const liveSocket of liveSockets) liveSocket.destroy(); + await new Promise((resolve) => server.close(() => resolve())).catch(() => undefined); + await client.writeTextFile( + path.posix.join(stdinDir, `${String(stdinSeq + 1).padStart(12, "0")}.json`), + jsonLine({ type: "stdinEnd" }), + ).catch(() => undefined); + await client.remove(sessionDir).catch(() => undefined); + await fs.rm(proxyDir, { recursive: true, force: true }).catch(() => undefined); + }, + }; +} + +function getProcessSessionProxySource(input: { port: number; token: string }): string { + return `#!/usr/bin/env node +import net from "node:net"; + +const socket = net.createConnection({ host: "127.0.0.1", port: ${input.port} }); +const token = ${JSON.stringify(input.token)}; +let buffer = ""; +let exiting = false; + +function send(message) { + socket.write(JSON.stringify({ token, ...message }) + "\\n"); +} + +socket.on("connect", () => send({ type: "hello" })); +process.stdin.on("data", (chunk) => send({ type: "stdin", data: Buffer.from(chunk).toString("base64") })); +process.stdin.on("end", () => send({ type: "stdinEnd" })); +process.stdin.resume(); + +socket.setEncoding("utf8"); +socket.on("data", (chunk) => { + buffer += chunk; + const parts = buffer.split(/\\n/); + buffer = parts.pop() || ""; + for (const line of parts) { + if (!line.trim()) continue; + const message = JSON.parse(line); + if (message.type === "data") { + const out = Buffer.from(message.data || "", "base64"); + (message.stream === "stderr" ? process.stderr : process.stdout).write(out); + } else if (message.type === "error") { + process.stderr.write(String(message.message || "Process session bridge failed.") + "\\n"); + exiting = true; + process.exitCode = 1; + socket.end(); + } else if (message.type === "exit") { + exiting = true; + process.exitCode = typeof message.code === "number" ? message.code : 1; + socket.end(); + } + } +}); +socket.on("close", () => { + if (!exiting) process.exit(1); +}); +`; +} + +function getProcessSessionRemoteSource(): string { + return `import { spawn } from "node:child_process"; +import { promises as fs } from "node:fs"; +import path from "node:path"; + +const sessionDir = process.env.PAPERCLIP_PROCESS_SESSION_DIR; +const commandPayload = process.env.PAPERCLIP_PROCESS_SESSION_COMMAND_B64; +if (!sessionDir || !commandPayload) throw new Error("Missing process session bridge env."); + +const stdinDir = path.posix.join(sessionDir, "stdin"); +const eventsDir = path.posix.join(sessionDir, "events"); +let seq = 0; +let stdinClosed = false; + +const config = JSON.parse(Buffer.from(commandPayload, "base64").toString("utf8")); +await fs.mkdir(stdinDir, { recursive: true }); +await fs.mkdir(eventsDir, { recursive: true }); + +let writeChain = Promise.resolve(); + +function writeEvent(event) { + seq += 1; + const file = path.posix.join(eventsDir, String(seq).padStart(12, "0") + ".json"); + const write = writeChain.then(async () => { + await fs.writeFile(file + ".tmp", JSON.stringify(event) + "\\n", "utf8"); + await fs.rename(file + ".tmp", file); + }); + writeChain = write.catch(() => undefined); + return write; +} + +const child = spawn(config.command, Array.isArray(config.args) ? config.args : [], { + cwd: config.cwd || process.cwd(), + env: { ...process.env, ...(config.env || {}) }, + stdio: ["pipe", "pipe", "pipe"], +}); + +child.stdout.on("data", (chunk) => void writeEvent({ type: "data", stream: "stdout", data: Buffer.from(chunk).toString("base64") })); +child.stderr.on("data", (chunk) => void writeEvent({ type: "data", stream: "stderr", data: Buffer.from(chunk).toString("base64") })); +child.on("error", (error) => void writeEvent({ type: "error", message: error.message })); +// "close" (not "exit") so stdout/stderr fully drain before the exit event; +// the write chain then guarantees the exit file lands after every data file. +child.on("close", (code, signal) => void writeEvent({ type: "exit", code, signal })); + +async function pollStdin() { + while (!stdinClosed) { + const entries = (await fs.readdir(stdinDir).catch(() => [])).filter((name) => name.endsWith(".json")).sort(); + for (const name of entries) { + const file = path.posix.join(stdinDir, name); + const raw = await fs.readFile(file, "utf8").catch(() => null); + await fs.rm(file, { force: true }).catch(() => undefined); + if (!raw) continue; + const message = JSON.parse(raw); + if (message.type === "stdin" && typeof message.data === "string") { + child.stdin.write(Buffer.from(message.data, "base64")); + } else if (message.type === "stdinEnd") { + stdinClosed = true; + child.stdin.end(); + break; + } + } + if (!stdinClosed) await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +void pollStdin().catch((error) => void writeEvent({ type: "error", message: error instanceof Error ? error.message : String(error) })); +`; +} + export async function startAdapterExecutionTargetPaperclipBridge(input: { runId: string; target: AdapterExecutionTarget | null | undefined; diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts index c0ada020ab..d47e1dc038 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts @@ -896,6 +896,9 @@ describe("sandbox callback bridge", () => { { method: "POST", path: "/api/issues/issue-1/release" }, { method: "PATCH", path: "/api/issues/issue-1" }, { method: "GET", path: "/api/issues/issue-1/approvals" }, + { method: "GET", path: "/api/issues/issue-1/work-products" }, + { method: "POST", path: "/api/issues/issue-1/work-products" }, + { method: "PATCH", path: "/api/work-products/wp-1" }, { method: "GET", path: "/api/issues/issue-1/interactions" }, { method: "GET", path: "/api/issues/issue-1/interactions/inter-1" }, { method: "POST", path: "/api/issues/issue-1/interactions" }, @@ -940,6 +943,7 @@ describe("sandbox callback bridge", () => { { method: "POST", path: "/api/companies/co-1/archive" }, { method: "DELETE", path: "/api/issues/issue-1/documents/plan" }, { method: "DELETE", path: "/api/issues/issue-1/approvals/ap-1" }, + { method: "DELETE", path: "/api/work-products/wp-1" }, { method: "POST", path: "/api/approvals/ap-1/approve" }, { method: "POST", path: "/api/approvals/ap-1/reject" }, { method: "POST", path: "/api/companies/co-1/logo" }, diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.ts b/packages/adapter-utils/src/sandbox-callback-bridge.ts index 6531aacfe9..4bc19f08ae 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.ts @@ -67,6 +67,11 @@ export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST: readonly SandboxCa { method: "PATCH", path: /^\/api\/issues\/[^/]+$/ }, { method: "GET", path: /^\/api\/issues\/[^/]+\/approvals$/ }, + // Work products: publish branch/commit/artifact metadata for completed work. + { method: "GET", path: /^\/api\/issues\/[^/]+\/work-products$/ }, + { method: "POST", path: /^\/api\/issues\/[^/]+\/work-products$/ }, + { method: "PATCH", path: /^\/api\/work-products\/[^/]+$/ }, + // Issue-thread interactions (suggest tasks, ask questions, request confirmation) { method: "GET", path: /^\/api\/issues\/[^/]+\/interactions(?:\/[^/]+)?$/ }, { method: "POST", path: /^\/api\/issues\/[^/]+\/interactions$/ }, diff --git a/packages/adapters/claude-local/src/server/acp.test.ts b/packages/adapters/claude-local/src/server/acp.test.ts index 791f0b719c..ba8cb5bc34 100644 --- a/packages/adapters/claude-local/src/server/acp.test.ts +++ b/packages/adapters/claude-local/src/server/acp.test.ts @@ -258,6 +258,79 @@ describe("claude_local ACP lane", () => { ).resolves.toEqual({ engine: "acp", explicit: true }); }); + it("uses ACP for bridged sandbox auto runs when the ACP command is configured as a shell command", async () => { + setNodeVersion("v22.12.0"); + await expect( + resolveClaudeExecutionEngineForRun({ + config: { agentCommand: "claude-agent-acp" }, + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "fake-plugin", + remoteCwd: "/work", + runner: { + execute: async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: "", + stderr: "", + pid: null, + startedAt: new Date().toISOString(), + }), + }, + }, + }), + ).resolves.toEqual({ engine: "acp", explicit: false }); + }); + + it("falls back to the CLI lane for one-shot sandbox auto runs", async () => { + setNodeVersion("v22.12.0"); + await expect( + resolveClaudeExecutionEngineForRun({ + config: {}, + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "fake-plugin", + remoteCwd: "/work", + }, + }), + ).resolves.toMatchObject({ + engine: "cli", + explicit: false, + fallbackReason: expect.stringContaining("bidirectional remote process"), + }); + }); + + it("falls back to the CLI lane for non-sandbox remote auto runs", async () => { + setNodeVersion("v22.12.0"); + await expect( + resolveClaudeExecutionEngineForRun({ + config: {}, + executionTarget: { + kind: "remote", + transport: "ssh", + remoteCwd: "/work", + spec: { + host: "127.0.0.1", + port: 22, + username: "fixture", + remoteCwd: "/work", + remoteWorkspacePath: "/work", + privateKey: null, + knownHosts: null, + strictHostKeyChecking: true, + }, + }, + }), + ).resolves.toMatchObject({ + engine: "cli", + explicit: false, + fallbackReason: expect.stringContaining("sandbox remote targets only"), + }); + }); + it("reports ACP prerequisites for the ACP lane", async () => { const root = await makeTempRoot("paperclip-claude-acp-env-"); const commandPath = path.join(root, "bin", "claude-agent-acp"); diff --git a/packages/adapters/claude-local/src/server/acp.ts b/packages/adapters/claude-local/src/server/acp.ts index 63dcd13254..8b74ddfdef 100644 --- a/packages/adapters/claude-local/src/server/acp.ts +++ b/packages/adapters/claude-local/src/server/acp.ts @@ -8,7 +8,11 @@ import type { AdapterExecutionContext, AdapterExecutionResult, } from "@paperclipai/adapter-utils"; -import { readAdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; +import { + ensureAdapterExecutionTargetCommandResolvable, + readAdapterExecutionTarget, + resolveAdapterExecutionTargetCwd, +} from "@paperclipai/adapter-utils/execution-target"; import { DEFAULT_ACP_ENGINE_MODE, DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS, @@ -179,10 +183,30 @@ async function findAncestorBin(startDir: string, binName: string): Promise { +async function commandIsResolvable( + command: string, + input?: ClaudeEngineResolutionInput, +): Promise { const trimmed = command.trim(); if (!trimmed) return false; if (looksLikeShellCommand(trimmed)) return true; + const target = readAdapterExecutionTarget({ + executionTarget: input?.executionTarget, + legacyRemoteExecution: input?.executionTransport?.remoteExecution, + }); + if (target?.kind === "remote") { + try { + await ensureAdapterExecutionTargetCommandResolvable( + trimmed, + target, + resolveAdapterExecutionTargetCwd(target, asString(input?.config.cwd, ""), process.cwd()), + process.env, + ); + return true; + } catch { + return false; + } + } if (path.isAbsolute(trimmed) || hasPathSeparator(trimmed)) return pathExists(trimmed); return (await findCommandOnPath(trimmed)) !== null; } @@ -197,6 +221,22 @@ async function resolveClaudeAcpCommand(config: Record): Promise ); } +function sandboxTargetHasProcessSessionBridge( + target: ReturnType, +): boolean { + return target?.kind === "remote" && target.transport === "sandbox" && Boolean(target.runner); +} + +async function resolveClaudeAcpCommandForTarget( + config: Record, + target: ReturnType, +): Promise { + const configured = firstNonEmptyString(config.agentCommand, config.acpAgentCommand); + if (configured) return configured; + if (target?.kind === "remote") return "claude-agent-acp"; + return resolveClaudeAcpCommand(config); +} + async function defaultClaudeAcpFallbackReason( input: ClaudeEngineResolutionInput, ): Promise { @@ -204,14 +244,17 @@ async function defaultClaudeAcpFallbackReason( executionTarget: input.executionTarget, legacyRemoteExecution: input.executionTransport?.remoteExecution, }); - if (target?.kind === "remote") { - return "Claude ACP currently supports only the local Paperclip host, but this run targets a remote environment."; + if (target?.kind === "remote" && !sandboxTargetHasProcessSessionBridge(target)) { + if (target.transport === "sandbox") { + return "Claude ACP requires a bidirectional remote process target; this sandbox exposes only one-shot command execution."; + } + return "Claude ACP supports sandbox remote targets only; this run targets a non-sandbox remote environment."; } if (!nodeVersionMeetsClaudeAcpMinimum()) { return `Node ${process.version} does not satisfy Claude ACP's Node >=${MIN_ACP_NODE_VERSION} prerequisite.`; } - const command = await resolveClaudeAcpCommand(input.config); - if (!(await commandIsResolvable(command))) { + const command = await resolveClaudeAcpCommandForTarget(input.config, target); + if (!(await commandIsResolvable(command, input))) { return `Claude ACP server command is not available: ${command}.`; } return null; @@ -244,10 +287,10 @@ export async function testClaudeAcpEnvironment( if (targetIsRemote) { checks.push({ - code: "claude_acp_remote_target_unsupported", - level: "error", - message: "Claude ACP currently runs on the local Paperclip host and cannot target a remote execution environment.", - hint: "Use engine=cli for remote or sandbox Claude runs.", + code: "claude_acp_remote_target", + level: "info", + message: "Claude ACP will run against the remote execution environment.", + hint: "Remote ACP requires a bidirectional process target such as SSH or Paperclip's sandbox process-session bridge.", }); } @@ -279,8 +322,11 @@ export async function testClaudeAcpEnvironment( : `Run Claude ACP with Node >=${MIN_ACP_NODE_VERSION} or switch engine=cli.`, }); - const command = await resolveClaudeAcpCommand(config); - const commandResolvable = await commandIsResolvable(command); + const command = await resolveClaudeAcpCommandForTarget(config, target); + const commandResolvable = await commandIsResolvable(command, { + config, + executionTarget: ctx.executionTarget, + }); checks.push({ code: commandResolvable ? "claude_acp_command_resolvable" : "claude_acp_command_missing", level: commandResolvable ? "info" : "error", diff --git a/packages/adapters/claude-local/src/server/test.probe.test.ts b/packages/adapters/claude-local/src/server/test.probe.test.ts index 130fb22e43..9ff7b5f0e8 100644 --- a/packages/adapters/claude-local/src/server/test.probe.test.ts +++ b/packages/adapters/claude-local/src/server/test.probe.test.ts @@ -88,7 +88,7 @@ describe("claude sandbox hello probe diagnostics", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { command: "claude", model: "claude-opus-4-8" }, + config: { engine: "cli", command: "claude", model: "claude-opus-4-8" }, executionTarget: sandboxTarget, environmentName: "Daytona", }); @@ -114,7 +114,7 @@ describe("claude sandbox hello probe diagnostics", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { command: "claude" }, + config: { engine: "cli", command: "claude" }, executionTarget: sandboxTarget, environmentName: "Daytona", }); @@ -133,7 +133,7 @@ describe("claude sandbox hello probe diagnostics", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { command: "claude" }, + config: { engine: "cli", command: "claude" }, executionTarget: sandboxTarget, environmentName: "Daytona", }); @@ -152,7 +152,7 @@ describe("claude sandbox hello probe diagnostics", () => { const result = await testEnvironment({ companyId: "company-1", adapterType: "claude_local", - config: { command: "claude" }, + config: { engine: "cli", command: "claude" }, executionTarget: sandboxTarget, environmentName: "Daytona", }); diff --git a/packages/adapters/codex-local/src/server/acp.test.ts b/packages/adapters/codex-local/src/server/acp.test.ts index cfb7df5550..100e147400 100644 --- a/packages/adapters/codex-local/src/server/acp.test.ts +++ b/packages/adapters/codex-local/src/server/acp.test.ts @@ -245,6 +245,79 @@ describe("codex_local ACP lane", () => { ).resolves.toEqual({ engine: "acp", explicit: true }); }); + it("uses ACP for bridged sandbox auto runs when the ACP command is configured as a shell command", async () => { + setNodeVersion("v22.13.0"); + await expect( + resolveCodexExecutionEngineForRun({ + config: { agentCommand: "codex-acp" }, + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "fake-plugin", + remoteCwd: "/work", + runner: { + execute: async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: "", + stderr: "", + pid: null, + startedAt: new Date().toISOString(), + }), + }, + }, + }), + ).resolves.toEqual({ engine: "acp", explicit: false }); + }); + + it("falls back to the CLI lane for one-shot sandbox auto runs", async () => { + setNodeVersion("v22.13.0"); + await expect( + resolveCodexExecutionEngineForRun({ + config: {}, + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "fake-plugin", + remoteCwd: "/work", + }, + }), + ).resolves.toMatchObject({ + engine: "cli", + explicit: false, + fallbackReason: expect.stringContaining("bidirectional remote process"), + }); + }); + + it("falls back to the CLI lane for non-sandbox remote auto runs", async () => { + setNodeVersion("v22.13.0"); + await expect( + resolveCodexExecutionEngineForRun({ + config: {}, + executionTarget: { + kind: "remote", + transport: "ssh", + remoteCwd: "/work", + spec: { + host: "127.0.0.1", + port: 22, + username: "fixture", + remoteCwd: "/work", + remoteWorkspacePath: "/work", + privateKey: null, + knownHosts: null, + strictHostKeyChecking: true, + }, + }, + }), + ).resolves.toMatchObject({ + engine: "cli", + explicit: false, + fallbackReason: expect.stringContaining("sandbox remote targets only"), + }); + }); + it("maps Codex config to the ACPX Codex target", () => { expect(buildCodexAcpConfig({ engine: "acp", diff --git a/packages/adapters/codex-local/src/server/acp.ts b/packages/adapters/codex-local/src/server/acp.ts index 8336babae6..6b8ae974ca 100644 --- a/packages/adapters/codex-local/src/server/acp.ts +++ b/packages/adapters/codex-local/src/server/acp.ts @@ -8,7 +8,11 @@ import type { AdapterExecutionContext, AdapterExecutionResult, } from "@paperclipai/adapter-utils"; -import { readAdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; +import { + ensureAdapterExecutionTargetCommandResolvable, + readAdapterExecutionTarget, + resolveAdapterExecutionTargetCwd, +} from "@paperclipai/adapter-utils/execution-target"; import { DEFAULT_ACP_ENGINE_MODE, DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS, @@ -179,10 +183,30 @@ async function findAncestorBin(startDir: string, binName: string): Promise { +async function commandIsResolvable( + command: string, + input?: CodexEngineResolutionInput, +): Promise { const trimmed = command.trim(); if (!trimmed) return false; if (looksLikeShellCommand(trimmed)) return true; + const target = readAdapterExecutionTarget({ + executionTarget: input?.executionTarget, + legacyRemoteExecution: input?.executionTransport?.remoteExecution, + }); + if (target?.kind === "remote") { + try { + await ensureAdapterExecutionTargetCommandResolvable( + trimmed, + target, + resolveAdapterExecutionTargetCwd(target, asString(input?.config.cwd, ""), process.cwd()), + process.env, + ); + return true; + } catch { + return false; + } + } if (path.isAbsolute(trimmed) || hasPathSeparator(trimmed)) return pathExists(trimmed); return (await findCommandOnPath(trimmed)) !== null; } @@ -197,6 +221,22 @@ async function resolveCodexAcpCommand(config: Record): Promise< ); } +function sandboxTargetHasProcessSessionBridge( + target: ReturnType, +): boolean { + return target?.kind === "remote" && target.transport === "sandbox" && Boolean(target.runner); +} + +async function resolveCodexAcpCommandForTarget( + config: Record, + target: ReturnType, +): Promise { + const configured = firstNonEmptyString(config.agentCommand, config.acpAgentCommand); + if (configured) return configured; + if (target?.kind === "remote") return "codex-acp"; + return resolveCodexAcpCommand(config); +} + async function defaultCodexAcpFallbackReason( input: CodexEngineResolutionInput, ): Promise { @@ -204,14 +244,17 @@ async function defaultCodexAcpFallbackReason( executionTarget: input.executionTarget, legacyRemoteExecution: input.executionTransport?.remoteExecution, }); - if (target?.kind === "remote") { - return "Codex ACP currently supports only the local Paperclip host, but this run targets a remote environment."; + if (target?.kind === "remote" && !sandboxTargetHasProcessSessionBridge(target)) { + if (target.transport === "sandbox") { + return "Codex ACP requires a bidirectional remote process target; this sandbox exposes only one-shot command execution."; + } + return "Codex ACP supports sandbox remote targets only; this run targets a non-sandbox remote environment."; } if (!nodeVersionMeetsCodexAcpMinimum()) { return `Node ${process.version} does not satisfy Codex ACP's Node >=${MIN_ACP_NODE_VERSION} prerequisite.`; } - const command = await resolveCodexAcpCommand(input.config); - if (!(await commandIsResolvable(command))) { + const command = await resolveCodexAcpCommandForTarget(input.config, target); + if (!(await commandIsResolvable(command, input))) { return `Codex ACP server command is not available: ${command}.`; } return null; @@ -257,10 +300,10 @@ export async function testCodexAcpEnvironment( if (targetIsRemote) { checks.push({ - code: "codex_acp_remote_target_unsupported", - level: "error", - message: "Codex ACP currently runs on the local Paperclip host and cannot target a remote execution environment.", - hint: "Use engine=cli for remote or sandbox Codex runs.", + code: "codex_acp_remote_target", + level: "info", + message: "Codex ACP will run against the remote execution environment.", + hint: "Remote ACP requires a bidirectional process target such as SSH or Paperclip's sandbox process-session bridge.", }); } @@ -292,8 +335,11 @@ export async function testCodexAcpEnvironment( : `Run Codex ACP with Node >=${MIN_ACP_NODE_VERSION} or switch engine=cli.`, }); - const command = await resolveCodexAcpCommand(config); - const commandResolvable = await commandIsResolvable(command); + const command = await resolveCodexAcpCommandForTarget(config, target); + const commandResolvable = await commandIsResolvable(command, { + config, + executionTarget: ctx.executionTarget, + }); checks.push({ code: commandResolvable ? "codex_acp_command_resolvable" : "codex_acp_command_missing", level: commandResolvable ? "info" : "error", diff --git a/packages/adapters/gemini-local/src/server/acp.test.ts b/packages/adapters/gemini-local/src/server/acp.test.ts index 9f0a7cfdc0..6ec366c6b7 100644 --- a/packages/adapters/gemini-local/src/server/acp.test.ts +++ b/packages/adapters/gemini-local/src/server/acp.test.ts @@ -264,10 +264,11 @@ describe("gemini_local ACP lane", () => { ).resolves.toEqual({ engine: "acp", explicit: true }); }); - it("falls back to the CLI lane for remote auto runs", async () => { + it("falls back to the CLI lane for non-sandbox remote auto runs", async () => { + setNodeVersion("v20.0.0"); await expect( resolveGeminiExecutionEngineForRun({ - config: {}, + config: { agentCommand: "gemini --acp" }, executionTarget: { kind: "remote", transport: "ssh", @@ -287,7 +288,55 @@ describe("gemini_local ACP lane", () => { ).resolves.toMatchObject({ engine: "cli", explicit: false, - fallbackReason: expect.stringContaining("remote environment"), + fallbackReason: expect.stringContaining("sandbox remote targets only"), + }); + }); + + it("falls back to the CLI lane for one-shot sandbox auto runs", async () => { + setNodeVersion("v20.0.0"); + await expect( + resolveGeminiExecutionEngineForRun({ + config: {}, + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "fake-plugin", + remoteCwd: "/work", + }, + }), + ).resolves.toMatchObject({ + engine: "cli", + explicit: false, + fallbackReason: expect.stringContaining("bidirectional remote process"), + }); + }); + + it("uses ACP for bridged sandbox auto runs when the ACP command is configured as a shell command", async () => { + setNodeVersion("v20.0.0"); + await expect( + resolveGeminiExecutionEngineForRun({ + config: { agentCommand: "gemini --acp" }, + executionTarget: { + kind: "remote", + transport: "sandbox", + providerKey: "fake-plugin", + remoteCwd: "/work", + runner: { + execute: async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: "", + stderr: "", + pid: null, + startedAt: new Date().toISOString(), + }), + }, + }, + }), + ).resolves.toEqual({ + engine: "acp", + explicit: false, }); }); diff --git a/packages/adapters/gemini-local/src/server/acp.ts b/packages/adapters/gemini-local/src/server/acp.ts index f357933d67..bdedd2df43 100644 --- a/packages/adapters/gemini-local/src/server/acp.ts +++ b/packages/adapters/gemini-local/src/server/acp.ts @@ -8,7 +8,11 @@ import type { AdapterExecutionContext, AdapterExecutionResult, } from "@paperclipai/adapter-utils"; -import { readAdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; +import { + ensureAdapterExecutionTargetCommandResolvable, + readAdapterExecutionTarget, + resolveAdapterExecutionTargetCwd, +} from "@paperclipai/adapter-utils/execution-target"; import { DEFAULT_ACP_ENGINE_MODE, DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS, @@ -183,9 +187,30 @@ function resolveConfigPath(config: Record): string { : process.env.PATH ?? ""; } -async function commandIsResolvable(command: string, pathValue = process.env.PATH ?? ""): Promise { +async function commandIsResolvable( + command: string, + pathValue = process.env.PATH ?? "", + input?: GeminiEngineResolutionInput, +): Promise { const token = firstShellToken(command); if (!token) return true; + const target = readAdapterExecutionTarget({ + executionTarget: input?.executionTarget, + legacyRemoteExecution: input?.executionTransport?.remoteExecution, + }); + if (target?.kind === "remote") { + try { + await ensureAdapterExecutionTargetCommandResolvable( + token, + target, + resolveAdapterExecutionTargetCwd(target, asString(input?.config.cwd, ""), process.cwd()), + process.env, + ); + return true; + } catch { + return false; + } + } if (path.isAbsolute(token) || hasPathSeparator(token)) return pathExists(token); return (await findCommandOnPath(token, pathValue)) !== null; } @@ -197,6 +222,12 @@ function resolveGeminiAcpCommand(config: Record): string { return `${geminiCommand} --acp`; } +function sandboxTargetHasProcessSessionBridge( + target: ReturnType, +): boolean { + return target?.kind === "remote" && target.transport === "sandbox" && Boolean(target.runner); +} + async function defaultGeminiAcpFallbackReason( input: GeminiEngineResolutionInput, ): Promise { @@ -204,14 +235,17 @@ async function defaultGeminiAcpFallbackReason( executionTarget: input.executionTarget, legacyRemoteExecution: input.executionTransport?.remoteExecution, }); - if (target?.kind === "remote") { - return "Gemini ACP currently supports only the local Paperclip host, but this run targets a remote environment."; + if (target?.kind === "remote" && !sandboxTargetHasProcessSessionBridge(target)) { + if (target.transport === "sandbox") { + return "Gemini ACP requires a bidirectional remote process target; this sandbox exposes only one-shot command execution."; + } + return "Gemini ACP supports sandbox remote targets only; this run targets a non-sandbox remote environment."; } if (!nodeVersionMeetsGeminiAcpMinimum()) { return `Node ${process.version} does not satisfy Gemini ACP's Node >=${MIN_ACP_NODE_VERSION} prerequisite.`; } const command = resolveGeminiAcpCommand(input.config); - if (!(await commandIsResolvable(command, resolveConfigPath(input.config)))) { + if (!(await commandIsResolvable(command, resolveConfigPath(input.config), input))) { return `Gemini ACP command is not available: ${command}.`; } return null; @@ -244,10 +278,10 @@ export async function testGeminiAcpEnvironment( if (targetIsRemote) { checks.push({ - code: "gemini_acp_remote_target_unsupported", - level: "error", - message: "Gemini ACP currently runs on the local Paperclip host and cannot target a remote execution environment.", - hint: "Use engine=cli for remote or sandbox Gemini runs.", + code: "gemini_acp_remote_target", + level: "info", + message: "Gemini ACP will run against the remote execution environment.", + hint: "Remote ACP requires a bidirectional process target such as SSH or Paperclip's sandbox process-session bridge.", }); } @@ -280,7 +314,10 @@ export async function testGeminiAcpEnvironment( }); const command = resolveGeminiAcpCommand(config); - const commandResolvable = await commandIsResolvable(command, resolveConfigPath(config)); + const commandResolvable = await commandIsResolvable(command, resolveConfigPath(config), { + config, + executionTarget: ctx.executionTarget, + }); checks.push({ code: commandResolvable ? "gemini_acp_command_resolvable" : "gemini_acp_command_missing", level: commandResolvable ? "info" : "error", diff --git a/packages/adapters/gemini-local/src/server/execute.remote.test.ts b/packages/adapters/gemini-local/src/server/execute.remote.test.ts index 0fcbcfffd7..2b307ae544 100644 --- a/packages/adapters/gemini-local/src/server/execute.remote.test.ts +++ b/packages/adapters/gemini-local/src/server/execute.remote.test.ts @@ -272,6 +272,9 @@ describe("gemini remote execution", () => { taskKey: null, }, config: { + // Pin the CLI lane: sandbox targets with a runner now default to ACP, + // and this test covers the CLI lane's managed-HOME auth flow. + engine: "cli", command: "gemini", env: { GEMINI_API_KEY: "test-key" }, }, diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.ts index 07e22516ac..aab0b293d3 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.ts @@ -901,7 +901,14 @@ const plugin = definePlugin({ }); return { providerLeaseId: sandbox.id, - metadata: leaseMetadata({ config, sandbox, shellCommand, remoteCwd, resumedLease: false, workspaceSentinel }), + metadata: leaseMetadata({ + config, + sandbox, + shellCommand, + remoteCwd, + resumedLease: false, + workspaceSentinel, + }), }; } catch (error) { await sandbox.delete(toTimeoutSeconds(config.timeoutMs)).catch(() => undefined); @@ -933,7 +940,14 @@ const plugin = definePlugin({ const shellCommand = await detectSandboxShellCommand(sandbox, toTimeoutSeconds(config.timeoutMs)); return { providerLeaseId: sandbox.id, - metadata: leaseMetadata({ config, sandbox, shellCommand, remoteCwd, resumedLease: true, workspaceSentinel }), + metadata: leaseMetadata({ + config, + sandbox, + shellCommand, + remoteCwd, + resumedLease: true, + workspaceSentinel, + }), }; } catch (error) { await sandbox.delete(toTimeoutSeconds(config.timeoutMs)).catch(() => undefined); diff --git a/server/src/__tests__/environment-execution-target.test.ts b/server/src/__tests__/environment-execution-target.test.ts index 6577abed16..a627f105ca 100644 --- a/server/src/__tests__/environment-execution-target.test.ts +++ b/server/src/__tests__/environment-execution-target.test.ts @@ -133,6 +133,49 @@ describe("resolveEnvironmentExecutionTarget", () => { }); }); + it("keeps sandbox targets on callback bridge execution even when lease metadata advertises SSH access", async () => { + mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({ + driver: "sandbox", + config: { + provider: "fake-plugin", + reuseLease: false, + timeoutMs: 30_000, + }, + }); + + const target = await resolveEnvironmentExecutionTarget({ + db: {} as never, + companyId: "company-1", + adapterType: "claude_local", + environment: { + id: "env-1", + driver: "sandbox", + config: { + provider: "fake-plugin", + }, + }, + leaseId: "lease-1", + leaseMetadata: { + remoteCwd: "/home/sandbox/paperclip-workspace", + sshAccess: { + type: "ssh", + host: "ssh.example.test", + port: 22, + username: "paperclip", + }, + }, + lease: null, + environmentRuntime: null, + }); + + expect(target).toMatchObject({ + kind: "remote", + transport: "sandbox", + providerKey: "fake-plugin", + remoteCwd: "/home/sandbox/paperclip-workspace", + }); + }); + it("resolves SSH execution targets in bridge mode", async () => { mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({ driver: "ssh",