Migrate SSH environment callback to bridge (#5116)

> **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
This commit is contained in:
Devin Foley 2026-05-03 12:43:52 -07:00 committed by GitHub
parent a7b45938b7
commit 076067865f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 331 additions and 259 deletions

View file

@ -28,7 +28,6 @@ export interface CommandManagedRuntimeSpec {
leaseId?: string | null; leaseId?: string | null;
remoteCwd: string; remoteCwd: string;
timeoutMs?: number | null; timeoutMs?: number | null;
paperclipApiUrl?: string | null;
} }
export type CommandManagedRuntimeAsset = SandboxManagedRuntimeAsset; export type CommandManagedRuntimeAsset = SandboxManagedRuntimeAsset;
@ -155,7 +154,6 @@ export async function prepareCommandManagedRuntime(input: {
remoteCwd: workspaceRemoteDir, remoteCwd: workspaceRemoteDir,
timeoutMs, timeoutMs,
apiKey: null, apiKey: null,
paperclipApiUrl: input.spec.paperclipApiUrl ?? null,
}; };
const client = createCommandManagedRuntimeClient({ const client = createCommandManagedRuntimeClient({
runner: input.runner, runner: input.runner,

View file

@ -7,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { import {
adapterExecutionTargetSessionIdentity, adapterExecutionTargetSessionIdentity,
adapterExecutionTargetToRemoteSpec, adapterExecutionTargetToRemoteSpec,
adapterExecutionTargetUsesPaperclipBridge,
runAdapterExecutionTargetProcess, runAdapterExecutionTargetProcess,
runAdapterExecutionTargetShellCommand, runAdapterExecutionTargetShellCommand,
startAdapterExecutionTargetPaperclipBridge, startAdapterExecutionTargetPaperclipBridge,
@ -104,7 +105,6 @@ describe("sandbox adapter execution targets", () => {
environmentId: "env-1", environmentId: "env-1",
leaseId: "lease-1", leaseId: "lease-1",
remoteCwd: "/workspace", 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 () => { it("uses the provider-declared shell for sandbox helper commands", async () => {
const runner = { const runner = {
execute: vi.fn(async () => ({ execute: vi.fn(async () => ({
@ -210,7 +237,6 @@ describe("sandbox adapter execution targets", () => {
environmentId: "env-1", environmentId: "env-1",
leaseId: "lease-1", leaseId: "lease-1",
remoteCwd, remoteCwd,
paperclipTransport: "bridge",
runner: createLocalSandboxRunner(), runner: createLocalSandboxRunner(),
timeoutMs: 30_000, timeoutMs: 30_000,
}; };
@ -288,7 +314,6 @@ describe("sandbox adapter execution targets", () => {
environmentId: "env-1", environmentId: "env-1",
leaseId: "lease-1", leaseId: "lease-1",
remoteCwd, remoteCwd,
paperclipTransport: "bridge",
runner: createLocalSandboxRunner(), runner: createLocalSandboxRunner(),
timeoutMs: 30_000, timeoutMs: 30_000,
}; };

View file

@ -18,7 +18,7 @@ import {
startSandboxCallbackBridgeServer, startSandboxCallbackBridgeServer,
startSandboxCallbackBridgeWorker, startSandboxCallbackBridgeWorker,
} from "./sandbox-callback-bridge.js"; } from "./sandbox-callback-bridge.js";
import { parseSshRemoteExecutionSpec, runSshCommand, shellQuote } from "./ssh.js"; import { createSshCommandManagedRuntimeRunner, parseSshRemoteExecutionSpec, runSshCommand, shellQuote } from "./ssh.js";
import { import {
ensureCommandResolvable, ensureCommandResolvable,
resolveCommandForLogs, resolveCommandForLogs,
@ -40,7 +40,6 @@ export interface AdapterSshExecutionTarget {
environmentId?: string | null; environmentId?: string | null;
leaseId?: string | null; leaseId?: string | null;
remoteCwd: string; remoteCwd: string;
paperclipApiUrl?: string | null;
spec: SshRemoteExecutionSpec; spec: SshRemoteExecutionSpec;
} }
@ -52,8 +51,6 @@ export interface AdapterSandboxExecutionTarget {
environmentId?: string | null; environmentId?: string | null;
leaseId?: string | null; leaseId?: string | null;
remoteCwd: string; remoteCwd: string;
paperclipApiUrl?: string | null;
paperclipTransport?: "direct" | "bridge";
timeoutMs?: number | null; timeoutMs?: number | null;
runner?: CommandManagedRuntimeRunner; runner?: CommandManagedRuntimeRunner;
} }
@ -128,15 +125,6 @@ function resolveDefaultPaperclipApiUrl(): string {
return `http://${runtimeHost}:${runtimePort}`; return `http://${runtimeHost}:${runtimePort}`;
} }
function resolveSandboxPaperclipTransport(
target: Pick<AdapterSandboxExecutionTarget, "paperclipTransport" | "paperclipApiUrl">,
): "direct" | "bridge" {
if (target.paperclipTransport === "direct" || target.paperclipTransport === "bridge") {
return target.paperclipTransport;
}
return target.paperclipApiUrl ? "direct" : "bridge";
}
function isAdapterExecutionTargetInstance(value: unknown): value is AdapterExecutionTarget { function isAdapterExecutionTargetInstance(value: unknown): value is AdapterExecutionTarget {
const parsed = parseObject(value); const parsed = parseObject(value);
if (parsed.kind === "local") return true; if (parsed.kind === "local") return true;
@ -182,21 +170,10 @@ export function resolveAdapterExecutionTargetCwd(
return adapterExecutionTargetRemoteCwd(target, localFallbackCwd); 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( export function adapterExecutionTargetUsesPaperclipBridge(
target: AdapterExecutionTarget | null | undefined, target: AdapterExecutionTarget | null | undefined,
): boolean { ): boolean {
return target?.kind === "remote" && return target?.kind === "remote";
target.transport === "sandbox" &&
resolveSandboxPaperclipTransport(target) === "bridge";
} }
export function describeAdapterExecutionTarget( export function describeAdapterExecutionTarget(
@ -220,6 +197,29 @@ function preferredSandboxShell(target: AdapterSandboxExecutionTarget): "bash" |
return preferredShellForSandbox(target.shellCommand); 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( export async function ensureAdapterExecutionTargetCommandResolvable(
command: string, command: string,
target: AdapterExecutionTarget | null | undefined, target: AdapterExecutionTarget | null | undefined,
@ -465,15 +465,12 @@ export function adapterExecutionTargetSessionIdentity(
): Record<string, unknown> | null { ): Record<string, unknown> | null {
if (!target || target.kind === "local") return null; if (!target || target.kind === "local") return null;
if (target.transport === "ssh") return buildRemoteExecutionSessionIdentity(target.spec); if (target.transport === "ssh") return buildRemoteExecutionSessionIdentity(target.spec);
const paperclipTransport = resolveSandboxPaperclipTransport(target);
return { return {
transport: "sandbox", transport: "sandbox",
providerKey: target.providerKey ?? null, providerKey: target.providerKey ?? null,
environmentId: target.environmentId ?? null, environmentId: target.environmentId ?? null,
leaseId: target.leaseId ?? null, leaseId: target.leaseId ?? null,
remoteCwd: target.remoteCwd, 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, "providerKey") === current?.providerKey &&
readStringMeta(parsedSaved, "environmentId") === current?.environmentId && readStringMeta(parsedSaved, "environmentId") === current?.environmentId &&
readStringMeta(parsedSaved, "leaseId") === current?.leaseId && readStringMeta(parsedSaved, "leaseId") === current?.leaseId &&
readStringMeta(parsedSaved, "remoteCwd") === current?.remoteCwd && readStringMeta(parsedSaved, "remoteCwd") === current?.remoteCwd
readStringMeta(parsedSaved, "paperclipTransport") === (current?.paperclipTransport ?? null) &&
readStringMeta(parsedSaved, "paperclipApiUrl") === (current?.paperclipApiUrl ?? null)
); );
} }
@ -519,14 +514,12 @@ export function parseAdapterExecutionTarget(value: unknown): AdapterExecutionTar
environmentId: readStringMeta(parsed, "environmentId"), environmentId: readStringMeta(parsed, "environmentId"),
leaseId: readStringMeta(parsed, "leaseId"), leaseId: readStringMeta(parsed, "leaseId"),
remoteCwd: spec.remoteCwd, remoteCwd: spec.remoteCwd,
paperclipApiUrl: readStringMeta(parsed, "paperclipApiUrl") ?? spec.paperclipApiUrl ?? null,
spec, spec,
}; };
} }
if (kind === "remote" && readStringMeta(parsed, "transport") === "sandbox") { if (kind === "remote" && readStringMeta(parsed, "transport") === "sandbox") {
const remoteCwd = readStringMeta(parsed, "remoteCwd"); const remoteCwd = readStringMeta(parsed, "remoteCwd");
const paperclipTransport = readStringMeta(parsed, "paperclipTransport");
if (!remoteCwd) return null; if (!remoteCwd) return null;
return { return {
kind: "remote", kind: "remote",
@ -535,11 +528,6 @@ export function parseAdapterExecutionTarget(value: unknown): AdapterExecutionTar
environmentId: readStringMeta(parsed, "environmentId"), environmentId: readStringMeta(parsed, "environmentId"),
leaseId: readStringMeta(parsed, "leaseId"), leaseId: readStringMeta(parsed, "leaseId"),
remoteCwd, remoteCwd,
paperclipApiUrl: readStringMeta(parsed, "paperclipApiUrl"),
paperclipTransport:
paperclipTransport === "direct" || paperclipTransport === "bridge"
? paperclipTransport
: undefined,
timeoutMs: typeof parsed.timeoutMs === "number" ? parsed.timeoutMs : null, timeoutMs: typeof parsed.timeoutMs === "number" ? parsed.timeoutMs : null,
}; };
} }
@ -560,7 +548,6 @@ export function adapterExecutionTargetFromRemoteExecution(
environmentId: metadata.environmentId ?? null, environmentId: metadata.environmentId ?? null,
leaseId: metadata.leaseId ?? null, leaseId: metadata.leaseId ?? null,
remoteCwd: ssh.remoteCwd, remoteCwd: ssh.remoteCwd,
paperclipApiUrl: ssh.paperclipApiUrl ?? null,
spec: ssh, spec: ssh,
}; };
} }
@ -623,7 +610,6 @@ export async function prepareAdapterExecutionTargetRuntime(input: {
leaseId: target.leaseId, leaseId: target.leaseId,
remoteCwd: target.remoteCwd, remoteCwd: target.remoteCwd,
timeoutMs: target.timeoutMs, timeoutMs: target.timeoutMs,
paperclipApiUrl: target.paperclipApiUrl,
}, },
adapterKey: input.adapterKey, adapterKey: input.adapterKey,
workspaceLocalDir: input.workspaceLocalDir, workspaceLocalDir: input.workspaceLocalDir,
@ -711,7 +697,7 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
if (!adapterExecutionTargetUsesPaperclipBridge(input.target)) { if (!adapterExecutionTargetUsesPaperclipBridge(input.target)) {
return null; return null;
} }
if (!input.target || input.target.kind !== "remote" || input.target.transport !== "sandbox") { if (!input.target || input.target.kind !== "remote") {
return null; return null;
} }
@ -739,6 +725,8 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
process.env.PAPERCLIP_RUNTIME_API_URL?.trim() || process.env.PAPERCLIP_RUNTIME_API_URL?.trim() ||
process.env.PAPERCLIP_API_URL?.trim() || process.env.PAPERCLIP_API_URL?.trim() ||
resolveDefaultPaperclipApiUrl(); resolveDefaultPaperclipApiUrl();
const shellCommand = adapterExecutionTargetShellCommand(target);
const runner = adapterExecutionTargetCommandRunner(target);
await onLog( await onLog(
"stdout", "stdout",
@ -750,10 +738,10 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
let worker: Awaited<ReturnType<typeof startSandboxCallbackBridgeWorker>> | null = null; let worker: Awaited<ReturnType<typeof startSandboxCallbackBridgeWorker>> | null = null;
try { try {
const client = createCommandManagedSandboxCallbackBridgeQueueClient({ const client = createCommandManagedSandboxCallbackBridgeQueueClient({
runner: requireSandboxRunner(target), runner,
remoteCwd: target.remoteCwd, remoteCwd: target.remoteCwd,
timeoutMs: target.timeoutMs, timeoutMs: adapterExecutionTargetTimeoutMs(target),
shellCommand: preferredSandboxShell(target), shellCommand,
}); });
worker = await startSandboxCallbackBridgeWorker({ worker = await startSandboxCallbackBridgeWorker({
client, client,
@ -782,15 +770,15 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
}, },
}); });
server = await startSandboxCallbackBridgeServer({ server = await startSandboxCallbackBridgeServer({
runner: requireSandboxRunner(target), runner,
remoteCwd: target.remoteCwd, remoteCwd: target.remoteCwd,
assetRemoteDir, assetRemoteDir,
queueDir, queueDir,
bridgeToken, bridgeToken,
bridgeAsset, bridgeAsset,
timeoutMs: target.timeoutMs, timeoutMs: adapterExecutionTargetTimeoutMs(target),
maxBodyBytes, maxBodyBytes,
shellCommand: preferredSandboxShell(target), shellCommand,
}); });
} catch (error) { } catch (error) {
await Promise.allSettled([ await Promise.allSettled([

View file

@ -44,7 +44,6 @@ export function buildRemoteExecutionSessionIdentity(spec: SshRemoteExecutionSpec
port: spec.port, port: spec.port,
username: spec.username, username: spec.username,
remoteCwd: spec.remoteCwd, remoteCwd: spec.remoteCwd,
...(spec.paperclipApiUrl ? { paperclipApiUrl: spec.paperclipApiUrl } : {}),
} as const; } as const;
} }
@ -58,8 +57,7 @@ export function remoteExecutionSessionMatches(saved: unknown, current: SshRemote
asString(parsedSaved.host) === currentIdentity.host && asString(parsedSaved.host) === currentIdentity.host &&
asNumber(parsedSaved.port) === currentIdentity.port && asNumber(parsedSaved.port) === currentIdentity.port &&
asString(parsedSaved.username) === currentIdentity.username && asString(parsedSaved.username) === currentIdentity.username &&
asString(parsedSaved.remoteCwd) === currentIdentity.remoteCwd && asString(parsedSaved.remoteCwd) === currentIdentity.remoteCwd
asString(parsedSaved.paperclipApiUrl) === asString(currentIdentity.paperclipApiUrl)
); );
} }

View file

@ -13,7 +13,6 @@ export interface SandboxRemoteExecutionSpec {
remoteCwd: string; remoteCwd: string;
timeoutMs: number; timeoutMs: number;
apiKey: string | null; apiKey: string | null;
paperclipApiUrl?: string | null;
} }
export interface SandboxManagedRuntimeAsset { export interface SandboxManagedRuntimeAsset {
@ -85,7 +84,6 @@ export function parseSandboxRemoteExecutionSpec(value: unknown): SandboxRemoteEx
remoteCwd, remoteCwd,
timeoutMs, timeoutMs,
apiKey: asString(parsed.apiKey).trim() || null, apiKey: asString(parsed.apiKey).trim() || null,
paperclipApiUrl: asString(parsed.paperclipApiUrl).trim() || null,
}; };
} }
@ -96,7 +94,6 @@ export function buildSandboxExecutionSessionIdentity(spec: SandboxRemoteExecutio
provider: spec.provider, provider: spec.provider,
sandboxId: spec.sandboxId, sandboxId: spec.sandboxId,
remoteCwd: spec.remoteCwd, remoteCwd: spec.remoteCwd,
...(spec.paperclipApiUrl ? { paperclipApiUrl: spec.paperclipApiUrl } : {}),
} as const; } as const;
} }
@ -108,8 +105,7 @@ export function sandboxExecutionSessionMatches(saved: unknown, current: SandboxR
asString(parsedSaved.transport) === currentIdentity.transport && asString(parsedSaved.transport) === currentIdentity.transport &&
asString(parsedSaved.provider) === currentIdentity.provider && asString(parsedSaved.provider) === currentIdentity.provider &&
asString(parsedSaved.sandboxId) === currentIdentity.sandboxId && asString(parsedSaved.sandboxId) === currentIdentity.sandboxId &&
asString(parsedSaved.remoteCwd) === currentIdentity.remoteCwd && asString(parsedSaved.remoteCwd) === currentIdentity.remoteCwd
asString(parsedSaved.paperclipApiUrl) === asString(currentIdentity.paperclipApiUrl)
); );
} }

View file

@ -3,6 +3,8 @@ import { constants as fsConstants, createReadStream, createWriteStream, promises
import net from "node:net"; import net from "node:net";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import type { CommandManagedRuntimeRunner } from "./command-managed-runtime.js";
import type { RunProcessResult } from "./server-utils.js";
export interface SshConnectionConfig { export interface SshConnectionConfig {
host: string; host: string;
@ -21,7 +23,86 @@ export interface SshCommandResult {
export interface SshRemoteExecutionSpec extends SshConnectionConfig { export interface SshRemoteExecutionSpec extends SshConnectionConfig {
remoteCwd: string; 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<RunProcessResult> => {
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 { export interface SshEnvLabSupport {
@ -83,10 +164,6 @@ export function parseSshRemoteExecutionSpec(value: unknown): SshRemoteExecutionS
port: portValue, port: portValue,
username, username,
remoteCwd, remoteCwd,
paperclipApiUrl:
typeof parsed.paperclipApiUrl === "string" && parsed.paperclipApiUrl.trim().length > 0
? parsed.paperclipApiUrl.trim()
: null,
remoteWorkspacePath: remoteWorkspacePath:
typeof parsed.remoteWorkspacePath === "string" && parsed.remoteWorkspacePath.trim().length > 0 typeof parsed.remoteWorkspacePath === "string" && parsed.remoteWorkspacePath.trim().length > 0
? parsed.remoteWorkspacePath.trim() ? 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<string | null> {
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( async function execFileText(
file: string, file: string,
args: string[], args: string[],

View file

@ -10,6 +10,7 @@ const {
prepareWorkspaceForSshExecution, prepareWorkspaceForSshExecution,
restoreWorkspaceFromSshExecution, restoreWorkspaceFromSshExecution,
syncDirectoryToSsh, syncDirectoryToSsh,
startAdapterExecutionTargetPaperclipBridge,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
runChildProcess: vi.fn(async () => ({ runChildProcess: vi.fn(async () => ({
exitCode: 0, exitCode: 0,
@ -29,6 +30,14 @@ const {
prepareWorkspaceForSshExecution: vi.fn(async () => undefined), prepareWorkspaceForSshExecution: vi.fn(async () => undefined),
restoreWorkspaceFromSshExecution: vi.fn(async () => undefined), restoreWorkspaceFromSshExecution: vi.fn(async () => undefined),
syncDirectoryToSsh: 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 () => { 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<typeof import("@paperclipai/adapter-utils/execution-target")>(
"@paperclipai/adapter-utils/execution-target",
);
return {
...actual,
startAdapterExecutionTargetPaperclipBridge,
};
});
import { execute } from "./execute.js"; import { execute } from "./execute.js";
describe("claude remote execution", () => { describe("claude remote execution", () => {
@ -112,7 +131,6 @@ describe("claude remote execution", () => {
privateKey: "PRIVATE KEY", privateKey: "PRIVATE KEY",
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
strictHostKeyChecking: true, strictHostKeyChecking: true,
paperclipApiUrl: "http://198.51.100.10:3102",
}, },
}, },
onLog: async () => {}, 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("/remote/workspace/.paperclip-runtime/claude/skills/agent-instructions.md");
expect(call?.[2]).toContain("--add-dir"); expect(call?.[2]).toContain("--add-dir");
expect(call?.[2]).toContain("/remote/workspace/.paperclip-runtime/claude/skills"); 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(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace");
expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1);
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1);
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledWith(expect.objectContaining({ expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledWith(expect.objectContaining({
localDir: workspaceDir, localDir: workspaceDir,

View file

@ -5,7 +5,6 @@ import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclip
import type { RunProcessResult } from "@paperclipai/adapter-utils/server-utils"; import type { RunProcessResult } from "@paperclipai/adapter-utils/server-utils";
import { import {
adapterExecutionTargetIsRemote, adapterExecutionTargetIsRemote,
adapterExecutionTargetPaperclipApiUrl,
adapterExecutionTargetRemoteCwd, adapterExecutionTargetRemoteCwd,
adapterExecutionTargetSessionIdentity, adapterExecutionTargetSessionIdentity,
adapterExecutionTargetSessionMatches, adapterExecutionTargetSessionMatches,
@ -222,11 +221,6 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
if (runtimePrimaryUrl) { if (runtimePrimaryUrl) {
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl; env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
} }
const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget);
if (targetPaperclipApiUrl) {
env.PAPERCLIP_API_URL = targetPaperclipApiUrl;
}
for (const [key, value] of Object.entries(envConfig)) { for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value; if (typeof value === "string") env[key] = value;
} }

View file

@ -10,6 +10,7 @@ const {
prepareWorkspaceForSshExecution, prepareWorkspaceForSshExecution,
restoreWorkspaceFromSshExecution, restoreWorkspaceFromSshExecution,
syncDirectoryToSsh, syncDirectoryToSsh,
startAdapterExecutionTargetPaperclipBridge,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
runChildProcess: vi.fn(async () => ({ runChildProcess: vi.fn(async () => ({
exitCode: 1, exitCode: 1,
@ -25,6 +26,14 @@ const {
prepareWorkspaceForSshExecution: vi.fn(async () => undefined), prepareWorkspaceForSshExecution: vi.fn(async () => undefined),
restoreWorkspaceFromSshExecution: vi.fn(async () => undefined), restoreWorkspaceFromSshExecution: vi.fn(async () => undefined),
syncDirectoryToSsh: 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 () => { 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<typeof import("@paperclipai/adapter-utils/execution-target")>(
"@paperclipai/adapter-utils/execution-target",
);
return {
...actual,
startAdapterExecutionTargetPaperclipBridge,
};
});
import { execute } from "./execute.js"; import { execute } from "./execute.js";
describe("codex remote execution", () => { describe("codex remote execution", () => {
@ -134,7 +153,10 @@ describe("codex remote execution", () => {
| [string, string, string[], { env: Record<string, string>; remoteExecution?: { remoteCwd: string } | null }] | [string, string, string[], { env: Record<string, string>; remoteExecution?: { remoteCwd: string } | null }]
| undefined; | undefined;
expect(call?.[3].env.CODEX_HOME).toBe("/remote/workspace/.paperclip-runtime/codex/home"); 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(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace");
expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1);
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1);
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledWith(expect.objectContaining({ expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledWith(expect.objectContaining({
localDir: workspaceDir, localDir: workspaceDir,

View file

@ -4,7 +4,6 @@ import { fileURLToPath } from "node:url";
import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils"; import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils";
import { import {
adapterExecutionTargetIsRemote, adapterExecutionTargetIsRemote,
adapterExecutionTargetPaperclipApiUrl,
adapterExecutionTargetRemoteCwd, adapterExecutionTargetRemoteCwd,
adapterExecutionTargetSessionIdentity, adapterExecutionTargetSessionIdentity,
adapterExecutionTargetSessionMatches, adapterExecutionTargetSessionMatches,
@ -448,10 +447,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (runtimePrimaryUrl) { if (runtimePrimaryUrl) {
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl; env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
} }
const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget);
if (targetPaperclipApiUrl) {
env.PAPERCLIP_API_URL = targetPaperclipApiUrl;
}
for (const [k, v] of Object.entries(envConfig)) { for (const [k, v] of Object.entries(envConfig)) {
if (typeof v === "string") env[k] = v; if (typeof v === "string") env[k] = v;
} }

View file

@ -11,6 +11,7 @@ const {
restoreWorkspaceFromSshExecution, restoreWorkspaceFromSshExecution,
runSshCommand, runSshCommand,
syncDirectoryToSsh, syncDirectoryToSsh,
startAdapterExecutionTargetPaperclipBridge,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
runChildProcess: vi.fn(async () => ({ runChildProcess: vi.fn(async () => ({
exitCode: 0, exitCode: 0,
@ -35,6 +36,14 @@ const {
exitCode: 0, exitCode: 0,
})), })),
syncDirectoryToSsh: 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 () => { 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<typeof import("@paperclipai/adapter-utils/execution-target")>(
"@paperclipai/adapter-utils/execution-target",
);
return {
...actual,
startAdapterExecutionTargetPaperclipBridge,
};
});
import { execute } from "./execute.js"; import { execute } from "./execute.js";
describe("cursor remote execution", () => { describe("cursor remote execution", () => {
@ -116,7 +135,6 @@ describe("cursor remote execution", () => {
privateKey: "PRIVATE KEY", privateKey: "PRIVATE KEY",
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
strictHostKeyChecking: true, strictHostKeyChecking: true,
paperclipApiUrl: "http://198.51.100.10:3102",
}, },
}, },
onLog: async () => {}, onLog: async () => {},
@ -131,7 +149,6 @@ describe("cursor remote execution", () => {
port: 2222, port: 2222,
username: "fixture", username: "fixture",
remoteCwd: "/remote/workspace", remoteCwd: "/remote/workspace",
paperclipApiUrl: "http://198.51.100.10:3102",
}, },
}); });
expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1); expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1);
@ -150,8 +167,10 @@ describe("cursor remote execution", () => {
| undefined; | undefined;
expect(call?.[2]).toContain("--workspace"); expect(call?.[2]).toContain("--workspace");
expect(call?.[2]).toContain("/remote/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(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace");
expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1);
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1);
}); });

View file

@ -5,7 +5,6 @@ import { fileURLToPath } from "node:url";
import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils"; import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils";
import { import {
adapterExecutionTargetIsRemote, adapterExecutionTargetIsRemote,
adapterExecutionTargetPaperclipApiUrl,
adapterExecutionTargetRemoteCwd, adapterExecutionTargetRemoteCwd,
adapterExecutionTargetSessionIdentity, adapterExecutionTargetSessionIdentity,
adapterExecutionTargetSessionMatches, adapterExecutionTargetSessionMatches,
@ -292,10 +291,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (workspaceHints.length > 0) { if (workspaceHints.length > 0) {
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); 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)) { for (const [k, v] of Object.entries(envConfig)) {
if (typeof v === "string") env[k] = v; if (typeof v === "string") env[k] = v;
} }

View file

@ -11,6 +11,7 @@ const {
restoreWorkspaceFromSshExecution, restoreWorkspaceFromSshExecution,
runSshCommand, runSshCommand,
syncDirectoryToSsh, syncDirectoryToSsh,
startAdapterExecutionTargetPaperclipBridge,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
runChildProcess: vi.fn(async () => ({ runChildProcess: vi.fn(async () => ({
exitCode: 0, exitCode: 0,
@ -41,6 +42,14 @@ const {
exitCode: 0, exitCode: 0,
})), })),
syncDirectoryToSsh: 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 () => { 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<typeof import("@paperclipai/adapter-utils/execution-target")>(
"@paperclipai/adapter-utils/execution-target",
);
return {
...actual,
startAdapterExecutionTargetPaperclipBridge,
};
});
import { execute } from "./execute.js"; import { execute } from "./execute.js";
describe("gemini remote execution", () => { describe("gemini remote execution", () => {
@ -122,7 +141,6 @@ describe("gemini remote execution", () => {
privateKey: "PRIVATE KEY", privateKey: "PRIVATE KEY",
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
strictHostKeyChecking: true, strictHostKeyChecking: true,
paperclipApiUrl: "http://198.51.100.10:3102",
}, },
}, },
onLog: async () => {}, onLog: async () => {},
@ -137,7 +155,6 @@ describe("gemini remote execution", () => {
port: 2222, port: 2222,
username: "fixture", username: "fixture",
remoteCwd: "/remote/workspace", remoteCwd: "/remote/workspace",
paperclipApiUrl: "http://198.51.100.10:3102",
}, },
}); });
expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1); expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1);
@ -154,8 +171,10 @@ describe("gemini remote execution", () => {
const call = runChildProcess.mock.calls[0] as unknown as const call = runChildProcess.mock.calls[0] as unknown as
| [string, string, string[], { env: Record<string, string>; remoteExecution?: { remoteCwd: string } | null }] | [string, string, string[], { env: Record<string, string>; remoteExecution?: { remoteCwd: string } | null }]
| undefined; | 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(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace");
expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1);
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1);
}); });

View file

@ -6,7 +6,6 @@ import { fileURLToPath } from "node:url";
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
import { import {
adapterExecutionTargetIsRemote, adapterExecutionTargetIsRemote,
adapterExecutionTargetPaperclipApiUrl,
adapterExecutionTargetRemoteCwd, adapterExecutionTargetRemoteCwd,
adapterExecutionTargetSessionIdentity, adapterExecutionTargetSessionIdentity,
adapterExecutionTargetSessionMatches, adapterExecutionTargetSessionMatches,
@ -252,9 +251,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
agentHome, agentHome,
}); });
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); if (workspaceHints.length > 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)) { for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value; if (typeof value === "string") env[key] = value;
} }

View file

@ -11,6 +11,7 @@ const {
restoreWorkspaceFromSshExecution, restoreWorkspaceFromSshExecution,
runSshCommand, runSshCommand,
syncDirectoryToSsh, syncDirectoryToSsh,
startAdapterExecutionTargetPaperclipBridge,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
runChildProcess: vi.fn(async () => ({ runChildProcess: vi.fn(async () => ({
exitCode: 0, exitCode: 0,
@ -39,6 +40,14 @@ const {
exitCode: 0, exitCode: 0,
})), })),
syncDirectoryToSsh: 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 () => { 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<typeof import("@paperclipai/adapter-utils/execution-target")>(
"@paperclipai/adapter-utils/execution-target",
);
return {
...actual,
startAdapterExecutionTargetPaperclipBridge,
};
});
import { execute } from "./execute.js"; import { execute } from "./execute.js";
describe("opencode remote execution", () => { describe("opencode remote execution", () => {
@ -121,7 +140,6 @@ describe("opencode remote execution", () => {
privateKey: "PRIVATE KEY", privateKey: "PRIVATE KEY",
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
strictHostKeyChecking: true, strictHostKeyChecking: true,
paperclipApiUrl: "http://198.51.100.10:3102",
}, },
}, },
onLog: async () => {}, onLog: async () => {},
@ -136,7 +154,6 @@ describe("opencode remote execution", () => {
port: 2222, port: 2222,
username: "fixture", username: "fixture",
remoteCwd: "/remote/workspace", remoteCwd: "/remote/workspace",
paperclipApiUrl: "http://198.51.100.10:3102",
}, },
}); });
expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1); expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1);
@ -156,9 +173,11 @@ describe("opencode remote execution", () => {
const call = runChildProcess.mock.calls[0] as unknown as const call = runChildProcess.mock.calls[0] as unknown as
| [string, string, string[], { env: Record<string, string>; remoteExecution?: { remoteCwd: string } | null }] | [string, string, string[], { env: Record<string, string>; remoteExecution?: { remoteCwd: string } | null }]
| undefined; | 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].env.XDG_CONFIG_HOME).toBe("/remote/workspace/.paperclip-runtime/opencode/xdgConfig");
expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace");
expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1);
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1);
}); });

View file

@ -5,7 +5,6 @@ import { fileURLToPath } from "node:url";
import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils"; import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils";
import { import {
adapterExecutionTargetIsRemote, adapterExecutionTargetIsRemote,
adapterExecutionTargetPaperclipApiUrl,
adapterExecutionTargetRemoteCwd, adapterExecutionTargetRemoteCwd,
adapterExecutionTargetSessionIdentity, adapterExecutionTargetSessionIdentity,
adapterExecutionTargetSessionMatches, adapterExecutionTargetSessionMatches,
@ -211,9 +210,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
agentHome, agentHome,
}); });
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); if (workspaceHints.length > 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)) { for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value; if (typeof value === "string") env[key] = value;
} }

View file

@ -11,6 +11,7 @@ const {
restoreWorkspaceFromSshExecution, restoreWorkspaceFromSshExecution,
runSshCommand, runSshCommand,
syncDirectoryToSsh, syncDirectoryToSsh,
startAdapterExecutionTargetPaperclipBridge,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
runChildProcess: vi.fn(async () => ({ runChildProcess: vi.fn(async () => ({
exitCode: 0, exitCode: 0,
@ -44,6 +45,14 @@ const {
exitCode: 0, exitCode: 0,
})), })),
syncDirectoryToSsh: 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 () => { 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<typeof import("@paperclipai/adapter-utils/execution-target")>(
"@paperclipai/adapter-utils/execution-target",
);
return {
...actual,
startAdapterExecutionTargetPaperclipBridge,
};
});
import { execute } from "./execute.js"; import { execute } from "./execute.js";
describe("pi remote execution", () => { describe("pi remote execution", () => {
@ -126,7 +145,6 @@ describe("pi remote execution", () => {
privateKey: "PRIVATE KEY", privateKey: "PRIVATE KEY",
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
strictHostKeyChecking: true, strictHostKeyChecking: true,
paperclipApiUrl: "http://198.51.100.10:3102",
}, },
}, },
onLog: async () => {}, onLog: async () => {},
@ -140,7 +158,6 @@ describe("pi remote execution", () => {
port: 2222, port: 2222,
username: "fixture", username: "fixture",
remoteCwd: "/remote/workspace", remoteCwd: "/remote/workspace",
paperclipApiUrl: "http://198.51.100.10:3102",
}, },
}); });
expect(String(result.sessionId)).toContain("/remote/workspace/.paperclip-runtime/pi/sessions/"); 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("--session");
expect(call?.[2]).toContain("--skill"); expect(call?.[2]).toContain("--skill");
expect(call?.[2]).toContain("/remote/workspace/.paperclip-runtime/pi/skills"); 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(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace");
expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1);
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1);
}); });

View file

@ -5,7 +5,6 @@ import { fileURLToPath } from "node:url";
import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils"; import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils";
import { import {
adapterExecutionTargetIsRemote, adapterExecutionTargetIsRemote,
adapterExecutionTargetPaperclipApiUrl,
adapterExecutionTargetRemoteCwd, adapterExecutionTargetRemoteCwd,
adapterExecutionTargetSessionIdentity, adapterExecutionTargetSessionIdentity,
adapterExecutionTargetSessionMatches, adapterExecutionTargetSessionMatches,
@ -240,9 +239,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
agentHome, agentHome,
}); });
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); if (workspaceHints.length > 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)) { for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value; if (typeof value === "string") env[key] = value;
} }

View file

@ -54,14 +54,11 @@ describe("resolveEnvironmentExecutionTarget", () => {
remoteCwd: DEFAULT_SANDBOX_REMOTE_CWD, remoteCwd: DEFAULT_SANDBOX_REMOTE_CWD,
leaseId: "lease-1", leaseId: "lease-1",
environmentId: "env-1", environmentId: "env-1",
paperclipTransport: "bridge",
timeoutMs: 30_000, timeoutMs: 30_000,
}); });
}); });
it("prefers an explicit Paperclip API URL from lease metadata for sandbox targets", async () => { it("keeps sandbox targets on bridge mode even when lease metadata includes a Paperclip API URL", async () => {
process.env.PAPERCLIP_API_URL = "https://paperclip.example.test";
process.env.PAPERCLIP_RUNTIME_API_URL = "http://paperclip.example.test:3200";
mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({ mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({
driver: "sandbox", driver: "sandbox",
config: { config: {
@ -93,9 +90,11 @@ describe("resolveEnvironmentExecutionTarget", () => {
expect(target).toMatchObject({ expect(target).toMatchObject({
kind: "remote", kind: "remote",
transport: "sandbox", transport: "sandbox",
paperclipApiUrl: "https://paperclip.example.test", providerKey: "fake-plugin",
paperclipTransport: "direct", 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 () => { it("passes through a provider-declared sandbox shell command from lease metadata", async () => {
@ -133,4 +132,50 @@ describe("resolveEnvironmentExecutionTarget", () => {
shellCommand: "bash", 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");
});
}); });

View file

@ -1,5 +1,4 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { createServer, type Server } from "node:http";
import { mkdtemp, rm } from "node:fs/promises"; import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
@ -55,7 +54,6 @@ describeEmbeddedPostgres("environment runtime driver contract", () => {
let stopDb: (() => Promise<void>) | null = null; let stopDb: (() => Promise<void>) | null = null;
let db!: ReturnType<typeof createDb>; let db!: ReturnType<typeof createDb>;
const fixtureRoots: string[] = []; const fixtureRoots: string[] = [];
const servers: Server[] = [];
beforeAll(async () => { beforeAll(async () => {
const started = await startEmbeddedPostgresTestDatabase("environment-runtime-contract"); const started = await startEmbeddedPostgresTestDatabase("environment-runtime-contract");
@ -64,9 +62,6 @@ describeEmbeddedPostgres("environment runtime driver contract", () => {
}); });
afterEach(async () => { afterEach(async () => {
for (const server of servers.splice(0)) {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
while (fixtureRoots.length > 0) { while (fixtureRoots.length > 0) {
const root = fixtureRoots.pop(); const root = fixtureRoots.pop();
if (!root) continue; 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<void>((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) { async function runContract(testCase: RuntimeContractCase) {
const cleanup = await testCase.setup?.(); const cleanup = await testCase.setup?.();
try { try {
@ -288,9 +262,6 @@ describeEmbeddedPostgres("environment runtime driver contract", () => {
fixtureRoots.push(fixtureRoot); fixtureRoots.push(fixtureRoot);
const fixture = await startSshEnvLabFixture({ statePath: path.join(fixtureRoot, "state.json") }); const fixture = await startSshEnvLabFixture({ statePath: path.join(fixtureRoot, "state.json") });
const sshConfig = await buildSshEnvLabFixtureConfig(fixture); 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({ await runContract({
name: "ssh", name: "ssh",
@ -304,16 +275,8 @@ describeEmbeddedPostgres("environment runtime driver contract", () => {
username: sshConfig.username, username: sshConfig.username,
remoteWorkspacePath: sshConfig.remoteWorkspacePath, remoteWorkspacePath: sshConfig.remoteWorkspacePath,
remoteCwd: 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;
}
},
}); });
}); });
}); });

View file

@ -1,5 +1,4 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { createServer } from "node:http";
import { mkdtemp, rm } from "node:fs/promises"; import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
@ -329,26 +328,6 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
const statePath = path.join(fixtureRoot, "state.json"); const statePath = path.join(fixtureRoot, "state.json");
const fixture = await startSshEnvLabFixture({ statePath }); const fixture = await startSshEnvLabFixture({ statePath });
const sshConfig = await buildSshEnvLabFixtureConfig(fixture); 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<void>((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<void>((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({ const { companyId, environment, runId } = await seedEnvironment({
driver: "ssh", driver: "ssh",
name: "Fixture SSH", name: "Fixture SSH",
@ -372,7 +351,6 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
username: sshConfig.username, username: sshConfig.username,
remoteWorkspacePath: sshConfig.remoteWorkspacePath, remoteWorkspacePath: sshConfig.remoteWorkspacePath,
remoteCwd: sshConfig.remoteWorkspacePath, remoteCwd: sshConfig.remoteWorkspacePath,
paperclipApiUrl: runtimeApiUrl,
}); });
const released = await runtime.releaseRunLeases(runId); const released = await runtime.releaseRunLeases(runId);
@ -381,12 +359,6 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
expect(released[0]?.environment.driver).toBe("ssh"); expect(released[0]?.environment.driver).toBe("ssh");
expect(released[0]?.lease.status).toBe("released"); expect(released[0]?.lease.status).toBe("released");
} finally { } finally {
if (previousCandidates === undefined) {
delete process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON;
} else {
process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = previousCandidates;
}
await new Promise<void>((resolve) => healthServer.close(() => resolve()));
} }
}); });

View file

@ -58,10 +58,6 @@ export async function resolveEnvironmentExecutionTarget(input: {
? input.leaseMetadata.remoteCwd.trim() ? input.leaseMetadata.remoteCwd.trim()
: DEFAULT_SANDBOX_REMOTE_CWD; : DEFAULT_SANDBOX_REMOTE_CWD;
const timeoutMs = "timeoutMs" in parsed.config ? parsed.config.timeoutMs : null; 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 = const shellCommand =
input.leaseMetadata?.shellCommand === "bash" || input.leaseMetadata?.shellCommand === "sh" input.leaseMetadata?.shellCommand === "bash" || input.leaseMetadata?.shellCommand === "sh"
? input.leaseMetadata.shellCommand ? input.leaseMetadata.shellCommand
@ -75,8 +71,6 @@ export async function resolveEnvironmentExecutionTarget(input: {
remoteCwd, remoteCwd,
environmentId: input.environment.id ?? null, environmentId: input.environment.id ?? null,
leaseId: input.leaseId ?? null, leaseId: input.leaseId ?? null,
paperclipApiUrl,
paperclipTransport: paperclipApiUrl ? "direct" : "bridge",
timeoutMs, timeoutMs,
runner: input.environmentRuntime && input.lease runner: input.environmentRuntime && input.lease
? { ? {
@ -143,10 +137,6 @@ export async function resolveEnvironmentExecutionTarget(input: {
environmentId: input.environment.id ?? null, environmentId: input.environment.id ?? null,
leaseId: input.leaseId ?? null, leaseId: input.leaseId ?? null,
remoteCwd, remoteCwd,
paperclipApiUrl:
typeof input.leaseMetadata?.paperclipApiUrl === "string" && input.leaseMetadata.paperclipApiUrl.trim().length > 0
? input.leaseMetadata.paperclipApiUrl.trim()
: null,
spec: { spec: {
host: parsed.config.host, host: parsed.config.host,
port: parsed.config.port, port: parsed.config.port,
@ -156,10 +146,6 @@ export async function resolveEnvironmentExecutionTarget(input: {
knownHosts: parsed.config.knownHosts, knownHosts: parsed.config.knownHosts,
strictHostKeyChecking: parsed.config.strictHostKeyChecking, strictHostKeyChecking: parsed.config.strictHostKeyChecking,
remoteCwd, remoteCwd,
paperclipApiUrl:
typeof input.leaseMetadata?.paperclipApiUrl === "string" && input.leaseMetadata.paperclipApiUrl.trim().length > 0
? input.leaseMetadata.paperclipApiUrl.trim()
: null,
}, },
}; };
} }

View file

@ -14,7 +14,7 @@ import type {
PluginEnvironmentLease, PluginEnvironmentLease,
PluginEnvironmentRealizeWorkspaceResult, PluginEnvironmentRealizeWorkspaceResult,
} from "@paperclipai/plugin-sdk"; } 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 { environmentService } from "./environments.js";
import { import {
parseEnvironmentDriverConfig, parseEnvironmentDriverConfig,
@ -227,27 +227,6 @@ function createSshEnvironmentDriver(db: Db): EnvironmentRuntimeDriver {
} }
const { remoteCwd } = await ensureSshWorkspaceReady(parsed.config); 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({ return await environmentsSvc.acquireLease({
companyId: input.companyId, companyId: input.companyId,
environmentId: input.environment.id, environmentId: input.environment.id,
@ -265,7 +244,6 @@ function createSshEnvironmentDriver(db: Db): EnvironmentRuntimeDriver {
username: parsed.config.username, username: parsed.config.username,
remoteWorkspacePath: parsed.config.remoteWorkspacePath, remoteWorkspacePath: parsed.config.remoteWorkspacePath,
remoteCwd, remoteCwd,
paperclipApiUrl,
}, },
}); });
}, },