import path from "node:path"; import type { SshRemoteExecutionSpec } from "./ssh.js"; import { prepareCommandManagedRuntime, type CommandManagedRuntimeRunner, } from "./command-managed-runtime.js"; import { buildRemoteExecutionSessionIdentity, prepareRemoteManagedRuntime, remoteExecutionSessionMatches, type RemoteManagedRuntimeAsset, } from "./remote-managed-runtime.js"; import { createCommandManagedSandboxCallbackBridgeQueueClient, createSandboxCallbackBridgeAsset, createSandboxCallbackBridgeToken, DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES, startSandboxCallbackBridgeServer, startSandboxCallbackBridgeWorker, } from "./sandbox-callback-bridge.js"; import { createSshCommandManagedRuntimeRunner, parseSshRemoteExecutionSpec, runSshCommand, shellQuote } from "./ssh.js"; import { ensureCommandResolvable, resolveCommandForLogs, runChildProcess, type RunProcessResult, type TerminalResultCleanupOptions, } from "./server-utils.js"; import { sanitizeRemoteExecutionEnv } from "./remote-execution-env.js"; import { preferredShellForSandbox, shellCommandArgs } from "./sandbox-shell.js"; export interface AdapterLocalExecutionTarget { kind: "local"; environmentId?: string | null; leaseId?: string | null; } export interface AdapterSshExecutionTarget { kind: "remote"; transport: "ssh"; environmentId?: string | null; leaseId?: string | null; remoteCwd: string; spec: SshRemoteExecutionSpec; } export interface AdapterSandboxExecutionTarget { kind: "remote"; transport: "sandbox"; providerKey?: string | null; shellCommand?: "bash" | "sh" | null; environmentId?: string | null; leaseId?: string | null; remoteCwd: string; timeoutMs?: number | null; runner?: CommandManagedRuntimeRunner; } export type AdapterExecutionTarget = | AdapterLocalExecutionTarget | AdapterSshExecutionTarget | AdapterSandboxExecutionTarget; export type AdapterRemoteExecutionSpec = SshRemoteExecutionSpec; export type AdapterManagedRuntimeAsset = RemoteManagedRuntimeAsset; export interface PreparedAdapterExecutionTargetRuntime { target: AdapterExecutionTarget; workspaceRemoteDir: string | null; runtimeRootDir: string | null; assetDirs: Record; restoreWorkspace(): Promise; } export interface AdapterExecutionTargetProcessOptions { cwd: string; env: Record; stdin?: string; timeoutSec: number; graceSec: number; onLog: (stream: "stdout" | "stderr", chunk: string) => Promise; onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise; terminalResultCleanup?: TerminalResultCleanupOptions; } export interface AdapterExecutionTargetShellOptions { cwd: string; env: Record; timeoutSec?: number; graceSec?: number; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; } export interface AdapterExecutionTargetPaperclipBridgeHandle { env: Record; stop(): Promise; } export { sanitizeRemoteExecutionEnv } from "./remote-execution-env.js"; function parseObject(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; } function readString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } function readStringMeta(parsed: Record, key: string): string | null { return readString(parsed[key]); } function resolveHostForUrl(rawHost: string): string { const host = rawHost.trim(); if (!host || host === "0.0.0.0" || host === "::") return "localhost"; if (host.includes(":") && !host.startsWith("[") && !host.endsWith("]")) return `[${host}]`; return host; } function resolveDefaultPaperclipApiUrl(): string { const runtimeHost = resolveHostForUrl( process.env.PAPERCLIP_LISTEN_HOST ?? process.env.HOST ?? "localhost", ); // 3100 matches the default Paperclip dev server port when the runtime does not provide one. const runtimePort = process.env.PAPERCLIP_LISTEN_PORT ?? process.env.PORT ?? "3100"; return `http://${runtimeHost}:${runtimePort}`; } function isBridgeDebugEnabled(env: NodeJS.ProcessEnv): boolean { const value = env.PAPERCLIP_BRIDGE_DEBUG?.trim().toLowerCase(); return value === "1" || value === "true" || value === "yes"; } function isAdapterExecutionTargetInstance(value: unknown): value is AdapterExecutionTarget { const parsed = parseObject(value); if (parsed.kind === "local") return true; if (parsed.kind !== "remote") return false; if (parsed.transport === "ssh") return parseSshRemoteExecutionSpec(parseObject(parsed.spec)) !== null; if (parsed.transport !== "sandbox") return false; return readStringMeta(parsed, "remoteCwd") !== null; } export function adapterExecutionTargetToRemoteSpec( target: AdapterExecutionTarget | null | undefined, ): AdapterRemoteExecutionSpec | null { return target?.kind === "remote" && target.transport === "ssh" ? target.spec : null; } export function adapterExecutionTargetIsRemote( target: AdapterExecutionTarget | null | undefined, ): boolean { return target?.kind === "remote"; } export function adapterExecutionTargetUsesManagedHome( target: AdapterExecutionTarget | null | undefined, ): boolean { return target?.kind === "remote" && target.transport === "sandbox"; } export function adapterExecutionTargetRemoteCwd( target: AdapterExecutionTarget | null | undefined, localCwd: string, ): string { return target?.kind === "remote" ? target.remoteCwd : localCwd; } export function overrideAdapterExecutionTargetRemoteCwd( target: AdapterExecutionTarget | null | undefined, remoteCwd: string | null | undefined, ): AdapterExecutionTarget | null | undefined { const nextRemoteCwd = remoteCwd?.trim(); if (!target || target.kind !== "remote" || !nextRemoteCwd) { return target; } if (target.remoteCwd === nextRemoteCwd) { return target; } if (target.transport === "ssh") { return { ...target, remoteCwd: nextRemoteCwd, spec: { ...target.spec, remoteCwd: nextRemoteCwd, }, }; } return { ...target, remoteCwd: nextRemoteCwd, }; } export function resolveAdapterExecutionTargetCwd( target: AdapterExecutionTarget | null | undefined, configuredCwd: string | null | undefined, localFallbackCwd: string, ): string { if (typeof configuredCwd === "string" && configuredCwd.trim().length > 0) { return configuredCwd; } return adapterExecutionTargetRemoteCwd(target, localFallbackCwd); } export function adapterExecutionTargetUsesPaperclipBridge( target: AdapterExecutionTarget | null | undefined, ): boolean { return target?.kind === "remote"; } export function describeAdapterExecutionTarget( target: AdapterExecutionTarget | null | undefined, ): string { if (!target || target.kind === "local") return "local environment"; if (target.transport === "ssh") { return `SSH environment ${target.spec.username}@${target.spec.host}:${target.spec.port}`; } return `sandbox environment${target.providerKey ? ` (${target.providerKey})` : ""}`; } function requireSandboxRunner(target: AdapterSandboxExecutionTarget): CommandManagedRuntimeRunner { if (target.runner) return target.runner; throw new Error( "Sandbox execution target is missing its provider runtime runner. Sandbox commands must execute through the environment runtime.", ); } function preferredSandboxShell(target: AdapterSandboxExecutionTarget): "bash" | "sh" { 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, cwd: string, env: NodeJS.ProcessEnv, options: { installCommand?: string | null } = {}, ) { if (target?.kind === "remote" && target.transport === "sandbox") { await ensureSandboxCommandResolvable(command, target, options.installCommand?.trim() || null); return; } await ensureCommandResolvable(command, cwd, env, { remoteExecution: adapterExecutionTargetToRemoteSpec(target), }); } async function probeSandboxCommandResolvable( command: string, target: AdapterSandboxExecutionTarget, ): Promise<{ resolved: boolean; timedOut: boolean; stderr: string }> { const runner = requireSandboxRunner(target); const probeScript = `command -v ${shellQuote(command)}`; const result = await runner.execute({ command: "sh", args: ["-c", probeScript], cwd: target.remoteCwd, timeoutMs: target.timeoutMs ?? 15_000, }); return { resolved: !result.timedOut && (result.exitCode ?? 1) === 0, timedOut: result.timedOut, stderr: result.stderr.trim(), }; } async function ensureSandboxCommandResolvable( command: string, target: AdapterSandboxExecutionTarget, installCommand: string | null, ): Promise { // Probe whether the binary is resolvable inside the sandbox. We previously // short-circuited this for sandbox targets, which let the caller report a // success message even when the CLI was missing from the image. Now we run // a real `command -v` through the same runner the hello probe will use, so // the first step honestly reflects whether the binary is on PATH. The // sandbox provider is responsible for sourcing login profiles (e2b mirrors // SSH's buildSshSpawnTarget) so this and the hello probe agree on PATH. let probe = await probeSandboxCommandResolvable(command, target); if (probe.resolved) return; if (probe.timedOut) { throw new Error(`Timed out checking command "${command}" on sandbox target.`); } // If the caller supplied an install command, attempt the install once via // the sandbox runner (which the sandbox provider wraps in a login shell) // and re-probe before reporting failure. This lets fresh sandbox leases // bring up the CLI before the resolvability gate, mirroring the test path. let installFailureDetail: string | null = null; if (installCommand) { const runner = requireSandboxRunner(target); try { const installResult = await runner.execute({ command: "sh", args: shellCommandArgs(installCommand), cwd: target.remoteCwd, timeoutMs: target.timeoutMs ?? 300_000, }); if (installResult.timedOut) { installFailureDetail = `install command timed out: ${installCommand}`; } else if ((installResult.exitCode ?? 0) !== 0) { const tail = (text: string) => text.split(/\r?\n/).filter((line) => line.trim().length > 0).slice(-2).join(" | ").slice(0, 240); const reason = tail(installResult.stderr || installResult.stdout) || `exit ${installResult.exitCode ?? "?"}`; installFailureDetail = `install command exited ${installResult.exitCode ?? "?"}: ${reason}`; } } catch (err) { installFailureDetail = `install command threw: ${err instanceof Error ? err.message : String(err)}`; } probe = await probeSandboxCommandResolvable(command, target); if (probe.resolved) return; if (probe.timedOut) { throw new Error(`Timed out checking command "${command}" on sandbox target.`); } } const probeStderr = probe.stderr.length > 0 ? ` probe stderr: ${probe.stderr}` : ""; const installDetail = installFailureDetail ? `; ${installFailureDetail}` : ""; throw new Error( `Command "${command}" is not installed or not on PATH in the sandbox environment${installDetail}.${probeStderr}`, ); } export async function resolveAdapterExecutionTargetCommandForLogs( command: string, target: AdapterExecutionTarget | null | undefined, cwd: string, env: NodeJS.ProcessEnv, ): Promise { if (target?.kind === "remote" && target.transport === "sandbox") { return `sandbox://${target.providerKey ?? "provider"}/${target.leaseId ?? "lease"}/${target.remoteCwd} :: ${command}`; } return await resolveCommandForLogs(command, cwd, env, { remoteExecution: adapterExecutionTargetToRemoteSpec(target), }); } export async function runAdapterExecutionTargetProcess( runId: string, target: AdapterExecutionTarget | null | undefined, command: string, args: string[], options: AdapterExecutionTargetProcessOptions, ): Promise { if (target?.kind === "remote" && target.transport === "sandbox") { const runner = requireSandboxRunner(target); const env = sanitizeRemoteExecutionEnv(options.env); return await runner.execute({ command, args, cwd: target.remoteCwd, env, stdin: options.stdin, timeoutMs: options.timeoutSec > 0 ? options.timeoutSec * 1000 : target.timeoutMs ?? undefined, onLog: options.onLog, onSpawn: options.onSpawn ? async (meta) => options.onSpawn?.({ ...meta, processGroupId: null }) : undefined, }); } const env = target?.kind === "remote" && target.transport === "ssh" ? sanitizeRemoteExecutionEnv(options.env) : options.env; return await runChildProcess(runId, command, args, { cwd: options.cwd, env, stdin: options.stdin, timeoutSec: options.timeoutSec, graceSec: options.graceSec, onLog: options.onLog, onSpawn: options.onSpawn, terminalResultCleanup: options.terminalResultCleanup, remoteExecution: adapterExecutionTargetToRemoteSpec(target), }); } export async function runAdapterExecutionTargetShellCommand( runId: string, target: AdapterExecutionTarget | null | undefined, command: string, options: AdapterExecutionTargetShellOptions, ): Promise { const onLog = options.onLog ?? (async () => {}); if (target?.kind === "remote") { const startedAt = new Date().toISOString(); const env = sanitizeRemoteExecutionEnv(options.env); if (target.transport === "ssh") { try { // Pass the raw command — `runSshCommand` owns profile sourcing and // the outer shell wrapper. Wrapping again here would nest a second // shell after the explicit `env KEY=VAL` overrides, re-sourcing // login profiles AFTER the override and silently undoing any // identity var (NVM_DIR / PATH / etc.) that a profile re-exports. const result = await runSshCommand(target.spec, command, { env, timeoutMs: (options.timeoutSec ?? 15) * 1000, }); if (result.stdout) await onLog("stdout", result.stdout); if (result.stderr) await onLog("stderr", result.stderr); return { exitCode: 0, signal: null, timedOut: false, stdout: result.stdout, stderr: result.stderr, pid: null, startedAt, }; } catch (error) { const timedOutError = error as NodeJS.ErrnoException & { stdout?: string; stderr?: string; signal?: string | null; }; const stdout = timedOutError.stdout ?? ""; const stderr = timedOutError.stderr ?? ""; if (typeof timedOutError.code === "number") { if (stdout) await onLog("stdout", stdout); if (stderr) await onLog("stderr", stderr); return { exitCode: timedOutError.code, signal: timedOutError.signal ?? null, timedOut: false, stdout, stderr, pid: null, startedAt, }; } if (timedOutError.code !== "ETIMEDOUT") { throw error; } if (stdout) await onLog("stdout", stdout); if (stderr) await onLog("stderr", stderr); return { exitCode: null, signal: timedOutError.signal ?? null, timedOut: true, stdout, stderr, pid: null, startedAt, }; } } const shellCommand = preferredSandboxShell(target); return await requireSandboxRunner(target).execute({ command: shellCommand, args: shellCommandArgs(command), cwd: target.remoteCwd, env, timeoutMs: (options.timeoutSec ?? 15) * 1000, onLog, }); } return await runAdapterExecutionTargetProcess( runId, target, "sh", ["-lc", command], { cwd: options.cwd, env: options.env, timeoutSec: options.timeoutSec ?? 15, graceSec: options.graceSec ?? 5, onLog, }, ); } export interface AdapterSandboxInstallCommandCheck { code: string; level: "info" | "warn" | "error"; message: string; detail?: string; hint?: string; } // Best-effort run of an adapter-supplied install command on a sandbox target // before the resolvability + hello probe. Returns null for non-sandbox // targets so callers can no-op. Returns a structured check otherwise — never // throws — so the rest of the test still runs and reports the post-install // state honestly. Caller pushes the check into its result array; the test // report shows whether install was attempted and what came back. export async function maybeRunSandboxInstallCommand(input: { runId: string; target: AdapterExecutionTarget | null | undefined; adapterKey: string; installCommand: string; /** When provided, skip the install if `command -v ` succeeds. */ detectCommand?: string | null; env?: Record; timeoutSec?: number; }): Promise { const { target, adapterKey, installCommand } = input; if (!target || target.kind !== "remote" || target.transport !== "sandbox") { return null; } const trimmed = installCommand.trim(); if (trimmed.length === 0) return null; const code = `${adapterKey}_install_command_run`; // Skip install when the binary is already on PATH. Avoids running // network-dependent installers (e.g. `curl ... | bash`) on every test // probe when the CLI is preinstalled on the lease/template. const detectCommand = input.detectCommand?.trim(); if (detectCommand) { try { const probe = await runAdapterExecutionTargetShellCommand( input.runId, target, `command -v ${shellQuote(detectCommand)} >/dev/null 2>&1`, { cwd: target.remoteCwd, env: input.env ?? {}, timeoutSec: 30, graceSec: 5, }, ); if (!probe.timedOut && probe.exitCode === 0) { return { code, level: "info", message: `${detectCommand} already on PATH; skipped install.`, }; } } catch { // Fall through to actually running the install — failure to probe // is not a reason to skip the install gate. } } let result; try { result = await runAdapterExecutionTargetShellCommand(input.runId, target, trimmed, { cwd: target.remoteCwd, env: input.env ?? {}, timeoutSec: input.timeoutSec ?? 240, graceSec: 10, }); } catch (err) { return { code, level: "warn", message: "Install command threw before completion.", detail: err instanceof Error ? err.message : String(err), }; } const tail = (text: string) => text.split(/\r?\n/).filter((line) => line.trim().length > 0).slice(-3).join(" | ").slice(0, 480); if (result.timedOut) { return { code, level: "warn", message: `Install command timed out: ${trimmed}`, detail: tail(result.stderr || result.stdout), }; } if ((result.exitCode ?? 1) === 0) { return { code, level: "info", message: `Install command ran: ${trimmed}`, ...(tail(result.stdout) ? { detail: tail(result.stdout) } : {}), }; } return { code, level: "warn", message: `Install command exited ${result.exitCode}: ${trimmed}`, detail: tail(result.stderr || result.stdout), }; } export async function readAdapterExecutionTargetHomeDir( runId: string, target: AdapterExecutionTarget | null | undefined, options: AdapterExecutionTargetShellOptions, ): Promise { const result = await runAdapterExecutionTargetShellCommand( runId, target, 'printf %s "$HOME"', options, ); const homeDir = result.stdout.trim(); return homeDir.length > 0 ? homeDir : null; } export async function ensureAdapterExecutionTargetRuntimeCommandInstalled(input: { runId: string; target: AdapterExecutionTarget | null | undefined; installCommand?: string | null; detectCommand?: string | null; cwd: string; env: Record; timeoutSec?: number; graceSec?: number; onLog?: AdapterExecutionTargetShellOptions["onLog"]; }): Promise { const installCommand = input.installCommand?.trim(); if (!installCommand || input.target?.kind !== "remote" || input.target.transport !== "sandbox") { return; } const detectCommand = input.detectCommand?.trim(); if (detectCommand) { const probe = await runAdapterExecutionTargetShellCommand( input.runId, input.target, `command -v ${shellQuote(detectCommand)} >/dev/null 2>&1`, { cwd: input.cwd, env: input.env, timeoutSec: input.timeoutSec, graceSec: input.graceSec, }, ); if (!probe.timedOut && probe.exitCode === 0) { return; } } const result = await runAdapterExecutionTargetShellCommand( input.runId, input.target, installCommand, { cwd: input.cwd, env: input.env, timeoutSec: input.timeoutSec, graceSec: input.graceSec, onLog: input.onLog, }, ); // A failed or timed-out install is not necessarily fatal: the CLI may already // be on PATH from a previous lease's install, the template image, or another // path entry. Re-run the detect probe (when one is configured) so a transient // install failure does not abort the agent run when the binary is reachable. const installFailed = result.timedOut || (result.exitCode ?? 0) !== 0; if (!installFailed) { return; } if (detectCommand) { const recheck = await runAdapterExecutionTargetShellCommand( input.runId, input.target, `command -v ${shellQuote(detectCommand)} >/dev/null 2>&1`, { cwd: input.cwd, env: input.env, timeoutSec: input.timeoutSec, graceSec: input.graceSec, }, ); if (!recheck.timedOut && recheck.exitCode === 0) { if (input.onLog) { const reason = result.timedOut ? "timed out" : `exited ${result.exitCode ?? "?"}`; await input.onLog( "stderr", `[paperclip] Install command ${reason} (${installCommand}) but ${detectCommand} is on PATH; continuing.\n`, ); } return; } } if (result.timedOut) { throw new Error(`Timed out while installing the adapter runtime command via: ${installCommand}`); } throw new Error(`Failed to install the adapter runtime command via: ${installCommand}`); } export async function ensureAdapterExecutionTargetFile( runId: string, target: AdapterExecutionTarget | null | undefined, filePath: string, options: AdapterExecutionTargetShellOptions, ): Promise { await runAdapterExecutionTargetShellCommand( runId, target, `mkdir -p ${shellQuote(path.posix.dirname(filePath))} && : > ${shellQuote(filePath)}`, options, ); } /** * Ensure a working directory exists (and is a directory) on the execution target. * * For local targets this delegates to the local `ensureAbsoluteDirectory` helper * (Node fs). For remote (SSH/sandbox) targets it shells out and runs * `mkdir -p` (when allowed) followed by a `[ -d ]` check so the result reflects * the directory state inside the environment, not on the Paperclip host. * * Throws an Error with a human-readable message on failure. */ export async function ensureAdapterExecutionTargetDirectory( runId: string, target: AdapterExecutionTarget | null | undefined, cwd: string, options: AdapterExecutionTargetShellOptions & { createIfMissing?: boolean }, ): Promise { const createIfMissing = options.createIfMissing ?? false; if (!target || target.kind === "local") { const { ensureAbsoluteDirectory } = await import("./server-utils.js"); await ensureAbsoluteDirectory(cwd, { createIfMissing }); return; } // Remote (SSH or sandbox): both expect POSIX absolute paths inside the env. if (!cwd.startsWith("/")) { throw new Error(`Working directory must be an absolute POSIX path on the remote target: "${cwd}"`); } const quoted = shellQuote(cwd); const script = createIfMissing ? `mkdir -p ${quoted} && [ -d ${quoted} ]` : `[ -d ${quoted} ]`; const result = await runAdapterExecutionTargetShellCommand(runId, target, script, { cwd: target.kind === "remote" ? target.remoteCwd : cwd, env: options.env, timeoutSec: options.timeoutSec ?? 15, graceSec: options.graceSec ?? 5, onLog: options.onLog, }); if (result.timedOut) { throw new Error(`Timed out checking working directory on remote target: "${cwd}"`); } if ((result.exitCode ?? 1) !== 0) { const detail = (result.stderr || result.stdout || "").trim(); if (createIfMissing) { throw new Error( `Could not create working directory "${cwd}" on remote target${detail ? `: ${detail}` : "."}`, ); } throw new Error( `Working directory does not exist on remote target: "${cwd}"${detail ? ` (${detail})` : ""}`, ); } } export function adapterExecutionTargetSessionIdentity( target: AdapterExecutionTarget | null | undefined, ): Record | null { if (!target || target.kind === "local") return null; if (target.transport === "ssh") return buildRemoteExecutionSessionIdentity(target.spec); return { transport: "sandbox", providerKey: target.providerKey ?? null, environmentId: target.environmentId ?? null, leaseId: target.leaseId ?? null, remoteCwd: target.remoteCwd, }; } export function adapterExecutionTargetSessionMatches( saved: unknown, target: AdapterExecutionTarget | null | undefined, ): boolean { if (!target || target.kind === "local") { return Object.keys(parseObject(saved)).length === 0; } if (target.transport === "ssh") return remoteExecutionSessionMatches(saved, target.spec); const current = adapterExecutionTargetSessionIdentity(target); const parsedSaved = parseObject(saved); return ( readStringMeta(parsedSaved, "transport") === current?.transport && readStringMeta(parsedSaved, "providerKey") === current?.providerKey && readStringMeta(parsedSaved, "environmentId") === current?.environmentId && readStringMeta(parsedSaved, "leaseId") === current?.leaseId && readStringMeta(parsedSaved, "remoteCwd") === current?.remoteCwd ); } export function parseAdapterExecutionTarget(value: unknown): AdapterExecutionTarget | null { const parsed = parseObject(value); const kind = readStringMeta(parsed, "kind"); if (kind === "local") { return { kind: "local", environmentId: readStringMeta(parsed, "environmentId"), leaseId: readStringMeta(parsed, "leaseId"), }; } if (kind === "remote" && readStringMeta(parsed, "transport") === "ssh") { const spec = parseSshRemoteExecutionSpec(parseObject(parsed.spec)); if (!spec) return null; return { kind: "remote", transport: "ssh", environmentId: readStringMeta(parsed, "environmentId"), leaseId: readStringMeta(parsed, "leaseId"), remoteCwd: spec.remoteCwd, spec, }; } if (kind === "remote" && readStringMeta(parsed, "transport") === "sandbox") { const remoteCwd = readStringMeta(parsed, "remoteCwd"); if (!remoteCwd) return null; return { kind: "remote", transport: "sandbox", providerKey: readStringMeta(parsed, "providerKey"), environmentId: readStringMeta(parsed, "environmentId"), leaseId: readStringMeta(parsed, "leaseId"), remoteCwd, timeoutMs: typeof parsed.timeoutMs === "number" ? parsed.timeoutMs : null, }; } return null; } export function adapterExecutionTargetFromRemoteExecution( remoteExecution: unknown, metadata: Pick = {}, ): AdapterExecutionTarget | null { const parsed = parseObject(remoteExecution); const ssh = parseSshRemoteExecutionSpec(parsed); if (ssh) { return { kind: "remote", transport: "ssh", environmentId: metadata.environmentId ?? null, leaseId: metadata.leaseId ?? null, remoteCwd: ssh.remoteCwd, spec: ssh, }; } return null; } export function readAdapterExecutionTarget(input: { executionTarget?: unknown; legacyRemoteExecution?: unknown; }): AdapterExecutionTarget | null { if (isAdapterExecutionTargetInstance(input.executionTarget)) { return input.executionTarget; } return ( parseAdapterExecutionTarget(input.executionTarget) ?? adapterExecutionTargetFromRemoteExecution(input.legacyRemoteExecution) ); } export async function prepareAdapterExecutionTargetRuntime(input: { runId: string; target: AdapterExecutionTarget | null | undefined; adapterKey: string; workspaceLocalDir: string; workspaceRemoteDir?: string; workspaceExclude?: string[]; preserveAbsentOnRestore?: string[]; assets?: AdapterManagedRuntimeAsset[]; installCommand?: string | null; /** When provided alongside `installCommand`, skip the install if the binary is already on PATH. */ detectCommand?: string | null; }): Promise { const target = input.target ?? { kind: "local" as const }; if (target.kind === "local") { return { target, workspaceRemoteDir: null, runtimeRootDir: null, assetDirs: {}, restoreWorkspace: async () => {}, }; } if (target.transport === "ssh") { const prepared = await prepareRemoteManagedRuntime({ spec: target.spec, runId: input.runId, adapterKey: input.adapterKey, workspaceLocalDir: input.workspaceLocalDir, workspaceRemoteDir: input.workspaceRemoteDir, assets: input.assets, }); return { target, workspaceRemoteDir: prepared.workspaceRemoteDir, runtimeRootDir: prepared.runtimeRootDir, assetDirs: prepared.assetDirs, restoreWorkspace: prepared.restoreWorkspace, }; } const prepared = await prepareCommandManagedRuntime({ runner: requireSandboxRunner(target), spec: { providerKey: target.providerKey, shellCommand: target.shellCommand, leaseId: target.leaseId, remoteCwd: target.remoteCwd, timeoutMs: target.timeoutMs, }, adapterKey: input.adapterKey, workspaceLocalDir: input.workspaceLocalDir, workspaceRemoteDir: input.workspaceRemoteDir, workspaceExclude: input.workspaceExclude, preserveAbsentOnRestore: input.preserveAbsentOnRestore, assets: input.assets, installCommand: input.installCommand, detectCommand: input.detectCommand, }); return { target, workspaceRemoteDir: prepared.workspaceRemoteDir, runtimeRootDir: prepared.runtimeRootDir, assetDirs: prepared.assetDirs, restoreWorkspace: prepared.restoreWorkspace, }; } export function runtimeAssetDir( prepared: Pick, key: string, fallbackRemoteCwd: string, ): string { return prepared.assetDirs[key] ?? path.posix.join(fallbackRemoteCwd, ".paperclip-runtime", key); } function buildBridgeResponseHeaders(response: Response): Record { const out: Record = {}; for (const key of ["content-type", "etag", "last-modified"]) { const value = response.headers.get(key); if (value && value.trim().length > 0) out[key] = value.trim(); } return out; } function buildBridgeForwardUrl(baseUrl: string, request: { path: string; query: string }): URL { const url = new URL(request.path, baseUrl); const query = request.query.trim(); url.search = query.startsWith("?") ? query.slice(1) : query; return url; } function bridgeResponseBodyLimitError(maxBodyBytes: number): Error { return new Error(`Bridge response body exceeded the configured size limit of ${maxBodyBytes} bytes.`); } async function readBridgeForwardResponseBody(response: Response, maxBodyBytes: number): Promise { const rawContentLength = response.headers.get("content-length"); if (rawContentLength) { const contentLength = Number.parseInt(rawContentLength, 10); if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) { throw bridgeResponseBodyLimitError(maxBodyBytes); } } if (!response.body) { return ""; } const reader = response.body.getReader(); const chunks: Buffer[] = []; let totalBytes = 0; while (true) { const { done, value } = await reader.read(); if (done) break; if (!value) continue; totalBytes += value.byteLength; if (totalBytes > maxBodyBytes) { await reader.cancel().catch(() => undefined); throw bridgeResponseBodyLimitError(maxBodyBytes); } chunks.push(Buffer.from(value)); } return Buffer.concat(chunks, totalBytes).toString("utf8"); } export async function startAdapterExecutionTargetPaperclipBridge(input: { runId: string; target: AdapterExecutionTarget | null | undefined; runtimeRootDir: string | null | undefined; adapterKey: string; hostApiToken: string | null | undefined; hostApiUrl?: string | null; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; maxBodyBytes?: number | null; }): Promise { if (!adapterExecutionTargetUsesPaperclipBridge(input.target)) { return null; } if (!input.target || input.target.kind !== "remote") { return null; } const target = input.target; const onLog = input.onLog ?? (async () => {}); const hostApiToken = input.hostApiToken?.trim() ?? ""; if (hostApiToken.length === 0) { throw new Error("Sandbox bridge mode requires a host-side Paperclip API token."); } const runtimeRootDir = input.runtimeRootDir?.trim().length ? input.runtimeRootDir.trim() : path.posix.join(target.remoteCwd, ".paperclip-runtime", input.adapterKey); const bridgeRuntimeDir = path.posix.join(runtimeRootDir, "paperclip-bridge"); const queueDir = path.posix.join(bridgeRuntimeDir, "queue"); const assetRemoteDir = path.posix.join(bridgeRuntimeDir, "server"); const bridgeToken = createSandboxCallbackBridgeToken(); const maxBodyBytes = typeof input.maxBodyBytes === "number" && Number.isFinite(input.maxBodyBytes) && input.maxBodyBytes > 0 ? Math.trunc(input.maxBodyBytes) : DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES; const hostApiUrl = input.hostApiUrl?.trim() || 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", `[paperclip] Starting sandbox callback bridge for ${input.adapterKey} in ${bridgeRuntimeDir}.\n`, ); const bridgeAsset = await createSandboxCallbackBridgeAsset(); let server: Awaited> | null = null; let worker: Awaited> | null = null; try { const client = createCommandManagedSandboxCallbackBridgeQueueClient({ runner, remoteCwd: target.remoteCwd, timeoutMs: adapterExecutionTargetTimeoutMs(target), shellCommand, }); // PAPERCLIP_BRIDGE_DEBUG opts into verbose stdout logs of every bridge // proxy request/response. The query string is logged verbatim, so callers // who pass auth tokens or other sensitive values as query parameters // should be aware those values appear in the host process's stdout when // this flag is enabled. Only intended for active debugging in trusted // environments. const bridgeDebugEnabled = isBridgeDebugEnabled(process.env); worker = await startSandboxCallbackBridgeWorker({ client, queueDir, maxBodyBytes, handleRequest: async (request) => { const method = request.method.trim().toUpperCase() || "GET"; if (bridgeDebugEnabled) { await onLog( "stdout", `[paperclip] Bridge proxy ${method} ${request.path}${request.query ? `?${request.query}` : ""}\n`, ); } const headers = new Headers(); for (const [key, value] of Object.entries(request.headers)) { if (value.trim().length === 0) continue; headers.set(key, value); } headers.set("authorization", `Bearer ${hostApiToken}`); headers.set("x-paperclip-run-id", input.runId); const response = await fetch(buildBridgeForwardUrl(hostApiUrl, request), { method, headers, ...(method === "GET" || method === "HEAD" ? {} : { body: request.body }), signal: AbortSignal.timeout(30_000), }); if (bridgeDebugEnabled) { await onLog( "stdout", `[paperclip] Bridge proxy response ${response.status} for ${method} ${request.path}${request.query ? `?${request.query}` : ""}\n`, ); } return { status: response.status, headers: buildBridgeResponseHeaders(response), body: await readBridgeForwardResponseBody(response, maxBodyBytes), }; }, }); server = await startSandboxCallbackBridgeServer({ runner, remoteCwd: target.remoteCwd, assetRemoteDir, queueDir, bridgeToken, bridgeAsset, timeoutMs: adapterExecutionTargetTimeoutMs(target), maxBodyBytes, shellCommand, }); } catch (error) { await Promise.allSettled([ server?.stop(), worker?.stop(), bridgeAsset.cleanup(), ]); throw error; } return { env: { PAPERCLIP_API_URL: server.baseUrl, PAPERCLIP_API_KEY: bridgeToken, PAPERCLIP_API_BRIDGE_MODE: "queue_v1", }, stop: async () => { await Promise.allSettled([ server?.stop(), ]); await Promise.allSettled([ worker?.stop(), bridgeAsset.cleanup(), ]); }, }; }