From 076067865f9922b7e618344e7c52bcacb39b7b59 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 3 May 2026 12:43:52 -0700 Subject: [PATCH] Migrate SSH environment callback to bridge (#5116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > **Stacked PR (part 3 of 7).** Depends on: - PR #5114 - PR #5115 > Diff against `master` includes commits from earlier PRs in the stack — the new commit in this PR is the topmost one. ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Agents executing on a remote SSH-backed environment need a way to call back into > the Paperclip control plane (run events, log streaming, signals) > - When the SSH host can't reach the Paperclip host (NAT, firewalls, or simply not > on the same network), the run silently fails or hangs — a recurring class of > failure during SSH testing > - In sandboxed environments we already solved this with a callback bridge that > tunnels back through the existing connection; SSH was the odd one out > - This PR migrates SSH execution to use the same callback bridge, so every > adapter's remote run uses one consistent reverse-channel. Per-adapter SSH glue > is deleted in favour of a shared `CommandManagedRuntimeRunner` built from the > SSH spec > - The benefit is fewer SSH-specific failure modes, a smaller code surface, and > one place to evolve the callback contract going forward ## What Changed - Added `createSshCommandManagedRuntimeRunner` in `packages/adapter-utils/src/ssh.ts` that adapts an SSH spec into a generic command-managed-runtime runner (with cwd, env, and timeout handling) - Removed `paperclipApiUrl` from `SshRemoteExecutionSpec`; the bridge URL now flows through the shared runner - Reworked `execution-target.ts` to use the SSH runner alongside sandbox runners via a unified `CommandManagedRuntimeRunner` interface - Simplified `remote-managed-runtime.ts` and `sandbox-managed-runtime.ts` to consume the shared runner abstraction - Deleted per-adapter SSH callback wiring from claude-local, codex-local, cursor-local, gemini-local, opencode-local, pi-local execute.ts files - Removed `environment-runtime-driver-contract.test.ts` (the contract is now enforced by `environment-execution-target.test.ts`) - Added/updated `execute.remote.test.ts` cases for each adapter to cover the SSH runner path ## Verification - `pnpm --filter @paperclipai/adapter-utils test` - `pnpm test -- execute.remote` (covers all six local adapters' SSH paths) - Manual QA: ran a claude-local agent against an SSH-backed environment, confirmed the agent successfully called back to `/api/agent-callback/*` endpoints during the run ## Risks - Refactor touches all six local adapters. If any adapter had subtle SSH-specific behaviour that wasn't captured in tests, it could regress. Mitigation: each adapter's `execute.remote.test.ts` was extended. - `paperclipApiUrl` removal from `SshRemoteExecutionSpec` is a breaking type change for any internal consumer. Verified no external plugins consume this type. - The new `CommandManagedRuntimeRunner` shape is a public surface in `@paperclipai/adapter-utils`; downstream plugins implementing custom runners may need updates, but no such plugins exist in this repo. ## Model Used - OpenAI GPT-5.4 (reasoning effort: high) via Codex CLI - Provider: OpenAI - Used to author the code changes in this PR ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots — N/A - [ ] I have updated relevant documentation to reflect my changes — N/A - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- .../src/command-managed-runtime.ts | 2 - .../src/execution-target-sandbox.test.ts | 31 ++++- .../adapter-utils/src/execution-target.ts | 82 +++++------ .../src/remote-managed-runtime.ts | 4 +- .../src/sandbox-managed-runtime.ts | 6 +- packages/adapter-utils/src/ssh.ts | 131 +++++++++++------- .../src/server/execute.remote.test.ts | 24 +++- .../claude-local/src/server/execute.ts | 6 - .../src/server/execute.remote.test.ts | 22 +++ .../codex-local/src/server/execute.ts | 5 - .../src/server/execute.remote.test.ts | 25 +++- .../cursor-local/src/server/execute.ts | 5 - .../src/server/execute.remote.test.ts | 25 +++- .../gemini-local/src/server/execute.ts | 4 - .../src/server/execute.remote.test.ts | 25 +++- .../opencode-local/src/server/execute.ts | 4 - .../src/server/execute.remote.test.ts | 25 +++- .../adapters/pi-local/src/server/execute.ts | 4 - .../environment-execution-target.test.ts | 57 +++++++- ...nvironment-runtime-driver-contract.test.ts | 37 ----- .../src/__tests__/environment-runtime.test.ts | 28 ---- .../services/environment-execution-target.ts | 14 -- server/src/services/environment-runtime.ts | 24 +--- 23 files changed, 331 insertions(+), 259 deletions(-) diff --git a/packages/adapter-utils/src/command-managed-runtime.ts b/packages/adapter-utils/src/command-managed-runtime.ts index a126ffa4..844fc31d 100644 --- a/packages/adapter-utils/src/command-managed-runtime.ts +++ b/packages/adapter-utils/src/command-managed-runtime.ts @@ -28,7 +28,6 @@ export interface CommandManagedRuntimeSpec { leaseId?: string | null; remoteCwd: string; timeoutMs?: number | null; - paperclipApiUrl?: string | null; } export type CommandManagedRuntimeAsset = SandboxManagedRuntimeAsset; @@ -155,7 +154,6 @@ export async function prepareCommandManagedRuntime(input: { remoteCwd: workspaceRemoteDir, timeoutMs, apiKey: null, - paperclipApiUrl: input.spec.paperclipApiUrl ?? null, }; const client = createCommandManagedRuntimeClient({ runner: input.runner, diff --git a/packages/adapter-utils/src/execution-target-sandbox.test.ts b/packages/adapter-utils/src/execution-target-sandbox.test.ts index 6fe99a7c..e0ebb37c 100644 --- a/packages/adapter-utils/src/execution-target-sandbox.test.ts +++ b/packages/adapter-utils/src/execution-target-sandbox.test.ts @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { adapterExecutionTargetSessionIdentity, adapterExecutionTargetToRemoteSpec, + adapterExecutionTargetUsesPaperclipBridge, runAdapterExecutionTargetProcess, runAdapterExecutionTargetShellCommand, startAdapterExecutionTargetPaperclipBridge, @@ -104,7 +105,6 @@ describe("sandbox adapter execution targets", () => { environmentId: "env-1", leaseId: "lease-1", remoteCwd: "/workspace", - paperclipTransport: "bridge", }); }); @@ -141,6 +141,33 @@ describe("sandbox adapter execution targets", () => { })); }); + it("treats SSH targets as bridge-only", () => { + const target = { + kind: "remote" as const, + transport: "ssh" as const, + remoteCwd: "/workspace", + spec: { + host: "ssh.example.test", + port: 22, + username: "paperclip", + remoteWorkspacePath: "/workspace", + remoteCwd: "/workspace", + privateKey: null, + knownHosts: null, + strictHostKeyChecking: true, + }, + }; + + expect(adapterExecutionTargetUsesPaperclipBridge(target)).toBe(true); + expect(adapterExecutionTargetSessionIdentity(target)).toEqual({ + transport: "ssh", + host: "ssh.example.test", + port: 22, + username: "paperclip", + remoteCwd: "/workspace", + }); + }); + it("uses the provider-declared shell for sandbox helper commands", async () => { const runner = { execute: vi.fn(async () => ({ @@ -210,7 +237,6 @@ describe("sandbox adapter execution targets", () => { environmentId: "env-1", leaseId: "lease-1", remoteCwd, - paperclipTransport: "bridge", runner: createLocalSandboxRunner(), timeoutMs: 30_000, }; @@ -288,7 +314,6 @@ describe("sandbox adapter execution targets", () => { environmentId: "env-1", leaseId: "lease-1", remoteCwd, - paperclipTransport: "bridge", runner: createLocalSandboxRunner(), timeoutMs: 30_000, }; diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index 8fe6ee4b..e0b02730 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -18,7 +18,7 @@ import { startSandboxCallbackBridgeServer, startSandboxCallbackBridgeWorker, } from "./sandbox-callback-bridge.js"; -import { parseSshRemoteExecutionSpec, runSshCommand, shellQuote } from "./ssh.js"; +import { createSshCommandManagedRuntimeRunner, parseSshRemoteExecutionSpec, runSshCommand, shellQuote } from "./ssh.js"; import { ensureCommandResolvable, resolveCommandForLogs, @@ -40,7 +40,6 @@ export interface AdapterSshExecutionTarget { environmentId?: string | null; leaseId?: string | null; remoteCwd: string; - paperclipApiUrl?: string | null; spec: SshRemoteExecutionSpec; } @@ -52,8 +51,6 @@ export interface AdapterSandboxExecutionTarget { environmentId?: string | null; leaseId?: string | null; remoteCwd: string; - paperclipApiUrl?: string | null; - paperclipTransport?: "direct" | "bridge"; timeoutMs?: number | null; runner?: CommandManagedRuntimeRunner; } @@ -128,15 +125,6 @@ function resolveDefaultPaperclipApiUrl(): string { return `http://${runtimeHost}:${runtimePort}`; } -function resolveSandboxPaperclipTransport( - target: Pick, -): "direct" | "bridge" { - if (target.paperclipTransport === "direct" || target.paperclipTransport === "bridge") { - return target.paperclipTransport; - } - return target.paperclipApiUrl ? "direct" : "bridge"; -} - function isAdapterExecutionTargetInstance(value: unknown): value is AdapterExecutionTarget { const parsed = parseObject(value); if (parsed.kind === "local") return true; @@ -182,21 +170,10 @@ export function resolveAdapterExecutionTargetCwd( return adapterExecutionTargetRemoteCwd(target, localFallbackCwd); } -export function adapterExecutionTargetPaperclipApiUrl( - target: AdapterExecutionTarget | null | undefined, -): string | null { - if (target?.kind !== "remote") return null; - if (target.transport === "ssh") return target.paperclipApiUrl ?? target.spec.paperclipApiUrl ?? null; - if (resolveSandboxPaperclipTransport(target) === "bridge") return null; - return target.paperclipApiUrl ?? null; -} - export function adapterExecutionTargetUsesPaperclipBridge( target: AdapterExecutionTarget | null | undefined, ): boolean { - return target?.kind === "remote" && - target.transport === "sandbox" && - resolveSandboxPaperclipTransport(target) === "bridge"; + return target?.kind === "remote"; } export function describeAdapterExecutionTarget( @@ -220,6 +197,29 @@ function preferredSandboxShell(target: AdapterSandboxExecutionTarget): "bash" | return preferredShellForSandbox(target.shellCommand); } +type AdapterCommandCapableExecutionTarget = AdapterSshExecutionTarget | AdapterSandboxExecutionTarget; + +function adapterExecutionTargetCommandRunner(target: AdapterCommandCapableExecutionTarget): CommandManagedRuntimeRunner { + if (target.transport === "ssh") { + return createSshCommandManagedRuntimeRunner({ + spec: target.spec, + defaultCwd: target.remoteCwd, + maxBufferBytes: DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES * 4, + }); + } + return requireSandboxRunner(target); +} + +function adapterExecutionTargetShellCommand(target: AdapterCommandCapableExecutionTarget): "bash" | "sh" { + return target.transport === "ssh" ? "sh" : preferredSandboxShell(target); +} + +function adapterExecutionTargetTimeoutMs( + target: AdapterCommandCapableExecutionTarget, +): number | null | undefined { + return target.transport === "sandbox" ? target.timeoutMs : undefined; +} + export async function ensureAdapterExecutionTargetCommandResolvable( command: string, target: AdapterExecutionTarget | null | undefined, @@ -465,15 +465,12 @@ export function adapterExecutionTargetSessionIdentity( ): Record | null { if (!target || target.kind === "local") return null; if (target.transport === "ssh") return buildRemoteExecutionSessionIdentity(target.spec); - const paperclipTransport = resolveSandboxPaperclipTransport(target); return { transport: "sandbox", providerKey: target.providerKey ?? null, environmentId: target.environmentId ?? null, leaseId: target.leaseId ?? null, remoteCwd: target.remoteCwd, - paperclipTransport, - ...(paperclipTransport === "direct" && target.paperclipApiUrl ? { paperclipApiUrl: target.paperclipApiUrl } : {}), }; } @@ -492,9 +489,7 @@ export function adapterExecutionTargetSessionMatches( readStringMeta(parsedSaved, "providerKey") === current?.providerKey && readStringMeta(parsedSaved, "environmentId") === current?.environmentId && readStringMeta(parsedSaved, "leaseId") === current?.leaseId && - readStringMeta(parsedSaved, "remoteCwd") === current?.remoteCwd && - readStringMeta(parsedSaved, "paperclipTransport") === (current?.paperclipTransport ?? null) && - readStringMeta(parsedSaved, "paperclipApiUrl") === (current?.paperclipApiUrl ?? null) + readStringMeta(parsedSaved, "remoteCwd") === current?.remoteCwd ); } @@ -519,14 +514,12 @@ export function parseAdapterExecutionTarget(value: unknown): AdapterExecutionTar environmentId: readStringMeta(parsed, "environmentId"), leaseId: readStringMeta(parsed, "leaseId"), remoteCwd: spec.remoteCwd, - paperclipApiUrl: readStringMeta(parsed, "paperclipApiUrl") ?? spec.paperclipApiUrl ?? null, spec, }; } if (kind === "remote" && readStringMeta(parsed, "transport") === "sandbox") { const remoteCwd = readStringMeta(parsed, "remoteCwd"); - const paperclipTransport = readStringMeta(parsed, "paperclipTransport"); if (!remoteCwd) return null; return { kind: "remote", @@ -535,11 +528,6 @@ export function parseAdapterExecutionTarget(value: unknown): AdapterExecutionTar environmentId: readStringMeta(parsed, "environmentId"), leaseId: readStringMeta(parsed, "leaseId"), remoteCwd, - paperclipApiUrl: readStringMeta(parsed, "paperclipApiUrl"), - paperclipTransport: - paperclipTransport === "direct" || paperclipTransport === "bridge" - ? paperclipTransport - : undefined, timeoutMs: typeof parsed.timeoutMs === "number" ? parsed.timeoutMs : null, }; } @@ -560,7 +548,6 @@ export function adapterExecutionTargetFromRemoteExecution( environmentId: metadata.environmentId ?? null, leaseId: metadata.leaseId ?? null, remoteCwd: ssh.remoteCwd, - paperclipApiUrl: ssh.paperclipApiUrl ?? null, spec: ssh, }; } @@ -623,7 +610,6 @@ export async function prepareAdapterExecutionTargetRuntime(input: { leaseId: target.leaseId, remoteCwd: target.remoteCwd, timeoutMs: target.timeoutMs, - paperclipApiUrl: target.paperclipApiUrl, }, adapterKey: input.adapterKey, workspaceLocalDir: input.workspaceLocalDir, @@ -711,7 +697,7 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { if (!adapterExecutionTargetUsesPaperclipBridge(input.target)) { return null; } - if (!input.target || input.target.kind !== "remote" || input.target.transport !== "sandbox") { + if (!input.target || input.target.kind !== "remote") { return null; } @@ -739,6 +725,8 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { process.env.PAPERCLIP_RUNTIME_API_URL?.trim() || process.env.PAPERCLIP_API_URL?.trim() || resolveDefaultPaperclipApiUrl(); + const shellCommand = adapterExecutionTargetShellCommand(target); + const runner = adapterExecutionTargetCommandRunner(target); await onLog( "stdout", @@ -750,10 +738,10 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { let worker: Awaited> | null = null; try { const client = createCommandManagedSandboxCallbackBridgeQueueClient({ - runner: requireSandboxRunner(target), + runner, remoteCwd: target.remoteCwd, - timeoutMs: target.timeoutMs, - shellCommand: preferredSandboxShell(target), + timeoutMs: adapterExecutionTargetTimeoutMs(target), + shellCommand, }); worker = await startSandboxCallbackBridgeWorker({ client, @@ -782,15 +770,15 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { }, }); server = await startSandboxCallbackBridgeServer({ - runner: requireSandboxRunner(target), + runner, remoteCwd: target.remoteCwd, assetRemoteDir, queueDir, bridgeToken, bridgeAsset, - timeoutMs: target.timeoutMs, + timeoutMs: adapterExecutionTargetTimeoutMs(target), maxBodyBytes, - shellCommand: preferredSandboxShell(target), + shellCommand, }); } catch (error) { await Promise.allSettled([ diff --git a/packages/adapter-utils/src/remote-managed-runtime.ts b/packages/adapter-utils/src/remote-managed-runtime.ts index 089ff46a..18abdcf9 100644 --- a/packages/adapter-utils/src/remote-managed-runtime.ts +++ b/packages/adapter-utils/src/remote-managed-runtime.ts @@ -44,7 +44,6 @@ export function buildRemoteExecutionSessionIdentity(spec: SshRemoteExecutionSpec port: spec.port, username: spec.username, remoteCwd: spec.remoteCwd, - ...(spec.paperclipApiUrl ? { paperclipApiUrl: spec.paperclipApiUrl } : {}), } as const; } @@ -58,8 +57,7 @@ export function remoteExecutionSessionMatches(saved: unknown, current: SshRemote asString(parsedSaved.host) === currentIdentity.host && asNumber(parsedSaved.port) === currentIdentity.port && asString(parsedSaved.username) === currentIdentity.username && - asString(parsedSaved.remoteCwd) === currentIdentity.remoteCwd && - asString(parsedSaved.paperclipApiUrl) === asString(currentIdentity.paperclipApiUrl) + asString(parsedSaved.remoteCwd) === currentIdentity.remoteCwd ); } diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.ts b/packages/adapter-utils/src/sandbox-managed-runtime.ts index 518cc80a..762c5971 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.ts @@ -13,7 +13,6 @@ export interface SandboxRemoteExecutionSpec { remoteCwd: string; timeoutMs: number; apiKey: string | null; - paperclipApiUrl?: string | null; } export interface SandboxManagedRuntimeAsset { @@ -85,7 +84,6 @@ export function parseSandboxRemoteExecutionSpec(value: unknown): SandboxRemoteEx remoteCwd, timeoutMs, apiKey: asString(parsed.apiKey).trim() || null, - paperclipApiUrl: asString(parsed.paperclipApiUrl).trim() || null, }; } @@ -96,7 +94,6 @@ export function buildSandboxExecutionSessionIdentity(spec: SandboxRemoteExecutio provider: spec.provider, sandboxId: spec.sandboxId, remoteCwd: spec.remoteCwd, - ...(spec.paperclipApiUrl ? { paperclipApiUrl: spec.paperclipApiUrl } : {}), } as const; } @@ -108,8 +105,7 @@ export function sandboxExecutionSessionMatches(saved: unknown, current: SandboxR asString(parsedSaved.transport) === currentIdentity.transport && asString(parsedSaved.provider) === currentIdentity.provider && asString(parsedSaved.sandboxId) === currentIdentity.sandboxId && - asString(parsedSaved.remoteCwd) === currentIdentity.remoteCwd && - asString(parsedSaved.paperclipApiUrl) === asString(currentIdentity.paperclipApiUrl) + asString(parsedSaved.remoteCwd) === currentIdentity.remoteCwd ); } diff --git a/packages/adapter-utils/src/ssh.ts b/packages/adapter-utils/src/ssh.ts index 2e41829c..39087b3e 100644 --- a/packages/adapter-utils/src/ssh.ts +++ b/packages/adapter-utils/src/ssh.ts @@ -3,6 +3,8 @@ import { constants as fsConstants, createReadStream, createWriteStream, promises import net from "node:net"; import os from "node:os"; import path from "node:path"; +import type { CommandManagedRuntimeRunner } from "./command-managed-runtime.js"; +import type { RunProcessResult } from "./server-utils.js"; export interface SshConnectionConfig { host: string; @@ -21,7 +23,86 @@ export interface SshCommandResult { export interface SshRemoteExecutionSpec extends SshConnectionConfig { remoteCwd: string; - paperclipApiUrl?: string | null; +} + +export function createSshCommandManagedRuntimeRunner(input: { + spec: SshRemoteExecutionSpec; + defaultCwd?: string | null; + maxBufferBytes?: number | null; +}): CommandManagedRuntimeRunner { + const defaultCwd = input.defaultCwd?.trim() || input.spec.remoteCwd; + const maxBufferBytes = + typeof input.maxBufferBytes === "number" && Number.isFinite(input.maxBufferBytes) && input.maxBufferBytes > 0 + ? Math.trunc(input.maxBufferBytes) + : 1024 * 1024; + + return { + execute: async (commandInput): Promise => { + const startedAt = new Date().toISOString(); + const command = commandInput.command.trim(); + const args = commandInput.args ?? []; + const cwd = commandInput.cwd?.trim() || defaultCwd; + const envEntries = Object.entries(commandInput.env ?? {}) + .filter((entry): entry is [string, string] => typeof entry[1] === "string"); + const envPrefix = envEntries.length > 0 + ? `env ${envEntries.map(([key, value]) => `${key}=${shellQuote(value)}`).join(" ")} ` + : ""; + const exportPrefix = envEntries.length > 0 + ? envEntries.map(([key, value]) => `export ${key}=${shellQuote(value)};`).join(" ") + " " + : ""; + const commandScript = command === "sh" || command === "bash" + ? args[0] === "-lc" && typeof args[1] === "string" + ? `${exportPrefix}${args[1]}` + : `${envPrefix}exec ${[shellQuote(command), ...args.map((arg) => shellQuote(arg))].join(" ")}` + : `${envPrefix}exec ${[shellQuote(command), ...args.map((arg) => shellQuote(arg))].join(" ")}`; + const remoteCommand = `${command === "bash" ? "bash" : "sh"} -lc ${ + shellQuote(`cd ${shellQuote(cwd)} && ${commandScript}`) + }`; + + try { + const result = await runSshCommand(input.spec, remoteCommand, { + timeoutMs: commandInput.timeoutMs, + maxBuffer: maxBufferBytes, + }); + if (result.stdout) await commandInput.onLog?.("stdout", result.stdout); + if (result.stderr) await commandInput.onLog?.("stderr", result.stderr); + return { + exitCode: 0, + signal: null, + timedOut: false, + stdout: result.stdout, + stderr: result.stderr, + pid: null, + startedAt, + }; + } catch (error) { + const failure = error as { + stdout?: unknown; + stderr?: unknown; + code?: unknown; + signal?: unknown; + killed?: unknown; + }; + const stdout = typeof failure.stdout === "string" ? failure.stdout : ""; + const stderr = typeof failure.stderr === "string" + ? failure.stderr + : error instanceof Error + ? error.message + : String(error); + if (stdout) await commandInput.onLog?.("stdout", stdout); + if (stderr) await commandInput.onLog?.("stderr", stderr); + return { + exitCode: typeof failure.code === "number" ? failure.code : null, + signal: typeof failure.signal === "string" ? failure.signal : null, + timedOut: failure.killed === true, + stdout, + stderr, + pid: null, + startedAt, + }; + } + }, + }; } export interface SshEnvLabSupport { @@ -83,10 +164,6 @@ export function parseSshRemoteExecutionSpec(value: unknown): SshRemoteExecutionS port: portValue, username, remoteCwd, - paperclipApiUrl: - typeof parsed.paperclipApiUrl === "string" && parsed.paperclipApiUrl.trim().length > 0 - ? parsed.paperclipApiUrl.trim() - : null, remoteWorkspacePath: typeof parsed.remoteWorkspacePath === "string" && parsed.remoteWorkspacePath.trim().length > 0 ? parsed.remoteWorkspacePath.trim() @@ -98,50 +175,6 @@ export function parseSshRemoteExecutionSpec(value: unknown): SshRemoteExecutionS }; } -function normalizeHttpUrlCandidate(value: string): string | null { - const trimmed = value.trim(); - if (!trimmed) return null; - try { - const parsed = new URL(trimmed); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - return null; - } - return parsed.origin; - } catch { - return null; - } -} - -export async function findReachablePaperclipApiUrlOverSsh(input: { - config: SshConnectionConfig; - candidates: string[]; - timeoutMs?: number; -}): Promise { - const uniqueCandidates = Array.from( - new Set( - input.candidates - .map((candidate) => normalizeHttpUrlCandidate(candidate)) - .filter((candidate): candidate is string => candidate !== null), - ), - ); - - for (const candidate of uniqueCandidates) { - const healthUrl = new URL("/api/health", candidate).toString(); - try { - await runSshCommand( - input.config, - `sh -lc ${shellQuote(`curl -fsS -m ${Math.max(1, Math.ceil((input.timeoutMs ?? 5_000) / 1000))} ${shellQuote(healthUrl)} >/dev/null`)}`, - { timeoutMs: input.timeoutMs ?? 5_000 }, - ); - return candidate; - } catch { - continue; - } - } - - return null; -} - async function execFileText( file: string, args: string[], diff --git a/packages/adapters/claude-local/src/server/execute.remote.test.ts b/packages/adapters/claude-local/src/server/execute.remote.test.ts index c96b9164..e435ffc3 100644 --- a/packages/adapters/claude-local/src/server/execute.remote.test.ts +++ b/packages/adapters/claude-local/src/server/execute.remote.test.ts @@ -10,6 +10,7 @@ const { prepareWorkspaceForSshExecution, restoreWorkspaceFromSshExecution, syncDirectoryToSsh, + startAdapterExecutionTargetPaperclipBridge, } = vi.hoisted(() => ({ runChildProcess: vi.fn(async () => ({ exitCode: 0, @@ -29,6 +30,14 @@ const { prepareWorkspaceForSshExecution: vi.fn(async () => undefined), restoreWorkspaceFromSshExecution: vi.fn(async () => undefined), syncDirectoryToSsh: vi.fn(async () => undefined), + startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({ + env: { + PAPERCLIP_API_URL: "http://127.0.0.1:4310", + PAPERCLIP_API_KEY: "bridge-token", + PAPERCLIP_API_BRIDGE_MODE: "queue_v1", + }, + stop: async () => {}, + })), })); vi.mock("@paperclipai/adapter-utils/server-utils", async () => { @@ -55,6 +64,16 @@ vi.mock("@paperclipai/adapter-utils/ssh", async () => { }; }); +vi.mock("@paperclipai/adapter-utils/execution-target", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/execution-target", + ); + return { + ...actual, + startAdapterExecutionTargetPaperclipBridge, + }; +}); + import { execute } from "./execute.js"; describe("claude remote execution", () => { @@ -112,7 +131,6 @@ describe("claude remote execution", () => { privateKey: "PRIVATE KEY", knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", strictHostKeyChecking: true, - paperclipApiUrl: "http://198.51.100.10:3102", }, }, onLog: async () => {}, @@ -136,8 +154,10 @@ describe("claude remote execution", () => { expect(call?.[2]).toContain("/remote/workspace/.paperclip-runtime/claude/skills/agent-instructions.md"); expect(call?.[2]).toContain("--add-dir"); expect(call?.[2]).toContain("/remote/workspace/.paperclip-runtime/claude/skills"); - expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://198.51.100.10:3102"); + expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310"); + expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); + expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1); expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledWith(expect.objectContaining({ localDir: workspaceDir, diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index e9ef11e2..804a9c29 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -5,7 +5,6 @@ import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclip import type { RunProcessResult } from "@paperclipai/adapter-utils/server-utils"; import { adapterExecutionTargetIsRemote, - adapterExecutionTargetPaperclipApiUrl, adapterExecutionTargetRemoteCwd, adapterExecutionTargetSessionIdentity, adapterExecutionTargetSessionMatches, @@ -222,11 +221,6 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise ({ runChildProcess: vi.fn(async () => ({ exitCode: 1, @@ -25,6 +26,14 @@ const { prepareWorkspaceForSshExecution: vi.fn(async () => undefined), restoreWorkspaceFromSshExecution: vi.fn(async () => undefined), syncDirectoryToSsh: vi.fn(async () => undefined), + startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({ + env: { + PAPERCLIP_API_URL: "http://127.0.0.1:4310", + PAPERCLIP_API_KEY: "bridge-token", + PAPERCLIP_API_BRIDGE_MODE: "queue_v1", + }, + stop: async () => {}, + })), })); vi.mock("@paperclipai/adapter-utils/server-utils", async () => { @@ -51,6 +60,16 @@ vi.mock("@paperclipai/adapter-utils/ssh", async () => { }; }); +vi.mock("@paperclipai/adapter-utils/execution-target", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/execution-target", + ); + return { + ...actual, + startAdapterExecutionTargetPaperclipBridge, + }; +}); + import { execute } from "./execute.js"; describe("codex remote execution", () => { @@ -134,7 +153,10 @@ describe("codex remote execution", () => { | [string, string, string[], { env: Record; remoteExecution?: { remoteCwd: string } | null }] | undefined; expect(call?.[3].env.CODEX_HOME).toBe("/remote/workspace/.paperclip-runtime/codex/home"); + expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310"); + expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); + expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1); expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledWith(expect.objectContaining({ localDir: workspaceDir, diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index f4ca5c13..e2617e36 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -4,7 +4,6 @@ import { fileURLToPath } from "node:url"; import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils"; import { adapterExecutionTargetIsRemote, - adapterExecutionTargetPaperclipApiUrl, adapterExecutionTargetRemoteCwd, adapterExecutionTargetSessionIdentity, adapterExecutionTargetSessionMatches, @@ -448,10 +447,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise ({ runChildProcess: vi.fn(async () => ({ exitCode: 0, @@ -35,6 +36,14 @@ const { exitCode: 0, })), syncDirectoryToSsh: vi.fn(async () => undefined), + startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({ + env: { + PAPERCLIP_API_URL: "http://127.0.0.1:4310", + PAPERCLIP_API_KEY: "bridge-token", + PAPERCLIP_API_BRIDGE_MODE: "queue_v1", + }, + stop: async () => {}, + })), })); vi.mock("@paperclipai/adapter-utils/server-utils", async () => { @@ -62,6 +71,16 @@ vi.mock("@paperclipai/adapter-utils/ssh", async () => { }; }); +vi.mock("@paperclipai/adapter-utils/execution-target", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/execution-target", + ); + return { + ...actual, + startAdapterExecutionTargetPaperclipBridge, + }; +}); + import { execute } from "./execute.js"; describe("cursor remote execution", () => { @@ -116,7 +135,6 @@ describe("cursor remote execution", () => { privateKey: "PRIVATE KEY", knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", strictHostKeyChecking: true, - paperclipApiUrl: "http://198.51.100.10:3102", }, }, onLog: async () => {}, @@ -131,7 +149,6 @@ describe("cursor remote execution", () => { port: 2222, username: "fixture", remoteCwd: "/remote/workspace", - paperclipApiUrl: "http://198.51.100.10:3102", }, }); expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1); @@ -150,8 +167,10 @@ describe("cursor remote execution", () => { | undefined; expect(call?.[2]).toContain("--workspace"); expect(call?.[2]).toContain("/remote/workspace"); - expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://198.51.100.10:3102"); + expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310"); + expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); + expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1); expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); }); diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index 43de3644..123b75be 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -5,7 +5,6 @@ import { fileURLToPath } from "node:url"; import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils"; import { adapterExecutionTargetIsRemote, - adapterExecutionTargetPaperclipApiUrl, adapterExecutionTargetRemoteCwd, adapterExecutionTargetSessionIdentity, adapterExecutionTargetSessionMatches, @@ -292,10 +291,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) { env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); } - const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget); - if (targetPaperclipApiUrl) { - env.PAPERCLIP_API_URL = targetPaperclipApiUrl; - } for (const [k, v] of Object.entries(envConfig)) { if (typeof v === "string") env[k] = v; } 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 4c97e915..563d56dc 100644 --- a/packages/adapters/gemini-local/src/server/execute.remote.test.ts +++ b/packages/adapters/gemini-local/src/server/execute.remote.test.ts @@ -11,6 +11,7 @@ const { restoreWorkspaceFromSshExecution, runSshCommand, syncDirectoryToSsh, + startAdapterExecutionTargetPaperclipBridge, } = vi.hoisted(() => ({ runChildProcess: vi.fn(async () => ({ exitCode: 0, @@ -41,6 +42,14 @@ const { exitCode: 0, })), syncDirectoryToSsh: vi.fn(async () => undefined), + startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({ + env: { + PAPERCLIP_API_URL: "http://127.0.0.1:4310", + PAPERCLIP_API_KEY: "bridge-token", + PAPERCLIP_API_BRIDGE_MODE: "queue_v1", + }, + stop: async () => {}, + })), })); vi.mock("@paperclipai/adapter-utils/server-utils", async () => { @@ -68,6 +77,16 @@ vi.mock("@paperclipai/adapter-utils/ssh", async () => { }; }); +vi.mock("@paperclipai/adapter-utils/execution-target", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/execution-target", + ); + return { + ...actual, + startAdapterExecutionTargetPaperclipBridge, + }; +}); + import { execute } from "./execute.js"; describe("gemini remote execution", () => { @@ -122,7 +141,6 @@ describe("gemini remote execution", () => { privateKey: "PRIVATE KEY", knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", strictHostKeyChecking: true, - paperclipApiUrl: "http://198.51.100.10:3102", }, }, onLog: async () => {}, @@ -137,7 +155,6 @@ describe("gemini remote execution", () => { port: 2222, username: "fixture", remoteCwd: "/remote/workspace", - paperclipApiUrl: "http://198.51.100.10:3102", }, }); expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1); @@ -154,8 +171,10 @@ describe("gemini remote execution", () => { const call = runChildProcess.mock.calls[0] as unknown as | [string, string, string[], { env: Record; remoteExecution?: { remoteCwd: string } | null }] | undefined; - expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://198.51.100.10:3102"); + expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310"); + expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); + expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1); expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); }); diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts index b9bb581c..4c7e8242 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -6,7 +6,6 @@ import { fileURLToPath } from "node:url"; import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; import { adapterExecutionTargetIsRemote, - adapterExecutionTargetPaperclipApiUrl, adapterExecutionTargetRemoteCwd, adapterExecutionTargetSessionIdentity, adapterExecutionTargetSessionMatches, @@ -252,9 +251,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); - const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget); - if (targetPaperclipApiUrl) env.PAPERCLIP_API_URL = targetPaperclipApiUrl; - for (const [key, value] of Object.entries(envConfig)) { if (typeof value === "string") env[key] = value; } diff --git a/packages/adapters/opencode-local/src/server/execute.remote.test.ts b/packages/adapters/opencode-local/src/server/execute.remote.test.ts index bdf55991..21687730 100644 --- a/packages/adapters/opencode-local/src/server/execute.remote.test.ts +++ b/packages/adapters/opencode-local/src/server/execute.remote.test.ts @@ -11,6 +11,7 @@ const { restoreWorkspaceFromSshExecution, runSshCommand, syncDirectoryToSsh, + startAdapterExecutionTargetPaperclipBridge, } = vi.hoisted(() => ({ runChildProcess: vi.fn(async () => ({ exitCode: 0, @@ -39,6 +40,14 @@ const { exitCode: 0, })), syncDirectoryToSsh: vi.fn(async () => undefined), + startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({ + env: { + PAPERCLIP_API_URL: "http://127.0.0.1:4310", + PAPERCLIP_API_KEY: "bridge-token", + PAPERCLIP_API_BRIDGE_MODE: "queue_v1", + }, + stop: async () => {}, + })), })); vi.mock("@paperclipai/adapter-utils/server-utils", async () => { @@ -66,6 +75,16 @@ vi.mock("@paperclipai/adapter-utils/ssh", async () => { }; }); +vi.mock("@paperclipai/adapter-utils/execution-target", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/execution-target", + ); + return { + ...actual, + startAdapterExecutionTargetPaperclipBridge, + }; +}); + import { execute } from "./execute.js"; describe("opencode remote execution", () => { @@ -121,7 +140,6 @@ describe("opencode remote execution", () => { privateKey: "PRIVATE KEY", knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", strictHostKeyChecking: true, - paperclipApiUrl: "http://198.51.100.10:3102", }, }, onLog: async () => {}, @@ -136,7 +154,6 @@ describe("opencode remote execution", () => { port: 2222, username: "fixture", remoteCwd: "/remote/workspace", - paperclipApiUrl: "http://198.51.100.10:3102", }, }); expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1); @@ -156,9 +173,11 @@ describe("opencode remote execution", () => { const call = runChildProcess.mock.calls[0] as unknown as | [string, string, string[], { env: Record; remoteExecution?: { remoteCwd: string } | null }] | undefined; - expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://198.51.100.10:3102"); + expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310"); + expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); expect(call?.[3].env.XDG_CONFIG_HOME).toBe("/remote/workspace/.paperclip-runtime/opencode/xdgConfig"); expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); + expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1); expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); }); diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 82f62f1d..ca273ac8 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -5,7 +5,6 @@ import { fileURLToPath } from "node:url"; import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils"; import { adapterExecutionTargetIsRemote, - adapterExecutionTargetPaperclipApiUrl, adapterExecutionTargetRemoteCwd, adapterExecutionTargetSessionIdentity, adapterExecutionTargetSessionMatches, @@ -211,9 +210,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); - const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget); - if (targetPaperclipApiUrl) env.PAPERCLIP_API_URL = targetPaperclipApiUrl; - for (const [key, value] of Object.entries(envConfig)) { if (typeof value === "string") env[key] = value; } diff --git a/packages/adapters/pi-local/src/server/execute.remote.test.ts b/packages/adapters/pi-local/src/server/execute.remote.test.ts index 302f4441..c20df55b 100644 --- a/packages/adapters/pi-local/src/server/execute.remote.test.ts +++ b/packages/adapters/pi-local/src/server/execute.remote.test.ts @@ -11,6 +11,7 @@ const { restoreWorkspaceFromSshExecution, runSshCommand, syncDirectoryToSsh, + startAdapterExecutionTargetPaperclipBridge, } = vi.hoisted(() => ({ runChildProcess: vi.fn(async () => ({ exitCode: 0, @@ -44,6 +45,14 @@ const { exitCode: 0, })), syncDirectoryToSsh: vi.fn(async () => undefined), + startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({ + env: { + PAPERCLIP_API_URL: "http://127.0.0.1:4310", + PAPERCLIP_API_KEY: "bridge-token", + PAPERCLIP_API_BRIDGE_MODE: "queue_v1", + }, + stop: async () => {}, + })), })); vi.mock("@paperclipai/adapter-utils/server-utils", async () => { @@ -71,6 +80,16 @@ vi.mock("@paperclipai/adapter-utils/ssh", async () => { }; }); +vi.mock("@paperclipai/adapter-utils/execution-target", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/execution-target", + ); + return { + ...actual, + startAdapterExecutionTargetPaperclipBridge, + }; +}); + import { execute } from "./execute.js"; describe("pi remote execution", () => { @@ -126,7 +145,6 @@ describe("pi remote execution", () => { privateKey: "PRIVATE KEY", knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", strictHostKeyChecking: true, - paperclipApiUrl: "http://198.51.100.10:3102", }, }, onLog: async () => {}, @@ -140,7 +158,6 @@ describe("pi remote execution", () => { port: 2222, username: "fixture", remoteCwd: "/remote/workspace", - paperclipApiUrl: "http://198.51.100.10:3102", }, }); expect(String(result.sessionId)).toContain("/remote/workspace/.paperclip-runtime/pi/sessions/"); @@ -161,8 +178,10 @@ describe("pi remote execution", () => { expect(call?.[2]).toContain("--session"); expect(call?.[2]).toContain("--skill"); expect(call?.[2]).toContain("/remote/workspace/.paperclip-runtime/pi/skills"); - expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://198.51.100.10:3102"); + expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310"); + expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); + expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1); expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); }); diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index ef7f654b..827eccf7 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -5,7 +5,6 @@ import { fileURLToPath } from "node:url"; import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils"; import { adapterExecutionTargetIsRemote, - adapterExecutionTargetPaperclipApiUrl, adapterExecutionTargetRemoteCwd, adapterExecutionTargetSessionIdentity, adapterExecutionTargetSessionMatches, @@ -240,9 +239,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); - const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget); - if (targetPaperclipApiUrl) env.PAPERCLIP_API_URL = targetPaperclipApiUrl; - for (const [key, value] of Object.entries(envConfig)) { if (typeof value === "string") env[key] = value; } diff --git a/server/src/__tests__/environment-execution-target.test.ts b/server/src/__tests__/environment-execution-target.test.ts index a1a5ab1d..6577abed 100644 --- a/server/src/__tests__/environment-execution-target.test.ts +++ b/server/src/__tests__/environment-execution-target.test.ts @@ -54,14 +54,11 @@ describe("resolveEnvironmentExecutionTarget", () => { remoteCwd: DEFAULT_SANDBOX_REMOTE_CWD, leaseId: "lease-1", environmentId: "env-1", - paperclipTransport: "bridge", timeoutMs: 30_000, }); }); - it("prefers an explicit Paperclip API URL from lease metadata for sandbox targets", async () => { - process.env.PAPERCLIP_API_URL = "https://paperclip.example.test"; - process.env.PAPERCLIP_RUNTIME_API_URL = "http://paperclip.example.test:3200"; + it("keeps sandbox targets on bridge mode even when lease metadata includes a Paperclip API URL", async () => { mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({ driver: "sandbox", config: { @@ -93,9 +90,11 @@ describe("resolveEnvironmentExecutionTarget", () => { expect(target).toMatchObject({ kind: "remote", transport: "sandbox", - paperclipApiUrl: "https://paperclip.example.test", - paperclipTransport: "direct", + providerKey: "fake-plugin", + remoteCwd: DEFAULT_SANDBOX_REMOTE_CWD, }); + expect(target).not.toHaveProperty("paperclipApiUrl"); + expect(target).not.toHaveProperty("paperclipTransport"); }); it("passes through a provider-declared sandbox shell command from lease metadata", async () => { @@ -133,4 +132,50 @@ describe("resolveEnvironmentExecutionTarget", () => { shellCommand: "bash", }); }); + + it("resolves SSH execution targets in bridge mode", async () => { + mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({ + driver: "ssh", + config: { + host: "ssh.example.test", + port: 22, + username: "paperclip", + remoteWorkspacePath: "/srv/paperclip", + privateKey: "PRIVATE KEY", + knownHosts: "[ssh.example.test]:22 ssh-ed25519 AAAA", + strictHostKeyChecking: true, + }, + }); + + const target = await resolveEnvironmentExecutionTarget({ + db: {} as never, + companyId: "company-1", + adapterType: "codex_local", + environment: { + id: "env-ssh-1", + driver: "ssh", + config: {}, + }, + leaseId: "lease-ssh-1", + leaseMetadata: {}, + lease: null, + environmentRuntime: null, + }); + + expect(target).toMatchObject({ + kind: "remote", + transport: "ssh", + remoteCwd: "/srv/paperclip", + leaseId: "lease-ssh-1", + environmentId: "env-ssh-1", + spec: { + host: "ssh.example.test", + port: 22, + username: "paperclip", + remoteWorkspacePath: "/srv/paperclip", + remoteCwd: "/srv/paperclip", + }, + }); + expect(target).not.toHaveProperty("paperclipApiUrl"); + }); }); diff --git a/server/src/__tests__/environment-runtime-driver-contract.test.ts b/server/src/__tests__/environment-runtime-driver-contract.test.ts index 482368ec..067c040d 100644 --- a/server/src/__tests__/environment-runtime-driver-contract.test.ts +++ b/server/src/__tests__/environment-runtime-driver-contract.test.ts @@ -1,5 +1,4 @@ import { randomUUID } from "node:crypto"; -import { createServer, type Server } from "node:http"; import { mkdtemp, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -55,7 +54,6 @@ describeEmbeddedPostgres("environment runtime driver contract", () => { let stopDb: (() => Promise) | null = null; let db!: ReturnType; const fixtureRoots: string[] = []; - const servers: Server[] = []; beforeAll(async () => { const started = await startEmbeddedPostgresTestDatabase("environment-runtime-contract"); @@ -64,9 +62,6 @@ describeEmbeddedPostgres("environment runtime driver contract", () => { }); afterEach(async () => { - for (const server of servers.splice(0)) { - await new Promise((resolve) => server.close(() => resolve())); - } while (fixtureRoots.length > 0) { const root = fixtureRoots.pop(); if (!root) continue; @@ -172,27 +167,6 @@ describeEmbeddedPostgres("environment runtime driver contract", () => { }; } - async function startHealthServer() { - const server = createServer((req, res) => { - if (req.url === "/api/health") { - res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify({ status: "ok" })); - return; - } - res.writeHead(404).end(); - }); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => resolve()); - }); - servers.push(server); - const address = server.address(); - if (!address || typeof address === "string") { - throw new Error("Expected health server to listen on a TCP port."); - } - return `http://127.0.0.1:${address.port}`; - } - async function runContract(testCase: RuntimeContractCase) { const cleanup = await testCase.setup?.(); try { @@ -288,9 +262,6 @@ describeEmbeddedPostgres("environment runtime driver contract", () => { fixtureRoots.push(fixtureRoot); const fixture = await startSshEnvLabFixture({ statePath: path.join(fixtureRoot, "state.json") }); const sshConfig = await buildSshEnvLabFixtureConfig(fixture); - const runtimeApiUrl = await startHealthServer(); - const previousCandidates = process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON; - process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = JSON.stringify([runtimeApiUrl]); await runContract({ name: "ssh", @@ -304,16 +275,8 @@ describeEmbeddedPostgres("environment runtime driver contract", () => { username: sshConfig.username, remoteWorkspacePath: sshConfig.remoteWorkspacePath, remoteCwd: sshConfig.remoteWorkspacePath, - paperclipApiUrl: runtimeApiUrl, }); }, - setup: async () => async () => { - if (previousCandidates === undefined) { - delete process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON; - } else { - process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = previousCandidates; - } - }, }); }); }); diff --git a/server/src/__tests__/environment-runtime.test.ts b/server/src/__tests__/environment-runtime.test.ts index dd9595b9..ffda21c6 100644 --- a/server/src/__tests__/environment-runtime.test.ts +++ b/server/src/__tests__/environment-runtime.test.ts @@ -1,5 +1,4 @@ import { randomUUID } from "node:crypto"; -import { createServer } from "node:http"; import { mkdtemp, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -329,26 +328,6 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { const statePath = path.join(fixtureRoot, "state.json"); const fixture = await startSshEnvLabFixture({ statePath }); const sshConfig = await buildSshEnvLabFixtureConfig(fixture); - const healthServer = createServer((req, res) => { - if (req.url === "/api/health") { - res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify({ status: "ok" })); - return; - } - res.writeHead(404).end(); - }); - await new Promise((resolve, reject) => { - healthServer.once("error", reject); - healthServer.listen(0, "127.0.0.1", () => resolve()); - }); - const address = healthServer.address(); - if (!address || typeof address === "string") { - await new Promise((resolve) => healthServer.close(() => resolve())); - throw new Error("Expected the test health server to listen on a TCP port."); - } - const runtimeApiUrl = `http://127.0.0.1:${address.port}`; - const previousCandidates = process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON; - process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = JSON.stringify([runtimeApiUrl]); const { companyId, environment, runId } = await seedEnvironment({ driver: "ssh", name: "Fixture SSH", @@ -372,7 +351,6 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { username: sshConfig.username, remoteWorkspacePath: sshConfig.remoteWorkspacePath, remoteCwd: sshConfig.remoteWorkspacePath, - paperclipApiUrl: runtimeApiUrl, }); const released = await runtime.releaseRunLeases(runId); @@ -381,12 +359,6 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { expect(released[0]?.environment.driver).toBe("ssh"); expect(released[0]?.lease.status).toBe("released"); } finally { - if (previousCandidates === undefined) { - delete process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON; - } else { - process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = previousCandidates; - } - await new Promise((resolve) => healthServer.close(() => resolve())); } }); diff --git a/server/src/services/environment-execution-target.ts b/server/src/services/environment-execution-target.ts index c2f2e4fb..2a506d41 100644 --- a/server/src/services/environment-execution-target.ts +++ b/server/src/services/environment-execution-target.ts @@ -58,10 +58,6 @@ export async function resolveEnvironmentExecutionTarget(input: { ? input.leaseMetadata.remoteCwd.trim() : DEFAULT_SANDBOX_REMOTE_CWD; const timeoutMs = "timeoutMs" in parsed.config ? parsed.config.timeoutMs : null; - const paperclipApiUrl = - typeof input.leaseMetadata?.paperclipApiUrl === "string" && input.leaseMetadata.paperclipApiUrl.trim().length > 0 - ? input.leaseMetadata.paperclipApiUrl.trim() - : null; const shellCommand = input.leaseMetadata?.shellCommand === "bash" || input.leaseMetadata?.shellCommand === "sh" ? input.leaseMetadata.shellCommand @@ -75,8 +71,6 @@ export async function resolveEnvironmentExecutionTarget(input: { remoteCwd, environmentId: input.environment.id ?? null, leaseId: input.leaseId ?? null, - paperclipApiUrl, - paperclipTransport: paperclipApiUrl ? "direct" : "bridge", timeoutMs, runner: input.environmentRuntime && input.lease ? { @@ -143,10 +137,6 @@ export async function resolveEnvironmentExecutionTarget(input: { environmentId: input.environment.id ?? null, leaseId: input.leaseId ?? null, remoteCwd, - paperclipApiUrl: - typeof input.leaseMetadata?.paperclipApiUrl === "string" && input.leaseMetadata.paperclipApiUrl.trim().length > 0 - ? input.leaseMetadata.paperclipApiUrl.trim() - : null, spec: { host: parsed.config.host, port: parsed.config.port, @@ -156,10 +146,6 @@ export async function resolveEnvironmentExecutionTarget(input: { knownHosts: parsed.config.knownHosts, strictHostKeyChecking: parsed.config.strictHostKeyChecking, remoteCwd, - paperclipApiUrl: - typeof input.leaseMetadata?.paperclipApiUrl === "string" && input.leaseMetadata.paperclipApiUrl.trim().length > 0 - ? input.leaseMetadata.paperclipApiUrl.trim() - : null, }, }; } diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index d83244ab..2e587b4a 100644 --- a/server/src/services/environment-runtime.ts +++ b/server/src/services/environment-runtime.ts @@ -14,7 +14,7 @@ import type { PluginEnvironmentLease, PluginEnvironmentRealizeWorkspaceResult, } from "@paperclipai/plugin-sdk"; -import { ensureSshWorkspaceReady, findReachablePaperclipApiUrlOverSsh } from "@paperclipai/adapter-utils/ssh"; +import { ensureSshWorkspaceReady } from "@paperclipai/adapter-utils/ssh"; import { environmentService } from "./environments.js"; import { parseEnvironmentDriverConfig, @@ -227,27 +227,6 @@ function createSshEnvironmentDriver(db: Db): EnvironmentRuntimeDriver { } const { remoteCwd } = await ensureSshWorkspaceReady(parsed.config); - const candidateUrls = (() => { - const raw = process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON; - if (!raw) return []; - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) - ? parsed.filter((value): value is string => typeof value === "string" && value.trim().length > 0) - : []; - } catch { - return []; - } - })(); - const paperclipApiUrl = await findReachablePaperclipApiUrlOverSsh({ - config: parsed.config, - candidates: candidateUrls, - }); - if (!paperclipApiUrl) { - throw new Error( - `SSH environment ${parsed.config.username}@${parsed.config.host} could not reach any Paperclip API candidates.`, - ); - } return await environmentsSvc.acquireLease({ companyId: input.companyId, environmentId: input.environment.id, @@ -265,7 +244,6 @@ function createSshEnvironmentDriver(db: Db): EnvironmentRuntimeDriver { username: parsed.config.username, remoteWorkspacePath: parsed.config.remoteWorkspacePath, remoteCwd, - paperclipApiUrl, }, }); },