From 72bf0c61c6b74df91ced1c087dad9b53457ad07d Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 08:05:35 -0500 Subject: [PATCH] Add command-backed attached computer execution target and allocated runner port Co-Authored-By: Paperclip --- .../adapter-utils/src/execution-target.ts | 68 ++++++++++++------- .../src/runner-connectivity.test.ts | 25 +++++++ .../adapter-utils/src/runner-connectivity.ts | 10 +-- 3 files changed, 76 insertions(+), 27 deletions(-) diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index c70c02d7b91..d6f73d2b3d6 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -233,10 +233,33 @@ export interface AdapterSandboxExecutionTarget extends AdapterExecutionTargetWor duplexObservabilityRecorder?: DuplexObservabilityRecorder | null; } +/** A durable attached computer owns a process, never the physical machine. */ +export interface AdapterComputerExecutionTarget extends Omit { + transport: "computer"; + listenerPort: number; + resourceAuthority: { kind: "computer-owner"; computerId: string; ownerId: string; generation: number }; + fileAuthority: { kind: "remote-persistent"; placementId: string; root: string; agentHome: string }; + launch(input: { command: string; args?: string[]; cwd?: string; env?: Record }): Promise; + inspectProcess(): Promise<{ running: boolean; claim: unknown }>; + retainWarm(idleTimeoutMs: number): Promise; + retire(): Promise; + computerTool: { command: string; args: string[] }; +} + +export type AdapterCommandBackedExecutionTarget = AdapterSandboxExecutionTarget | AdapterComputerExecutionTarget; + +/** Command transport capability; sandbox allocation ownership is unchanged. */ +export function adapterExecutionTargetIsCommandBacked( + target: AdapterExecutionTarget | null | undefined, +): target is AdapterCommandBackedExecutionTarget { + return target?.kind === "remote" && (target.transport === "sandbox" || target.transport === "computer"); +} + export type AdapterExecutionTarget = | AdapterLocalExecutionTarget | AdapterSshExecutionTarget - | AdapterSandboxExecutionTarget; + | AdapterSandboxExecutionTarget + | AdapterComputerExecutionTarget; export type AdapterRemoteExecutionSpec = SshRemoteExecutionSpec; @@ -443,7 +466,7 @@ function isAdapterExecutionTargetInstance(value: unknown): value is AdapterExecu 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; + if (parsed.transport !== "sandbox" && parsed.transport !== "computer") return false; return readStringMeta(parsed, "remoteCwd") !== null; } @@ -462,7 +485,7 @@ export function adapterExecutionTargetIsRemote( export function adapterExecutionTargetUsesManagedHome( target: AdapterExecutionTarget | null | undefined, ): boolean { - return target?.kind === "remote" && target.transport === "sandbox"; + return adapterExecutionTargetIsCommandBacked(target); } /** @@ -475,8 +498,7 @@ export function adapterExecutionTargetEnablesSandboxDuplexBridge( target: AdapterExecutionTarget | null | undefined, ): boolean { return ( - target?.kind === "remote" && - target.transport === "sandbox" && + adapterExecutionTargetIsCommandBacked(target) && target.enableSandboxDuplexBridge === true ); } @@ -489,7 +511,7 @@ export function adapterExecutionTargetEnablesSandboxDuplexBridge( export function adapterExecutionTargetDuplexObservabilityRecorder( target: AdapterExecutionTarget | null | undefined, ): DuplexObservabilityRecorder | null { - return target?.kind === "remote" && target.transport === "sandbox" + return adapterExecutionTargetIsCommandBacked(target) ? target.duplexObservabilityRecorder ?? null : null; } @@ -553,7 +575,7 @@ export function describeAdapterExecutionTarget( if (target.transport === "ssh") { return `SSH environment ${target.spec.username}@${target.spec.host}:${target.spec.port}`; } - return `sandbox environment${target.providerKey ? ` (${target.providerKey})` : ""}`; + return `${target.transport} environment${target.providerKey ? ` (${target.providerKey})` : ""}`; } export type AdapterExecutionTargetTimeoutSource = @@ -593,7 +615,7 @@ export function resolveAdapterExecutionTargetTimeout( // that usually apply their own shorter command defaults, so request an // explicit longer timeout for full adapter runs when the adapter leaves // timeoutSec unset. - if (target?.kind === "remote" && target.transport === "sandbox") { + if (adapterExecutionTargetIsCommandBacked(target)) { return { timeoutSec: DEFAULT_REMOTE_SANDBOX_ADAPTER_TIMEOUT_SEC, source: "sandbox_default" }; } return { timeoutSec: 0, source: "unlimited" }; @@ -659,18 +681,18 @@ export function formatAdapterExecutionTimeoutStartLogLine( ); } -function requireSandboxRunner(target: AdapterSandboxExecutionTarget): CommandManagedRuntimeRunner { +function requireSandboxRunner(target: AdapterCommandBackedExecutionTarget): 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" { +function preferredSandboxShell(target: AdapterCommandBackedExecutionTarget): "bash" | "sh" { return preferredShellForSandbox(target.shellCommand); } -type AdapterCommandCapableExecutionTarget = AdapterSshExecutionTarget | AdapterSandboxExecutionTarget; +type AdapterCommandCapableExecutionTarget = AdapterSshExecutionTarget | AdapterCommandBackedExecutionTarget; // The Secure Shell command runner's own output buffer. This value used to // derive from the bridge body limit (`DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES @@ -697,7 +719,7 @@ function adapterExecutionTargetShellCommand(target: AdapterCommandCapableExecuti function adapterExecutionTargetTimeoutMs( target: AdapterCommandCapableExecutionTarget, ): number | null | undefined { - return target.transport === "sandbox" ? target.timeoutMs : undefined; + return adapterExecutionTargetIsCommandBacked(target) ? target.timeoutMs : undefined; } export async function ensureAdapterExecutionTargetCommandResolvable( @@ -707,7 +729,7 @@ export async function ensureAdapterExecutionTargetCommandResolvable( env: NodeJS.ProcessEnv, options: { installCommand?: string | null; timeoutSec?: number | null } = {}, ) { - if (target?.kind === "remote" && target.transport === "sandbox") { + if (adapterExecutionTargetIsCommandBacked(target)) { await ensureSandboxCommandResolvable( command, target, @@ -726,7 +748,7 @@ export async function ensureAdapterExecutionTargetCommandResolvable( async function probeSandboxCommandResolvable( command: string, - target: AdapterSandboxExecutionTarget, + target: AdapterCommandBackedExecutionTarget, env: Record, ): Promise<{ resolved: boolean; timedOut: boolean; stderr: string }> { const runner = requireSandboxRunner(target); @@ -747,7 +769,7 @@ async function probeSandboxCommandResolvable( async function ensureSandboxCommandResolvable( command: string, - target: AdapterSandboxExecutionTarget, + target: AdapterCommandBackedExecutionTarget, env: Record, installCommand: string | null, timeoutSec?: number | null, @@ -815,7 +837,7 @@ export async function resolveAdapterExecutionTargetCommandForLogs( cwd: string, env: NodeJS.ProcessEnv, ): Promise { - if (target?.kind === "remote" && target.transport === "sandbox") { + if (adapterExecutionTargetIsCommandBacked(target)) { return `sandbox://${target.providerKey ?? "provider"}/${target.leaseId ?? "lease"}/${target.remoteCwd} :: ${command}`; } return await resolveCommandForLogs(command, cwd, env, { @@ -859,7 +881,7 @@ export async function runAdapterExecutionTargetProcess( args: string[], options: AdapterExecutionTargetProcessOptions, ): Promise { - if (target?.kind === "remote" && target.transport === "sandbox") { + if (adapterExecutionTargetIsCommandBacked(target)) { const runner = requireSandboxRunner(target); const env = sanitizeRemoteExecutionEnv(options.env); await options.onRuntimeProgress?.({ @@ -1055,7 +1077,7 @@ export async function maybeRunSandboxInstallCommand(input: { timeoutSec?: number; }): Promise { const { target, adapterKey, installCommand } = input; - if (!target || target.kind !== "remote" || target.transport !== "sandbox") { + if (!adapterExecutionTargetIsCommandBacked(target)) { return null; } const trimmed = installCommand.trim(); @@ -1162,7 +1184,7 @@ export async function ensureAdapterExecutionTargetRuntimeCommandInstalled(input: onLog?: AdapterExecutionTargetShellOptions["onLog"]; }): Promise { const installCommand = input.installCommand?.trim(); - if (!installCommand || input.target?.kind !== "remote" || input.target.transport !== "sandbox") { + if (!installCommand || !adapterExecutionTargetIsCommandBacked(input.target)) { return; } @@ -1562,7 +1584,7 @@ export function githubOperationLauncherDirectory(input: GitHubLauncherLocation): if (!/^[a-zA-Z0-9_-]+$/.test(input.runId)) throw new Error("Invalid GitHub launcher run ID"); return input.target?.kind === "remote" ? path.posix.join(input.target.remoteCwd, ".paperclip-runtime", - ...(input.target.transport === "sandbox" ? ["paperclip-runner"] : []), "github", input.runId) + ...(adapterExecutionTargetIsCommandBacked(input.target) ? ["paperclip-runner"] : []), "github", input.runId) : path.join(os.tmpdir(), "paperclip-github-runtime", input.runId); } @@ -1986,7 +2008,7 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: { // it does not start the 100 ms poll. Default OFF: the bridge keeps the poll. streamOutputViaSession?: boolean; }): Promise { - if (!input.target || input.target.kind !== "remote" || input.target.transport !== "sandbox") { + if (!adapterExecutionTargetIsCommandBacked(input.target)) { return null; } @@ -4838,7 +4860,7 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { // log line as the file path. The http2 path starts no file-bridge // worker, so create the log directory before the tail starts. let duplexRunLogTail: SandboxRunLogTailFactory | null = null; - if (target.transport === "sandbox" && target.streamRunLogs !== false) { + if (adapterExecutionTargetIsCommandBacked(target) && target.streamRunLogs !== false) { const duplexLogsDir = sandboxCallbackBridgeDirectories(queueDir).logsDir; await ensureSandboxRunLogDirectory({ runner, @@ -4930,7 +4952,7 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { } let runLogTail: SandboxRunLogTailFactory | null = null; - if (target.transport === "sandbox" && target.streamRunLogs !== false) { + if (adapterExecutionTargetIsCommandBacked(target) && target.streamRunLogs !== false) { runLogTail = createSandboxRunLogTailFactory({ runner, remoteCwd: target.remoteCwd, diff --git a/packages/adapter-utils/src/runner-connectivity.test.ts b/packages/adapter-utils/src/runner-connectivity.test.ts index 68a8b6d36de..0a601392e28 100644 --- a/packages/adapter-utils/src/runner-connectivity.test.ts +++ b/packages/adapter-utils/src/runner-connectivity.test.ts @@ -210,3 +210,28 @@ describe("paperclip runner transport routing", () => { ).toThrow(); }); }); + +it("uses each computer owner's allocated port without changing the sandbox default", async () => { + const { adapterExecutionTargetIsCommandBacked } = await import("./execution-target.js"); + const endpoint = ingress(); + const getRunnerIngressEndpoint = vi.fn(async () => endpoint); + const base = { + kind: "remote" as const, transport: "computer" as const, providerKey: "boat", remoteCwd: "/home/user/project", + leaseId: "attempt-lease", listenerPort: 45101, effectiveCapabilities: capabilities, getRunnerIngressEndpoint, + resourceAuthority: { kind: "computer-owner" as const, computerId: "machine", ownerId: "owner-a", generation: 2 }, + fileAuthority: { kind: "remote-persistent" as const, placementId: "placement", root: "/home/user/project", agentHome: "/home/user/agent-a" }, + launch: async () => ({}), inspectProcess: async () => ({ running: true, claim: {} }), + retainWarm: async () => {}, retire: async () => {}, computerTool: { command: "cua-driver", args: ["mcp"] }, + }; + expect(adapterExecutionTargetIsCommandBacked(base)).toBe(true); + for (const port of [45101, 45102]) { + const resolved = await resolvePaperclipRunnerTransport({ target: { ...base, listenerPort: port }, runId: "run-1", + localConnectUrl: "ws://localhost/unused", runnerIngressAuthorized: true }); + expect(resolved).toMatchObject({ mode: "provider_ingress", listenPort: port }); + expect(getRunnerIngressEndpoint).toHaveBeenLastCalledWith({ leaseId: "attempt-lease", port, path: "/api/runner/v1/connect/run-1" }); + } + const { listenerPort, resourceAuthority, fileAuthority, launch, inspectProcess, retainWarm, retire, computerTool, ...common } = base; + const resolved = await resolvePaperclipRunnerTransport({ target: { ...common, transport: "sandbox" }, runId: "sandbox-run", + localConnectUrl: "ws://localhost/unused", runnerIngressAuthorized: true }); + expect(resolved).toMatchObject({ mode: "provider_ingress", listenPort: 43127 }); +}); diff --git a/packages/adapter-utils/src/runner-connectivity.ts b/packages/adapter-utils/src/runner-connectivity.ts index c59dfeb5608..9b5fb3bbe56 100644 --- a/packages/adapter-utils/src/runner-connectivity.ts +++ b/packages/adapter-utils/src/runner-connectivity.ts @@ -1,4 +1,4 @@ -import type { AdapterExecutionTarget } from "./execution-target.js"; +import { adapterExecutionTargetIsCommandBacked, type AdapterExecutionTarget } from "./execution-target.js"; export const PAPERCLIP_RUNNER_INGRESS_PORT = 43_127; export const PAPERCLIP_RUNNER_CONNECT_PATH_PREFIX = "/api/runner/v1/connect"; @@ -133,7 +133,7 @@ export async function resolvePaperclipRunnerTransport(input: { } if ( - input.target.transport === "sandbox" && + adapterExecutionTargetIsCommandBacked(input.target) && input.target.effectiveCapabilities?.runnerWebSocketIngress === true ) { const ingressAuthorized = @@ -152,16 +152,18 @@ export async function resolvePaperclipRunnerTransport(input: { "The sandbox runner ingress provider is unavailable for this lease.", ); } + const port = input.target.transport === "computer" ? input.target.listenerPort : PAPERCLIP_RUNNER_INGRESS_PORT; + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new PaperclipRunnerTransportError("runner_ingress_unavailable", "Invalid allocated runner port."); const path = connectPath(input.runId); const ingress = await getRunnerIngressEndpoint({ leaseId: input.target.leaseId, - port: PAPERCLIP_RUNNER_INGRESS_PORT, + port, path, }); return { mode: "provider_ingress", listenAddress: "0.0.0.0", - listenPort: PAPERCLIP_RUNNER_INGRESS_PORT, + listenPort: port, listenPath: path, ingress, };