From 53e7067413c9c0b71bcc3f0f5172d7d2418addeb Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 8 Sep 2026 13:53:45 -0500 Subject: [PATCH] fix: preserve scoped sandbox home in legacy adapters Co-Authored-By: Paperclip --- doc/sandbox-work-folders.md | 4 +++ .../src/execution-target.test.ts | 13 +++++++ .../adapter-utils/src/execution-target.ts | 9 +++++ .../cursor-local/src/server/execute.ts | 15 ++++---- .../src/server/execute.remote.test.ts | 16 +++++++-- .../gemini-local/src/server/execute.ts | 12 +++---- .../adapters/kimi-local/src/server/execute.ts | 10 +++--- .../opencode-local/src/server/execute.ts | 15 ++++---- .../src/server/execute.remote.test.ts | 36 +++++++++++++++++++ .../adapters/pi-local/src/server/execute.ts | 9 +++-- 10 files changed, 106 insertions(+), 33 deletions(-) diff --git a/doc/sandbox-work-folders.md b/doc/sandbox-work-folders.md index 6b9a0cbbd0..6f5b0963c3 100644 --- a/doc/sandbox-work-folders.md +++ b/doc/sandbox-work-folders.md @@ -72,6 +72,10 @@ uploads remain unchanged. Task plans and documents are not materialized. The agent starts in `$HOME`. `PAPERCLIP_PRIMARY_REPO` and the workspace context identify the repository for project commands. `AGENT_HOME` and `PAPERCLIP_{TASK,AGENT,USER,PROJECT,REPOS}_DIR` expose the bound directories. +Legacy adapters use the host-bound sandbox home for both CLI launch and skill +discovery; a private per-run runtime directory must not override it. CLI-specific +configuration remains separately staged beneath that home. Local and SSH homes +are unchanged. CLI state is separate from the four shared collections. A change of task, agent, responsible user, or project cannot reuse a sandbox with another binding. diff --git a/packages/adapter-utils/src/execution-target.test.ts b/packages/adapter-utils/src/execution-target.test.ts index 5d89d97d3f..cc3c5fed7f 100644 --- a/packages/adapter-utils/src/execution-target.test.ts +++ b/packages/adapter-utils/src/execution-target.test.ts @@ -7,6 +7,7 @@ import { cleanupGitHubOperationLaunchers, prepareGitHubOperationLaunchers, adapterExecutionTargetUsesManagedHome, + adapterExecutionTargetManagedHomeDir, ensureAdapterExecutionTargetRuntimeCommandInstalled, resolveAdapterExecutionTargetCwd, runAdapterExecutionTargetProcess, @@ -201,6 +202,18 @@ describe("runAdapterExecutionTargetShellCommand", () => { expect(onLog).toHaveBeenCalledWith("stderr", "partial stderr"); }); + it("uses the host-bound sandbox home while retaining managed CLI configuration", () => { + const target = { kind: "remote" as const, transport: "sandbox" as const, + remoteCwd: "/home/daytona/repos/main", workFolderHome: "/home/daytona" }; + expect(adapterExecutionTargetManagedHomeDir(target, "/private/run-runtime")).toBe("/home/daytona"); + expect(adapterExecutionTargetManagedHomeDir(target, null)).toBe("/home/daytona"); + expect(adapterExecutionTargetUsesManagedHome(target)).toBe(true); + expect(adapterExecutionTargetManagedHomeDir({ ...target, workFolderHome: undefined }, "/private/run-runtime")) + .toBe("/private/run-runtime"); + expect(adapterExecutionTargetManagedHomeDir(null, "/private/run-runtime")).toBeNull(); + expect(adapterExecutionTargetManagedHomeDir({ kind: "local" }, "/private/run-runtime")).toBeNull(); + }); + it("keeps managed homes disabled for both local and SSH targets", () => { expect(adapterExecutionTargetUsesManagedHome(null)).toBe(false); expect(adapterExecutionTargetUsesManagedHome({ diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index e03bda3629..1ae721da9d 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -441,6 +441,15 @@ export function adapterExecutionTargetUsesManagedHome( return target?.kind === "remote" && target.transport === "sandbox"; } +/** Resolve the sandbox home without replacing a host-bound work-folder home. */ +export function adapterExecutionTargetManagedHomeDir( + target: AdapterExecutionTarget | null | undefined, + runtimeRootDir: string | null | undefined, +): string | null { + if (target?.kind !== "remote" || target.transport !== "sandbox") return null; + return target.workFolderHome ?? runtimeRootDir ?? null; +} + /** * Read the per-run duplex bridge kill switch off a target. Only a sandbox * target with `enableSandboxDuplexBridge` set to `true` returns `true`. Every diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index eed1a926a5..e6a5fde682 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -9,7 +9,7 @@ import { overrideAdapterExecutionTargetRemoteCwd, adapterExecutionTargetSessionIdentity, adapterExecutionTargetSessionMatches, - adapterExecutionTargetUsesManagedHome, + adapterExecutionTargetManagedHomeDir, adapterExecutionTargetUsesPaperclipBridge, describeAdapterExecutionTarget, ensureAdapterExecutionTargetCommandResolvable, @@ -396,13 +396,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise { }; }); +import * as targets from "@paperclipai/adapter-utils/execution-target"; import { execute } from "./execute.js"; describe("gemini remote execution", () => { const cleanupDirs: string[] = []; afterEach(async () => { + vi.restoreAllMocks(); vi.clearAllMocks(); while (cleanupDirs.length > 0) { const dir = cleanupDirs.pop(); @@ -230,12 +232,20 @@ describe("gemini remote execution", () => { expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); }); - it("pre-selects gemini-api-key auth in the managed HOME for sandbox execution", async () => { + it.each(["/home/daytona", undefined])("pre-selects gemini-api-key auth in the sandbox HOME (%s)", async (workFolderHome) => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-sandbox-")); cleanupDirs.push(rootDir); const workspaceDir = path.join(rootDir, "workspace"); await mkdir(workspaceDir, { recursive: true }); + // The command recorder does not implement archive transfer; this test + // exercises CLI authentication paths, while transport tests cover syncing. + vi.spyOn(targets, "prepareAdapterExecutionTargetRuntime").mockResolvedValue({ + target: { kind: "remote", transport: "sandbox", remoteCwd: "/remote/workspace", workFolderHome }, + runtimeRootDir: "/remote/workspace/.paperclip-runtime/gemini", + workspaceRemoteDir: "/remote/workspace", assetDirs: {}, additionalSourceDirs: {}, + additionalSourceFailures: [], workspaceSyncSnapshot: null, restoreWorkspace: async () => {}, + }); const geminiOutput = [ JSON.stringify({ type: "system", subtype: "init", session_id: "gemini-session-2", model: "gemini-2.5-pro" }), JSON.stringify({ type: "message", role: "assistant", content: "hello" }), @@ -288,6 +298,7 @@ describe("gemini remote execution", () => { kind: "remote", transport: "sandbox", providerKey: "kubernetes", + workFolderHome, remoteCwd: "/remote/workspace", runner: { execute: runnerExecute }, }, @@ -300,8 +311,7 @@ describe("gemini remote execution", () => { const settingsWrite = runnerScripts.find((script) => script.includes(".gemini/settings.json")); expect(settingsWrite).toBeDefined(); expect(settingsWrite).toContain("gemini-api-key"); - // The managed HOME lives under the per-run runtime root, never a real home. - expect(settingsWrite).toContain(".paperclip-runtime"); + expect(settingsWrite).toContain(workFolderHome ?? "/remote/workspace/.paperclip-runtime/gemini"); }); it("resumes saved Gemini sessions for remote SSH execution only when the identity matches", async () => { diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts index 1d267fdf2e..af8ab02dfd 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -10,7 +10,7 @@ import { overrideAdapterExecutionTargetRemoteCwd, adapterExecutionTargetSessionIdentity, adapterExecutionTargetSessionMatches, - adapterExecutionTargetUsesManagedHome, + adapterExecutionTargetManagedHomeDir, adapterExecutionTargetUsesPaperclipBridge, describeAdapterExecutionTarget, ensureAdapterExecutionTargetCommandResolvable, @@ -398,11 +398,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise { }; }); +import * as targets from "@paperclipai/adapter-utils/execution-target"; import { execute } from "./execute.js"; describe("pi remote execution", () => { @@ -104,6 +105,41 @@ describe("pi remote execution", () => { } }); + it.each(["/home/daytona", undefined])("launches Pi with the correct sandbox home (%s)", async (workFolderHome) => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pi-sandbox-home-")); + cleanupDirs.push(rootDir); + const runtimeRootDir = "/home/daytona/repos/main/.paperclip-runtime/runs/home-test/pi"; + const processSpy = vi.spyOn(targets, "runAdapterExecutionTargetProcess").mockResolvedValue({ + exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "", pid: null, + startedAt: new Date().toISOString(), + }); + vi.spyOn(targets, "prepareAdapterExecutionTargetRuntime").mockResolvedValue({ + runtimeRootDir, workspaceRemoteDir: "/home/daytona/repos/main", assetDirs: {}, + restoreWorkspace: async () => {}, target: { kind: "remote", transport: "sandbox", remoteCwd: "/home/daytona/repos/main", workFolderHome }, + additionalSourceDirs: {}, additionalSourceFailures: [], workspaceSyncSnapshot: null, + }); + vi.spyOn(targets, "ensureAdapterExecutionTargetCommandResolvable").mockResolvedValue(undefined); + vi.spyOn(targets, "resolveAdapterExecutionTargetCommandForLogs").mockResolvedValue("pi"); + vi.spyOn(targets, "ensureAdapterExecutionTargetFile").mockResolvedValue(undefined); + try { + await execute({ + runId: "home-test", + agent: { id: "agent-1", companyId: "company-1", name: "Pi Builder", adapterType: "pi_local", adapterConfig: {} }, + runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, + config: { command: "pi", model: "openai/gpt-5.4-mini", env: { HOME: "/home/daytona" } }, + context: { paperclipWorkspace: { cwd: rootDir, source: "project_primary" } }, + executionTarget: { kind: "remote", transport: "sandbox", remoteCwd: "/home/daytona/repos/main", workFolderHome }, + onLog: async () => {}, + }); + expect(processSpy).toHaveBeenCalled(); + const options = processSpy.mock.calls[0][4]; + expect(options.env.HOME).toBe(workFolderHome ?? runtimeRootDir); + expect(options.env.PAPERCLIP_WORKSPACE_CWD).toBe("/home/daytona/repos/main"); + } finally { + vi.restoreAllMocks(); + } + }); + it("prepares the workspace, syncs Pi skills, and restores workspace changes for remote SSH execution", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pi-remote-")); cleanupDirs.push(rootDir); diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index 398bcb7205..4aca7d918c 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -9,7 +9,7 @@ import { overrideAdapterExecutionTargetRemoteCwd, adapterExecutionTargetSessionIdentity, adapterExecutionTargetSessionMatches, - adapterExecutionTargetUsesManagedHome, + adapterExecutionTargetManagedHomeDir, adapterExecutionTargetUsesPaperclipBridge, describeAdapterExecutionTarget, ensureAdapterExecutionTargetCommandResolvable, @@ -458,8 +458,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise