From 52aee0392006a984f8679f36dfbfa9a24c35db21 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 08:18:49 -0500 Subject: [PATCH] Keep computer process capabilities stable across warm admission Co-Authored-By: Paperclip --- .../src/execution-target-kind.ts | 9 ++++++++ .../adapter-utils/src/execution-target.ts | 10 ++++---- .../src/runner-connectivity.test.ts | 3 ++- .../adapter-utils/src/runner-connectivity.ts | 3 ++- .../services/computer-environment-driver.ts | 13 +++++++---- .../native-session-executor.test.ts | 23 +++++++++++++++++++ .../native-runtime/native-session-executor.ts | 13 +++++++++-- 7 files changed, 59 insertions(+), 15 deletions(-) create mode 100644 packages/adapter-utils/src/execution-target-kind.ts diff --git a/packages/adapter-utils/src/execution-target-kind.ts b/packages/adapter-utils/src/execution-target-kind.ts new file mode 100644 index 00000000000..a95c76c50cd --- /dev/null +++ b/packages/adapter-utils/src/execution-target-kind.ts @@ -0,0 +1,9 @@ +import type { AdapterCommandBackedExecutionTarget, AdapterExecutionTarget } from "./execution-target.js"; + +/** 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"); +} + diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index d6f73d2b3d6..d682f9aad02 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -1,3 +1,5 @@ +import { adapterExecutionTargetIsCommandBacked } from "./execution-target-kind.js"; +export { adapterExecutionTargetIsCommandBacked } from "./execution-target-kind.js"; import fs from "node:fs/promises"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; @@ -237,6 +239,8 @@ export interface AdapterSandboxExecutionTarget extends AdapterExecutionTargetWor export interface AdapterComputerExecutionTarget extends Omit { transport: "computer"; listenerPort: number; + /** Native process control survives attempt handoff only for the same process claim. */ + processRunner: CommandManagedRuntimeRunner; 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; @@ -248,12 +252,6 @@ export interface AdapterComputerExecutionTarget extends Omit ({ exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "", pid: null, startedAt: new Date().toISOString() }) }, 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: {} }), @@ -230,7 +231,7 @@ it("uses each computer owner's allocated port without changing the sandbox defau 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 { listenerPort, processRunner, 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 9b5fb3bbe56..d499b9ebb4d 100644 --- a/packages/adapter-utils/src/runner-connectivity.ts +++ b/packages/adapter-utils/src/runner-connectivity.ts @@ -1,4 +1,5 @@ -import { adapterExecutionTargetIsCommandBacked, type AdapterExecutionTarget } from "./execution-target.js"; +import type { AdapterExecutionTarget } from "./execution-target.js"; +import { adapterExecutionTargetIsCommandBacked } from "./execution-target-kind.js"; export const PAPERCLIP_RUNNER_INGRESS_PORT = 43_127; export const PAPERCLIP_RUNNER_CONNECT_PATH_PREFIX = "/api/runner/v1/connect"; diff --git a/server/src/services/computer-environment-driver.ts b/server/src/services/computer-environment-driver.ts index 31bccdedc5e..3f36fa0a205 100644 --- a/server/src/services/computer-environment-driver.ts +++ b/server/src/services/computer-environment-driver.ts @@ -76,10 +76,12 @@ export function createComputerEnvironmentDriver(db: Db): EnvironmentRuntimeDrive return { providerLeaseId: lease.providerLeaseId, state: "stopped" }; }, async realizeWorkspace({ lease, workspace }) { - const request = workspace.metadata?.workspaceRealizationRequest as { source?: { projectId?: string; repoUrl?: string; branchName?: string; strategy?: string }; issueId?: string } | undefined; + const request = workspace.metadata?.workspaceRealizationRequest as { source?: { projectId?: string; repoUrl?: string; repoRef?: string; branchName?: string; strategy?: string }; issueId?: string } | undefined; const result = await computers.realizeWorkspace({ ...scope(lease), owner: computerOwnerFromLease(lease), projectId: request?.source?.projectId ?? undefined, taskId: request?.issueId ?? undefined, - repositoryUrl: request?.source?.repoUrl ?? undefined, branch: request?.source?.branchName ?? undefined, + repositoryUrl: request?.source?.repoUrl ?? undefined, + branch: request?.source?.branchName ?? request?.source?.repoRef ?? undefined, + baseRef: request?.source?.repoRef ?? undefined, mode: request?.source?.strategy === "git_worktree" ? "worktree" : "shared" }); await leases.updateLeaseMetadata(lease.id, { ...lease.metadata, remoteCwd: result.remoteCwd, fileAuthority: { kind: "remote-persistent", placementId: result.placementId, root: result.remoteCwd, agentHome: result.agentHome } }); @@ -88,7 +90,8 @@ export function createComputerEnvironmentDriver(db: Db): EnvironmentRuntimeDrive }, async execute({ lease, command, args, cwd, env, stdin, timeoutMs, onLog }) { const binding = await recover(lease); - return binding.runner.execute({ command, args, cwd: cwd ?? binding.remoteCwd, env, stdin, timeoutMs, onLog }); + return binding.runner.execute({ command, args, cwd: cwd ?? binding.remoteCwd, env, stdin, timeoutMs, + onLog: onLog ? async (stream, chunk) => { await onLog(stream, chunk); } : undefined }); }, async resolveCapabilities() { return COMPUTER_CAPABILITIES; }, }; @@ -101,13 +104,13 @@ export async function computerExecutionTarget(db: Db, lease: EnvironmentLease, i const owner = computerOwnerFromLease(lease); const binding = await computers.recover({ ...scope, owner }); const ingress = async (path: string): Promise => { - const endpoint = await binding.ingress({ path }); + const endpoint = await binding.process.ingress({ path }); return { kind: "authenticated_websocket", websocketUrl: endpoint.url, secretHeaders: Object.entries(endpoint.secretHeaders).map(([name, value]) => ({ name, value })), generation: `${owner.ownerId}:${owner.generation}`, refresh: () => ingress(path), close: async () => {} }; }; return { kind: "remote", transport: "computer", providerKey: "boat", environmentId: lease.environmentId, leaseId: lease.id, - remoteCwd: binding.remoteCwd, listenerPort: binding.listenerPort, runner: binding.runner, shellCommand: "bash", + remoteCwd: binding.remoteCwd, listenerPort: binding.listenerPort, runner: binding.runner, processRunner: binding.process.runner, shellCommand: "bash", effectiveCapabilities: COMPUTER_CAPABILITIES, reusableLeaseConfigured: true, runnerLifecyclePolicy: { mode: "warm", idleTimeoutMs }, resourceAuthority: { kind: "computer-owner", ...owner }, diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 4b41408a3d1..a9fd53f651e 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -705,6 +705,29 @@ describe("remote runner launch fingerprint compatibility", () => { }); describe("remote runner process supervision", () => { + it("launches a computer runner through owned process admission, never detached shell allocation", async () => { + let nonce = ""; + const launch = vi.fn(async (request: { args: string[] }) => { nonce = request.args[4]!; return {}; }); + const execute = vi.fn(async (request: { args: string[] }) => { + const label = request.args[2]; + return { exitCode: label === "paperclip-runner-monitor" ? 3 : 0, timedOut: false, signal: null, + stdout: label === "paperclip-runner-process-identity" ? `${nonce}\n4321\n2026-09-06T00:00:00.000Z\ncomputer-runner\nlinux:abcd-1234:100\n` + : label === "paperclip-runner-diagnostics" ? "stopped" : Buffer.from('{}').toString('base64'), stderr: "" }; + }); + const launcher = createRemoteRunnerProcessLauncher({ + target: { kind: "remote", transport: "computer", remoteCwd: "/home/user/project", launch } as never, + runner: { execute } as never, remoteBinary: "/runtime/runnerd", processIdentityPath: "/runtime/state/identity", + stateDirectory: "/runtime/state", diagnosticsDirectory: "/runtime/logs", runnerInstanceId: "computer-runner", + }); + const handle = launcher({ command: "/controller/runnerd", args: ["--runner-id", "computer-runner"], cwd: "/controller", environment: {} }); + await handle.completion; + expect(launch).toHaveBeenCalledOnce(); + expect(launch.mock.calls[0]![0].args).toContain("/runtime/runnerd"); + expect(launch.mock.calls[0]![0].args[1]).not.toContain("nohup"); + expect(execute.mock.calls.every(([request]) => request.args[2] !== "paperclip-runner-launch")).toBe(true); + expect(handle.child.pid).toBe(4321); + }); + it.each(["delivered", "sandbox_missing", "logging_failed"] as const)( "detaches runnerd and contains asynchronous signal failures (%s)", async (signalOutcome) => { let launchNonce = ""; diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 32f2c8ea99d..7c4e4c627f8 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -554,7 +554,7 @@ export async function claimWarmNativeInstructionCopy(input: { function instructionTargetIdentity(target?: AdapterExecutionTarget | null): string { return JSON.stringify(target?.kind === "remote" ? { environmentId: target.environmentId, cwd: target.remoteCwd, - providerLeaseId: target.transport === "sandbox" ? target.sandboxLeaseAcquisition?.providerLeaseId : target.transport === "computer" ? target.resourceAuthority.computerId : target.spec, + providerLeaseId: target.transport === "sandbox" ? target.sandboxLeaseAcquisition?.providerLeaseId : target.transport === "computer" ? [target.resourceAuthority.computerId, target.resourceAuthority.ownerId] : target.spec, } : { kind: "local", environmentId: target?.environmentId }); } @@ -567,6 +567,12 @@ export async function reserveWarmNativeInstructionDirectory(input: { for (const [id, entry] of warmNativeSessions) { if (entry.companyId !== input.companyId || entry.agentId !== input.agentId || entry.instructionCopy?.runId !== input.previousRunId) continue; if (entry.busy || entry.preparingRunId) throw new Error("native_session_supervisor_busy"); + if (input.target?.kind === "remote" && input.target.transport === "computer" && + entry.computerTarget?.resourceAuthority.computerId === input.target.resourceAuthority.computerId && + entry.computerTarget.resourceAuthority.ownerId === input.target.resourceAuthority.ownerId && + entry.computerTarget.resourceAuthority.generation < input.target.resourceAuthority.generation) { + entry.computerTarget = input.target; + } if (entry.idleTimer) clearTimeout(entry.idleTimer); entry.idleTimer = null; entry.preparingRunId = input.runId; @@ -8463,6 +8469,9 @@ async function executePaperclipNativeSessionWithinScope( entry.credentialRunId !== input.execution.binding.runId); if ( input.refreshTools === true || + (input.runnerExecutionTarget?.kind === "remote" && input.runnerExecutionTarget.transport === "computer" && + (entry.computerTarget?.resourceAuthority.computerId !== input.runnerExecutionTarget.resourceAuthority.computerId || + entry.computerTarget?.resourceAuthority.ownerId !== input.runnerExecutionTarget.resourceAuthority.ownerId)) || entry.closeOnReleaseReason !== undefined || entry.configDigest !== warmConfigDigest || entry.configuredEnvironmentDigest !== configuredEnvironmentDigest || @@ -11386,7 +11395,7 @@ async function createRunnerdBackendWithinSessionClaim( spec: remoteTarget.spec, defaultCwd: remoteTarget.remoteCwd, }) - : remoteTarget.runner + : remoteTarget.transport === "computer" ? remoteTarget.processRunner : remoteTarget.runner : null; if (remoteTarget && !remoteCommandRunner) { throw new Error(