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