diff --git a/packages/adapter-utils/src/execution-target.test.ts b/packages/adapter-utils/src/execution-target.test.ts index 46a4df7d6d..88919a99f3 100644 --- a/packages/adapter-utils/src/execution-target.test.ts +++ b/packages/adapter-utils/src/execution-target.test.ts @@ -5,6 +5,9 @@ import * as ssh from "./ssh.js"; import * as serverUtils from "./server-utils.js"; import { cleanupGitHubOperationLaunchers, + adapterExecutionTargetRuntimeRoot, + prepareAdapterExecutionTargetRuntime, + type AdapterComputerExecutionTarget, prepareGitHubOperationLaunchers, adapterExecutionTargetUsesManagedHome, ensureAdapterExecutionTargetRuntimeCommandInstalled, @@ -437,3 +440,36 @@ describe("GitHub launcher lifecycle", () => { expect(runner.execute).toHaveBeenCalledTimes(1); }); }); + + +describe("computer legacy runtime ownership", () => { + it.each(["claude", "codex"])("isolates %s assets and callback roots for owners sharing a checkout", async adapterKey => { + const execute = vi.fn(async () => ({ exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "", pid: 123, startedAt: "2026-10-10T00:00:00Z" })); + const target = (ownerId: string, generation = 1) => ({ + kind: "remote", transport: "computer", providerKey: "boat", environmentId: "environment", + leaseId: ownerId, remoteCwd: "/shared/project", listenerPort: 43127, + runner: { execute }, processRunner: { execute }, + resourceAuthority: { kind: "computer-owner", computerId: "computer", ownerId, generation }, + fileAuthority: { kind: "remote-persistent", placementId: "shared", root: "/shared/project", agentHome: `/homes/${ownerId}` }, + launch: vi.fn(), inspectProcess: vi.fn(), retainWarm: vi.fn(), retire: vi.fn(), + computerTool: { command: "cua-driver", args: ["mcp"] }, + } satisfies AdapterComputerExecutionTarget); + const prepare = (owner: AdapterComputerExecutionTarget) => prepareAdapterExecutionTargetRuntime({ + target: owner, runId: owner.leaseId!, adapterKey, workspaceLocalDir: "/unused-local-workspace", syncWorkspace: false, + }); + const first = await prepare(target("agent-one")); + const second = await prepare(target("agent-two")); + expect(first.workspaceRemoteDir).toBe("/shared/project"); + expect(second.workspaceRemoteDir).toBe(first.workspaceRemoteDir); + expect(first.runtimeRootDir).toBe(adapterExecutionTargetRuntimeRoot(target("agent-one"), adapterKey)); + expect(second.runtimeRootDir).not.toBe(first.runtimeRootDir); + expect(adapterExecutionTargetRuntimeRoot(target("agent-one", 2), adapterKey)).not.toBe(first.runtimeRootDir); + expect((await prepare(target("agent-one"))).runtimeRootDir).toBe(first.runtimeRootDir); + expect(first.runtimeRootDir).toMatch(new RegExp(`^/shared/project/\\.paperclip-runtime/${adapterKey}/owners/[a-f0-9]{64}$`)); + }); + + it("preserves the existing sandbox runtime root", () => { + const target = { kind: "remote", transport: "sandbox", remoteCwd: "/workspace" } as const; + expect(adapterExecutionTargetRuntimeRoot(target, "claude")).toBe("/workspace/.paperclip-runtime/claude"); + }); +}); diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index dbffa195aa..73595d5cfa 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -6,7 +6,7 @@ import { promisify } from "node:util"; import net from "node:net"; import os from "node:os"; import path from "node:path"; -import { randomBytes, randomUUID } from "node:crypto"; +import { createHash, randomBytes, randomUUID } from "node:crypto"; import { githubLauncherSource } from "./github-launcher.js"; import type { SshRemoteExecutionSpec } from "./ssh.js"; import { @@ -1442,6 +1442,20 @@ export function readAdapterExecutionTarget(input: { ); } +/** Keep mutable legacy assets separate for owners sharing the same checkout. */ +export function adapterExecutionTargetRuntimeRoot( + target: Extract, + adapterKey: string, + baseRoot = path.posix.join(target.remoteCwd, ".paperclip-runtime", adapterKey), +): string { + if (target.transport !== "computer") return baseRoot; + const owner = target.resourceAuthority; + const scope = createHash("sha256").update(JSON.stringify([ + owner.computerId, owner.ownerId, owner.generation, + ])).digest("hex"); + return path.posix.join(baseRoot, "owners", scope); +} + export async function prepareAdapterExecutionTargetRuntime(input: { runId: string; target: AdapterExecutionTarget | null | undefined; @@ -1535,7 +1549,9 @@ export async function prepareAdapterExecutionTargetRuntime(input: { adapterKey: input.adapterKey, workspaceLocalDir: input.workspaceLocalDir, workspaceRemoteDir: input.workspaceRemoteDir, - runtimeRootDir: input.runtimeRootDir, + runtimeRootDir: target.transport === "computer" + ? adapterExecutionTargetRuntimeRoot(target, input.adapterKey, input.runtimeRootDir) + : input.runtimeRootDir, syncWorkspace: input.syncWorkspace, workspaceInboundMode: input.workspaceInboundMode, workspaceDurableSeed: input.workspaceDurableSeed, @@ -2033,7 +2049,7 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: { ? Math.trunc(input.timeoutSec * 1000) : target.timeoutMs ?? undefined; const bridgeRuntimeDir = path.posix.join( - input.runtimeRootDir?.trim() || path.posix.join(target.remoteCwd, ".paperclip-runtime", input.adapterKey), + input.runtimeRootDir?.trim() || adapterExecutionTargetRuntimeRoot(target, input.adapterKey), "process-sessions", ); const sessionId = randomUUID(); @@ -4402,7 +4418,7 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { const runtimeRootDir = input.runtimeRootDir?.trim().length ? input.runtimeRootDir.trim() - : path.posix.join(target.remoteCwd, ".paperclip-runtime", input.adapterKey); + : adapterExecutionTargetRuntimeRoot(target, input.adapterKey); const bridgeRuntimeDir = path.posix.join(runtimeRootDir, "paperclip-bridge"); const queueDir = path.posix.join(bridgeRuntimeDir, "queue"); const assetRemoteDir = path.posix.join(bridgeRuntimeDir, "server");