From 12cb7b40fdc85c3a98755ea94ad9c2930eebbab6 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Thu, 7 May 2026 14:44:45 -0700 Subject: [PATCH] Harden remote workspace sync and restore flows (#5444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - When an agent runs against a remote target, Paperclip syncs the workspace out to the remote at run start and restores changes back to the local workspace at run end > - The previous restore flow naïvely overwrote local files with whatever the remote returned, so files that the remote run never touched but had timestamp/mode drift could be needlessly rewritten — and a single static `refs/paperclip/ssh-sync/imported` ref made concurrent SSH workspace exports race on the same git ref > - This pull request adds a `workspace-restore-merge` module that diffs a pre-run snapshot against the post-run remote state and only writes back files the remote actually changed; SSH workspace exports now use a per-import unique ref so concurrent runs can't trample each other > - Every adapter's execute path threads the snapshot through `prepareAdapterExecutionTargetRuntime` so the merge has the baseline it needs > - The benefit is workspace restores no longer churn untouched files, and concurrent SSH runs no longer collide on the import ref ## What Changed - `packages/adapter-utils/src/workspace-restore-merge.{ts,test.ts}`: new module — directory snapshot (kind/mode/sha256/symlink target) plus snapshot-aware merge that writes only the files the remote changed - `packages/adapter-utils/src/ssh.ts`: SSH workspace export uses a per-import unique ref (`refs/paperclip/ssh-sync/imported/`); restore goes through the new merge helper; `ssh-fixture.test.ts` covers the unique-ref + merge paths - `packages/adapter-utils/src/sandbox-managed-runtime.ts` + `remote-managed-runtime.ts`: thread the snapshot/merge through the sandbox and SSH paths - `packages/adapter-utils/src/server-utils.{ts,test.ts}` + `execution-target.ts`: helpers for capturing the pre-run snapshot; `prepareAdapterExecutionTargetRuntime` gains required `runId` and optional `workspaceRemoteDir`, and returns the realized `workspaceRemoteDir` - Each adapter's `execute.ts` (acpx, claude, codex, cursor, gemini, opencode, pi) takes the snapshot at run start and passes it through to the runtime restore - Remote execute test mocks updated to match the new `prepareWorkspaceForSshExecution` return shape and the per-run `${managedRemoteWorkspace}` cwd subdirectory ## Verification - `pnpm vitest run --no-coverage --project @paperclipai/adapter-utils --project @paperclipai/adapter-acpx-local --project @paperclipai/adapter-claude-local --project @paperclipai/adapter-codex-local --project @paperclipai/adapter-cursor-local --project @paperclipai/adapter-gemini-local --project @paperclipai/adapter-opencode-local --project @paperclipai/adapter-pi-local` — 196/196 passing - `pnpm typecheck` clean across the workspace ## Risks Medium. The restore path now writes a strict subset of what it previously did — files the remote did not touch are no longer rewritten. If any flow was relying on a touch-without-content-change being copied back (timestamp or permission propagation only), that behavior is now skipped. Snapshot capture adds an O(N-files-in-workspace) hash pass at run start; the cost is bounded by the existing exclude list. The `runId` parameter on `prepareAdapterExecutionTargetRuntime` is now required — every in-tree caller is updated; out-of-tree adapter authors need to pass it. ## Model Used Claude Opus 4.7 (1M context) ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable — new module + every adapter execute path covered - [x] If this change affects the UI, I have included before/after screenshots — N/A (no UI) - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- .../adapter-utils/src/execution-target.ts | 36 +++ .../src/remote-managed-runtime.ts | 21 +- .../src/sandbox-managed-runtime.test.ts | 2 +- .../src/sandbox-managed-runtime.ts | 10 +- .../adapter-utils/src/server-utils.test.ts | 95 +++++++ packages/adapter-utils/src/server-utils.ts | 93 +++++++ .../adapter-utils/src/ssh-fixture.test.ts | 244 ++++++++++++++++- packages/adapter-utils/src/ssh.ts | 161 ++++++++++- .../src/workspace-restore-merge.test.ts | 61 +++++ .../src/workspace-restore-merge.ts | 257 ++++++++++++++++++ .../adapters/acpx-local/src/server/execute.ts | 12 +- .../src/server/execute.remote.test.ts | 34 ++- .../claude-local/src/server/execute.ts | 61 ++++- .../src/server/execute.remote.test.ts | 31 ++- .../codex-local/src/server/execute.ts | 45 ++- .../src/server/execute.remote.test.ts | 6 +- .../cursor-local/src/server/execute.ts | 1 + .../src/server/execute.remote.test.ts | 20 +- .../gemini-local/src/server/execute.ts | 52 ++-- .../src/server/execute.remote.test.ts | 30 +- .../opencode-local/src/server/execute.ts | 53 ++-- .../src/server/execute.remote.test.ts | 35 +-- .../adapters/pi-local/src/server/execute.ts | 57 ++-- 23 files changed, 1234 insertions(+), 183 deletions(-) create mode 100644 packages/adapter-utils/src/workspace-restore-merge.test.ts create mode 100644 packages/adapter-utils/src/workspace-restore-merge.ts diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index b51780d5..091b988e 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -67,6 +67,7 @@ export type AdapterManagedRuntimeAsset = RemoteManagedRuntimeAsset; export interface PreparedAdapterExecutionTargetRuntime { target: AdapterExecutionTarget; + workspaceRemoteDir: string | null; runtimeRootDir: string | null; assetDirs: Record; restoreWorkspace(): Promise; @@ -167,6 +168,33 @@ export function adapterExecutionTargetRemoteCwd( 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, @@ -858,9 +886,11 @@ export function readAdapterExecutionTarget(input: { } export async function prepareAdapterExecutionTargetRuntime(input: { + runId: string; target: AdapterExecutionTarget | null | undefined; adapterKey: string; workspaceLocalDir: string; + workspaceRemoteDir?: string; workspaceExclude?: string[]; preserveAbsentOnRestore?: string[]; assets?: AdapterManagedRuntimeAsset[]; @@ -872,6 +902,7 @@ export async function prepareAdapterExecutionTargetRuntime(input: { if (target.kind === "local") { return { target, + workspaceRemoteDir: null, runtimeRootDir: null, assetDirs: {}, restoreWorkspace: async () => {}, @@ -881,12 +912,15 @@ export async function prepareAdapterExecutionTargetRuntime(input: { 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, @@ -904,6 +938,7 @@ export async function prepareAdapterExecutionTargetRuntime(input: { }, adapterKey: input.adapterKey, workspaceLocalDir: input.workspaceLocalDir, + workspaceRemoteDir: input.workspaceRemoteDir, workspaceExclude: input.workspaceExclude, preserveAbsentOnRestore: input.preserveAbsentOnRestore, assets: input.assets, @@ -912,6 +947,7 @@ export async function prepareAdapterExecutionTargetRuntime(input: { }); return { target, + workspaceRemoteDir: prepared.workspaceRemoteDir, runtimeRootDir: prepared.runtimeRootDir, assetDirs: prepared.assetDirs, restoreWorkspace: prepared.restoreWorkspace, diff --git a/packages/adapter-utils/src/remote-managed-runtime.ts b/packages/adapter-utils/src/remote-managed-runtime.ts index 18abdcf9..900824b4 100644 --- a/packages/adapter-utils/src/remote-managed-runtime.ts +++ b/packages/adapter-utils/src/remote-managed-runtime.ts @@ -5,6 +5,7 @@ import { restoreWorkspaceFromSshExecution, syncDirectoryToSsh, } from "./ssh.js"; +import { captureDirectorySnapshot } from "./workspace-restore-merge.js"; export interface RemoteManagedRuntimeAsset { key: string; @@ -63,19 +64,31 @@ export function remoteExecutionSessionMatches(saved: unknown, current: SshRemote export async function prepareRemoteManagedRuntime(input: { spec: SshRemoteExecutionSpec; + runId: string; adapterKey: string; workspaceLocalDir: string; workspaceRemoteDir?: string; assets?: RemoteManagedRuntimeAsset[]; }): Promise { - const workspaceRemoteDir = input.workspaceRemoteDir ?? input.spec.remoteCwd; + const baseWorkspaceRemoteDir = input.workspaceRemoteDir ?? input.spec.remoteCwd; + const workspaceRemoteDir = path.posix.join( + baseWorkspaceRemoteDir, + ".paperclip-runtime", + "runs", + input.runId, + "workspace", + ); const runtimeRootDir = path.posix.join(workspaceRemoteDir, ".paperclip-runtime", input.adapterKey); - await prepareWorkspaceForSshExecution({ + const preparedWorkspace = await prepareWorkspaceForSshExecution({ spec: input.spec, localDir: input.workspaceLocalDir, remoteDir: workspaceRemoteDir, }); + const restoreExclude = preparedWorkspace.gitBacked ? [".git", ".paperclip-runtime"] : [".paperclip-runtime"]; + const baselineSnapshot = await captureDirectorySnapshot(input.workspaceLocalDir, { + exclude: restoreExclude, + }); const assetDirs: Record = {}; try { @@ -95,6 +108,8 @@ export async function prepareRemoteManagedRuntime(input: { spec: input.spec, localDir: input.workspaceLocalDir, remoteDir: workspaceRemoteDir, + baselineSnapshot, + restoreGitHistory: preparedWorkspace.gitBacked, }); throw error; } @@ -110,6 +125,8 @@ export async function prepareRemoteManagedRuntime(input: { spec: input.spec, localDir: input.workspaceLocalDir, remoteDir: workspaceRemoteDir, + baselineSnapshot, + restoreGitHistory: preparedWorkspace.gitBacked, }); }, }; diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts index 4f8532c1..bbaa3426 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts @@ -126,7 +126,7 @@ describe("sandbox managed runtime", () => { await expect(readFile(path.join(localWorkspaceDir, "README.md"), "utf8")).resolves.toBe("remote workspace\n"); await expect(readFile(path.join(localWorkspaceDir, "remote-only.txt"), "utf8")).resolves.toBe("sync back\n"); - await expect(readFile(path.join(localWorkspaceDir, "local-stale.txt"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + await expect(readFile(path.join(localWorkspaceDir, "local-stale.txt"), "utf8")).resolves.toBe("remove\n"); await expect(readFile(path.join(localWorkspaceDir, ".claude", "settings.json"), "utf8")).resolves.toBe("{\"local\":true}\n"); await expect(readFile(path.join(localWorkspaceDir, ".paperclip-runtime", "state.json"), "utf8")).resolves.toBe("{}\n"); }); diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.ts b/packages/adapter-utils/src/sandbox-managed-runtime.ts index 762c5971..a5d3d5db 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.ts @@ -3,6 +3,7 @@ import { constants as fsConstants, promises as fs } from "node:fs"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; +import { captureDirectorySnapshot, mergeDirectoryWithBaseline } from "./workspace-restore-merge.js"; const execFile = promisify(execFileCallback); @@ -248,6 +249,9 @@ export async function prepareSandboxManagedRuntime(input: { }): Promise { const workspaceRemoteDir = input.workspaceRemoteDir ?? input.spec.remoteCwd; const runtimeRootDir = path.posix.join(workspaceRemoteDir, ".paperclip-runtime", input.adapterKey); + const baselineSnapshot = await captureDirectorySnapshot(input.workspaceLocalDir, { + exclude: [...new Set([".paperclip-runtime", ...(input.preserveAbsentOnRestore ?? []), ...(input.workspaceExclude ?? [])])], + }); await withTempDir("paperclip-sandbox-sync-", async (tempDir) => { const workspaceTarPath = path.join(tempDir, "workspace.tar"); @@ -326,8 +330,10 @@ export async function prepareSandboxManagedRuntime(input: { archivePath: localArchivePath, localDir: extractedDir, }); - await mirrorDirectory(extractedDir, input.workspaceLocalDir, { - preserveAbsent: [".paperclip-runtime", ...(input.preserveAbsentOnRestore ?? [])], + await mergeDirectoryWithBaseline({ + baseline: baselineSnapshot, + sourceDir: extractedDir, + targetDir: input.workspaceLocalDir, }); }); }, diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index 1ced4ce8..e798ba58 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -9,11 +9,13 @@ import { buildInvocationEnvForLogs, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, materializePaperclipSkillCopy, + refreshPaperclipWorkspaceEnvForExecution, renderPaperclipWakePrompt, runningProcesses, runChildProcess, sanitizeSshRemoteEnv, shapePaperclipWorkspaceEnvForExecution, + rewriteWorkspaceCwdEnvVarsForExecution, stringifyPaperclipWakePayload, } from "./server-utils.js"; @@ -810,6 +812,99 @@ describe("shapePaperclipWorkspaceEnvForExecution", () => { }); }); +describe("rewriteWorkspaceCwdEnvVarsForExecution", () => { + it("rewrites custom *_WORKSPACE_CWD env vars for remote execution", () => { + const env = rewriteWorkspaceCwdEnvVarsForExecution({ + workspaceCwd: "/host/workspace", + executionCwd: "/remote/workspace", + executionTargetIsRemote: true, + env: { + QA_PROJECT_WORKSPACE_CWD: "/host/workspace", + RANDOM_WORKSPACE_CWD: "/host/workspace", + OTHER_ENV: "/host/workspace", + }, + }); + + expect(env).toEqual({ + QA_PROJECT_WORKSPACE_CWD: "/remote/workspace", + RANDOM_WORKSPACE_CWD: "/remote/workspace", + OTHER_ENV: "/host/workspace", + }); + }); + + it("does not rewrite matching values for local execution", () => { + const env = rewriteWorkspaceCwdEnvVarsForExecution({ + workspaceCwd: "/host/workspace", + executionCwd: "/remote/workspace", + executionTargetIsRemote: false, + env: { + QA_PROJECT_WORKSPACE_CWD: "/host/workspace", + RANDOM_WORKSPACE_CWD_TOKEN: "/host/workspace", + }, + }); + + expect(env).toEqual({ + QA_PROJECT_WORKSPACE_CWD: "/host/workspace", + RANDOM_WORKSPACE_CWD_TOKEN: "/host/workspace", + }); + }); +}); + +describe("refreshPaperclipWorkspaceEnvForExecution", () => { + it("rewrites Paperclip workspace env to the prepared remote runtime cwd", () => { + const env: Record = { + PAPERCLIP_WORKSPACE_CWD: "/remote/workspace", + PAPERCLIP_WORKSPACE_WORKTREE_PATH: "/host/worktree", + PAPERCLIP_WORKSPACES_JSON: JSON.stringify([ + { workspaceId: "workspace-1", cwd: "/remote/workspace" }, + { workspaceId: "workspace-2", cwd: "/tmp/other" }, + ]), + QA_PROJECT_WORKSPACE_CWD: "/remote/workspace", + }; + + const shaped = refreshPaperclipWorkspaceEnvForExecution({ + env, + envConfig: { + QA_PROJECT_WORKSPACE_CWD: "/host/workspace", + }, + workspaceCwd: "/host/workspace", + workspaceWorktreePath: "/host/worktree", + workspaceHints: [ + { workspaceId: "workspace-1", cwd: "/host/workspace" }, + { workspaceId: "workspace-2", cwd: "/tmp/other" }, + ], + executionTargetIsRemote: true, + executionCwd: "/remote/workspace/.paperclip-runtime/runs/run-1/workspace", + }); + + expect(shaped).toEqual({ + workspaceCwd: "/remote/workspace/.paperclip-runtime/runs/run-1/workspace", + workspaceWorktreePath: null, + workspaceHints: [ + { + workspaceId: "workspace-1", + cwd: "/remote/workspace/.paperclip-runtime/runs/run-1/workspace", + }, + { + workspaceId: "workspace-2", + }, + ], + }); + expect(env.PAPERCLIP_WORKSPACE_CWD).toBe("/remote/workspace/.paperclip-runtime/runs/run-1/workspace"); + expect(env.PAPERCLIP_WORKSPACE_WORKTREE_PATH).toBeUndefined(); + expect(env.QA_PROJECT_WORKSPACE_CWD).toBe("/remote/workspace/.paperclip-runtime/runs/run-1/workspace"); + expect(JSON.parse(env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([ + { + workspaceId: "workspace-1", + cwd: "/remote/workspace/.paperclip-runtime/runs/run-1/workspace", + }, + { + workspaceId: "workspace-2", + }, + ]); + }); +}); + describe("appendWithByteCap", () => { it("keeps valid UTF-8 when trimming through multibyte text", () => { const output = appendWithByteCap("prefix ", "hello — world", 7); diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 646a6be6..42c77aa7 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -999,6 +999,99 @@ export function shapePaperclipWorkspaceEnvForExecution(input: { }; } +export function rewriteWorkspaceCwdEnvVarsForExecution(input: { + env: Record; + workspaceCwd?: string | null; + executionCwd?: string | null; + executionTargetIsRemote?: boolean; +}): Record { + const nextEnv = Object.fromEntries( + Object.entries(input.env) + .filter((entry): entry is [string, string] => typeof entry[1] === "string"), + ) as Record; + const localWorkspaceCwd = typeof input.workspaceCwd === "string" && input.workspaceCwd.trim().length > 0 + ? path.resolve(input.workspaceCwd) + : null; + const remoteWorkspaceCwd = typeof input.executionCwd === "string" && input.executionCwd.trim().length > 0 + ? path.resolve(input.executionCwd) + : null; + + if (!input.executionTargetIsRemote || !localWorkspaceCwd || !remoteWorkspaceCwd) { + return nextEnv; + } + + for (const [key, value] of Object.entries(nextEnv)) { + if (!key.endsWith("_WORKSPACE_CWD")) continue; + const trimmed = value.trim(); + if (!trimmed) continue; + if (path.resolve(trimmed) !== localWorkspaceCwd) continue; + nextEnv[key] = remoteWorkspaceCwd; + } + + return nextEnv; +} + +export function refreshPaperclipWorkspaceEnvForExecution(input: { + env: Record; + envConfig?: Record; + workspaceCwd?: string | null; + workspaceSource?: string | null; + workspaceStrategy?: string | null; + workspaceId?: string | null; + workspaceRepoUrl?: string | null; + workspaceRepoRef?: string | null; + workspaceBranch?: string | null; + workspaceWorktreePath?: string | null; + workspaceHints?: Array>; + agentHome?: string | null; + executionTargetIsRemote?: boolean; + executionCwd?: string | null; +}): { + workspaceCwd: string | null; + workspaceWorktreePath: string | null; + workspaceHints: Array>; +} { + const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({ + workspaceCwd: input.workspaceCwd, + workspaceWorktreePath: input.workspaceWorktreePath, + workspaceHints: input.workspaceHints, + executionTargetIsRemote: input.executionTargetIsRemote, + executionCwd: input.executionCwd, + }); + + delete input.env.PAPERCLIP_WORKSPACE_CWD; + delete input.env.PAPERCLIP_WORKSPACE_WORKTREE_PATH; + delete input.env.PAPERCLIP_WORKSPACES_JSON; + + applyPaperclipWorkspaceEnv(input.env, { + workspaceCwd: shapedWorkspaceEnv.workspaceCwd, + workspaceSource: input.workspaceSource, + workspaceStrategy: input.workspaceStrategy, + workspaceId: input.workspaceId, + workspaceRepoUrl: input.workspaceRepoUrl, + workspaceRepoRef: input.workspaceRepoRef, + workspaceBranch: input.workspaceBranch, + workspaceWorktreePath: shapedWorkspaceEnv.workspaceWorktreePath, + agentHome: input.agentHome, + }); + + if (shapedWorkspaceEnv.workspaceHints.length > 0) { + input.env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(shapedWorkspaceEnv.workspaceHints); + } + + const shapedEnvConfig = rewriteWorkspaceCwdEnvVarsForExecution({ + env: input.envConfig ?? {}, + workspaceCwd: input.workspaceCwd, + executionCwd: shapedWorkspaceEnv.workspaceCwd, + executionTargetIsRemote: input.executionTargetIsRemote, + }); + for (const [key, value] of Object.entries(shapedEnvConfig)) { + input.env[key] = value; + } + + return shapedWorkspaceEnv; +} + export function sanitizeInheritedPaperclipEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...baseEnv }; for (const key of Object.keys(env)) { diff --git a/packages/adapter-utils/src/ssh-fixture.test.ts b/packages/adapter-utils/src/ssh-fixture.test.ts index 7ca4a657..9c33ba41 100644 --- a/packages/adapter-utils/src/ssh-fixture.test.ts +++ b/packages/adapter-utils/src/ssh-fixture.test.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -15,6 +15,7 @@ import { startSshEnvLabFixture, stopSshEnvLabFixture, } from "./ssh.js"; +import { prepareRemoteManagedRuntime } from "./remote-managed-runtime.js"; async function git(cwd: string, args: string[]): Promise { return await new Promise((resolve, reject) => { @@ -308,4 +309,245 @@ describe("ssh env-lab fixture", () => { expect(await git(localRepo, ["status", "--short"])).toContain("M tracked.txt"); expect(await git(localRepo, ["status", "--short"])).not.toContain("._tracked.txt"); }); + + it("preserves both concurrent SSH restores in a shared git workspace", async () => { + const support = await getSshEnvLabSupport(); + if (!support.supported) { + console.warn( + `Skipping concurrent SSH restore test: ${support.reason ?? "unsupported environment"}`, + ); + return; + } + + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-")); + cleanupDirs.push(rootDir); + const statePath = path.join(rootDir, "state.json"); + const localRepo = path.join(rootDir, "local-workspace"); + + await mkdir(localRepo, { recursive: true }); + await git(localRepo, ["init", "-b", "main"]); + await git(localRepo, ["config", "user.name", "Paperclip Test"]); + await git(localRepo, ["config", "user.email", "test@paperclip.dev"]); + await writeFile(path.join(localRepo, "tracked.txt"), "base\n", "utf8"); + await git(localRepo, ["add", "tracked.txt"]); + await git(localRepo, ["commit", "-m", "initial"]); + + const started = await startSshEnvLabFixture({ statePath }); + const config = await buildSshEnvLabFixtureConfig(started); + const spec = { + ...config, + remoteCwd: started.workspaceDir, + } as const; + + const preparedA = await prepareRemoteManagedRuntime({ + spec, + runId: "run-a", + adapterKey: "test-adapter", + workspaceLocalDir: localRepo, + }); + const preparedB = await prepareRemoteManagedRuntime({ + spec, + runId: "run-b", + adapterKey: "test-adapter", + workspaceLocalDir: localRepo, + }); + + expect(preparedA.workspaceRemoteDir).not.toBe(preparedB.workspaceRemoteDir); + + await runSshCommand( + config, + `sh -lc 'printf "from run a\\n" > ${JSON.stringify(path.posix.join(preparedA.workspaceRemoteDir, "run-a.txt"))}'`, + { timeoutMs: 30_000, maxBuffer: 256 * 1024 }, + ); + await runSshCommand( + config, + `sh -lc 'printf "from run b\\n" > ${JSON.stringify(path.posix.join(preparedB.workspaceRemoteDir, "run-b.txt"))}'`, + { timeoutMs: 30_000, maxBuffer: 256 * 1024 }, + ); + + await Promise.all([ + preparedA.restoreWorkspace(), + preparedB.restoreWorkspace(), + ]); + + await expect(readFile(path.join(localRepo, "run-a.txt"), "utf8")).resolves.toBe("from run a\n"); + await expect(readFile(path.join(localRepo, "run-b.txt"), "utf8")).resolves.toBe("from run b\n"); + }); + + it("preserves nested per-run files across sequential SSH restores with stale baselines", async () => { + const support = await getSshEnvLabSupport(); + if (!support.supported) { + console.warn( + `Skipping sequential nested SSH restore test: ${support.reason ?? "unsupported environment"}`, + ); + return; + } + + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-")); + cleanupDirs.push(rootDir); + const statePath = path.join(rootDir, "state.json"); + const localRepo = path.join(rootDir, "local-workspace"); + + await mkdir(localRepo, { recursive: true }); + await git(localRepo, ["init", "-b", "main"]); + await git(localRepo, ["config", "user.name", "Paperclip Test"]); + await git(localRepo, ["config", "user.email", "test@paperclip.dev"]); + await writeFile(path.join(localRepo, "tracked.txt"), "base\n", "utf8"); + await git(localRepo, ["add", "tracked.txt"]); + await git(localRepo, ["commit", "-m", "initial"]); + + const started = await startSshEnvLabFixture({ statePath }); + const config = await buildSshEnvLabFixtureConfig(started); + const spec = { + ...config, + remoteCwd: started.workspaceDir, + } as const; + + const preparedA = await prepareRemoteManagedRuntime({ + spec, + runId: "run-a", + adapterKey: "test-adapter", + workspaceLocalDir: localRepo, + }); + const preparedB = await prepareRemoteManagedRuntime({ + spec, + runId: "run-b", + adapterKey: "test-adapter", + workspaceLocalDir: localRepo, + }); + + await runSshCommand( + config, + `sh -lc 'mkdir -p ${JSON.stringify(path.posix.join(preparedA.workspaceRemoteDir, "manual-qa/environment-matrix/ssh"))} && printf "from run a\\n" > ${JSON.stringify(path.posix.join(preparedA.workspaceRemoteDir, "manual-qa/environment-matrix/ssh/claude_local.md"))}'`, + { timeoutMs: 30_000, maxBuffer: 256 * 1024 }, + ); + await runSshCommand( + config, + `sh -lc 'mkdir -p ${JSON.stringify(path.posix.join(preparedB.workspaceRemoteDir, "manual-qa/environment-matrix/ssh"))} && printf "from run b\\n" > ${JSON.stringify(path.posix.join(preparedB.workspaceRemoteDir, "manual-qa/environment-matrix/ssh/codex_local.md"))}'`, + { timeoutMs: 30_000, maxBuffer: 256 * 1024 }, + ); + + await preparedA.restoreWorkspace(); + await preparedB.restoreWorkspace(); + + await expect(readFile(path.join(localRepo, "manual-qa/environment-matrix/ssh/claude_local.md"), "utf8")).resolves + .toBe("from run a\n"); + await expect(readFile(path.join(localRepo, "manual-qa/environment-matrix/ssh/codex_local.md"), "utf8")).resolves + .toBe("from run b\n"); + }); + + it("round-trips remote git commits through the managed runtime restore path", async () => { + const support = await getSshEnvLabSupport(); + if (!support.supported) { + console.warn( + `Skipping managed-runtime SSH git round-trip test: ${support.reason ?? "unsupported environment"}`, + ); + return; + } + + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-")); + cleanupDirs.push(rootDir); + const statePath = path.join(rootDir, "state.json"); + const localRepo = path.join(rootDir, "local-workspace"); + + await mkdir(localRepo, { recursive: true }); + await git(localRepo, ["init", "-b", "main"]); + await git(localRepo, ["config", "user.name", "Paperclip Test"]); + await git(localRepo, ["config", "user.email", "test@paperclip.dev"]); + await writeFile(path.join(localRepo, "tracked.txt"), "base\n", "utf8"); + await git(localRepo, ["add", "tracked.txt"]); + await git(localRepo, ["commit", "-m", "initial"]); + + const started = await startSshEnvLabFixture({ statePath }); + const config = await buildSshEnvLabFixtureConfig(started); + const spec = { + ...config, + remoteCwd: started.workspaceDir, + } as const; + + const prepared = await prepareRemoteManagedRuntime({ + spec, + runId: "run-commit", + adapterKey: "test-adapter", + workspaceLocalDir: localRepo, + }); + + await runSshCommand( + config, + `sh -lc 'cd ${JSON.stringify(prepared.workspaceRemoteDir)} && git config user.name "Paperclip SSH" && git config user.email "ssh@paperclip.dev" && printf "committed\\n" > tracked.txt && git add tracked.txt && git commit -m "remote update" >/dev/null && printf "dirty remote\\n" > tracked.txt'`, + { timeoutMs: 30_000, maxBuffer: 256 * 1024 }, + ); + + await prepared.restoreWorkspace(); + + expect(await git(localRepo, ["log", "-1", "--pretty=%s"])).toBe("remote update"); + await expect(readFile(path.join(localRepo, "tracked.txt"), "utf8")).resolves.toBe("dirty remote\n"); + }); + + it("merges concurrent remote commits through the managed runtime restore path", async () => { + const support = await getSshEnvLabSupport(); + if (!support.supported) { + console.warn( + `Skipping concurrent managed-runtime SSH git merge test: ${support.reason ?? "unsupported environment"}`, + ); + return; + } + + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-fixture-")); + cleanupDirs.push(rootDir); + const statePath = path.join(rootDir, "state.json"); + const localRepo = path.join(rootDir, "local-workspace"); + + await mkdir(localRepo, { recursive: true }); + await git(localRepo, ["init", "-b", "main"]); + await git(localRepo, ["config", "user.name", "Paperclip Test"]); + await git(localRepo, ["config", "user.email", "test@paperclip.dev"]); + await writeFile(path.join(localRepo, "tracked.txt"), "base\n", "utf8"); + await git(localRepo, ["add", "tracked.txt"]); + await git(localRepo, ["commit", "-m", "initial"]); + + const started = await startSshEnvLabFixture({ statePath }); + const config = await buildSshEnvLabFixtureConfig(started); + const spec = { + ...config, + remoteCwd: started.workspaceDir, + } as const; + + const preparedA = await prepareRemoteManagedRuntime({ + spec, + runId: "run-commit-a", + adapterKey: "test-adapter", + workspaceLocalDir: localRepo, + }); + const preparedB = await prepareRemoteManagedRuntime({ + spec, + runId: "run-commit-b", + adapterKey: "test-adapter", + workspaceLocalDir: localRepo, + }); + + await runSshCommand( + config, + `sh -lc 'cd ${JSON.stringify(preparedA.workspaceRemoteDir)} && git config user.name "Paperclip SSH" && git config user.email "ssh@paperclip.dev" && printf "from run a\\n" > run-a.txt && git add run-a.txt && git commit -m "remote update a" >/dev/null'`, + { timeoutMs: 30_000, maxBuffer: 256 * 1024 }, + ); + await runSshCommand( + config, + `sh -lc 'cd ${JSON.stringify(preparedB.workspaceRemoteDir)} && git config user.name "Paperclip SSH" && git config user.email "ssh@paperclip.dev" && printf "from run b\\n" > run-b.txt && git add run-b.txt && git commit -m "remote update b" >/dev/null'`, + { timeoutMs: 30_000, maxBuffer: 256 * 1024 }, + ); + + await Promise.all([ + preparedA.restoreWorkspace(), + preparedB.restoreWorkspace(), + ]); + + await expect(readFile(path.join(localRepo, "run-a.txt"), "utf8")).resolves.toBe("from run a\n"); + await expect(readFile(path.join(localRepo, "run-b.txt"), "utf8")).resolves.toBe("from run b\n"); + expect(await git(localRepo, ["log", "-1", "--pretty=%s"])).toContain("Paperclip SSH sync merge"); + + const recentSubjects = await git(localRepo, ["log", "--pretty=%s", "-3"]); + expect(recentSubjects).toContain("remote update a"); + expect(recentSubjects).toContain("remote update b"); + }); }); diff --git a/packages/adapter-utils/src/ssh.ts b/packages/adapter-utils/src/ssh.ts index 5070461c..96923a28 100644 --- a/packages/adapter-utils/src/ssh.ts +++ b/packages/adapter-utils/src/ssh.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { execFile, spawn } from "node:child_process"; import { constants as fsConstants, createReadStream, createWriteStream, promises as fs } from "node:fs"; import net from "node:net"; @@ -5,6 +6,8 @@ import os from "node:os"; import path from "node:path"; import type { CommandManagedRuntimeRunner } from "./command-managed-runtime.js"; import type { RunProcessResult } from "./server-utils.js"; +import type { DirectorySnapshot } from "./workspace-restore-merge.js"; +import { mergeDirectoryWithBaseline } from "./workspace-restore-merge.js"; export interface SshConnectionConfig { host: string; @@ -596,7 +599,9 @@ async function importGitWorkspaceToSsh(input: { }): Promise { const bundleDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-bundle-")); const bundlePath = path.join(bundleDir, "workspace.bundle"); - const tempRef = "refs/paperclip/ssh-sync/import"; + // Per-import unique ref so concurrent imports against the same local repo + // can't race on `update-ref` between this run's update and bundle create. + const tempRef = `refs/paperclip/ssh-sync/import/${randomUUID()}`; try { await runLocalGit(input.localDir, ["update-ref", tempRef, input.snapshot.headCommit], { @@ -621,6 +626,8 @@ async function importGitWorkspaceToSsh(input: { : `git -C ${shellQuote(input.remoteDir)} -c advice.detachedHead=false checkout --force --detach ${shellQuote(input.snapshot.headCommit)} >/dev/null`, `git -C ${shellQuote(input.remoteDir)} reset --hard ${shellQuote(input.snapshot.headCommit)} >/dev/null`, `git -C ${shellQuote(input.remoteDir)} clean -fdx -e .paperclip-runtime >/dev/null`, + // Drop the per-import ref on the remote side too so it can't accumulate. + `git -C ${shellQuote(input.remoteDir)} update-ref -d ${shellQuote(tempRef)} >/dev/null 2>&1 || true`, ].join("\n"); await streamLocalFileToSsh({ @@ -641,10 +648,12 @@ async function exportGitWorkspaceFromSsh(input: { spec: SshRemoteExecutionSpec; remoteDir: string; localDir: string; -}): Promise { + importedRef?: string; + resetLocalWorkspace?: boolean; +}): Promise { const bundleDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-bundle-")); const bundlePath = path.join(bundleDir, "workspace.bundle"); - const importedRef = "refs/paperclip/ssh-sync/imported"; + const importedRef = input.importedRef ?? `refs/paperclip/ssh-sync/imported/${randomUUID()}`; try { const exportScript = [ @@ -668,19 +677,97 @@ async function exportGitWorkspaceFromSsh(input: { timeout: 60_000, maxBuffer: 1024 * 1024, }); - await runLocalGit(input.localDir, ["reset", "--hard", importedRef], { - timeout: 60_000, - maxBuffer: 1024 * 1024, - }); - } finally { - await runLocalGit(input.localDir, ["update-ref", "-d", importedRef], { + if (input.resetLocalWorkspace !== false) { + await runLocalGit(input.localDir, ["reset", "--hard", importedRef], { + timeout: 60_000, + maxBuffer: 1024 * 1024, + }); + } + const importedHead = await runLocalGit(input.localDir, ["rev-parse", importedRef], { timeout: 10_000, maxBuffer: 16 * 1024, - }).catch(() => undefined); + }); + return importedHead.stdout.trim(); + } finally { + if (input.resetLocalWorkspace !== false) { + await runLocalGit(input.localDir, ["update-ref", "-d", importedRef], { + timeout: 10_000, + maxBuffer: 16 * 1024, + }).catch(() => undefined); + } await fs.rm(bundleDir, { recursive: true, force: true }).catch(() => undefined); } } +async function integrateImportedGitHead(input: { + localDir: string; + importedHead: string; +}): Promise { + const snapshot = await readLocalGitWorkspaceSnapshot(input.localDir); + if (!snapshot) return; + + const currentHead = snapshot.headCommit; + if (!currentHead || currentHead === input.importedHead) return; + + const headRef = snapshot.branchName ? `refs/heads/${snapshot.branchName}` : "HEAD"; + const mergeBase = await runLocalGit(input.localDir, ["merge-base", currentHead, input.importedHead], { + timeout: 10_000, + maxBuffer: 16 * 1024, + }).catch(() => null); + const mergeBaseHead = mergeBase?.stdout.trim() ?? ""; + + if (mergeBaseHead === input.importedHead) { + return; + } + + if (mergeBaseHead === currentHead) { + await runLocalGit(input.localDir, ["update-ref", headRef, input.importedHead, currentHead], { + timeout: 10_000, + maxBuffer: 16 * 1024, + }); + return; + } + + let mergedTree; + try { + mergedTree = await runLocalGit(input.localDir, ["merge-tree", "--write-tree", currentHead, input.importedHead], { + timeout: 60_000, + maxBuffer: 256 * 1024, + }); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to merge concurrent SSH git histories for ${currentHead.slice(0, 12)} and ${input.importedHead.slice(0, 12)}: ${reason}`, + ); + } + const mergedTreeId = mergedTree.stdout.trim().split("\n")[0]?.trim() ?? ""; + if (!mergedTreeId) { + throw new Error("Failed to compute a merged git tree for SSH workspace restore."); + } + + const mergeCommit = await runLocalGit( + input.localDir, + [ + "commit-tree", + mergedTreeId, + "-p", + currentHead, + "-p", + input.importedHead, + "-m", + `Paperclip SSH sync merge ${input.importedHead.slice(0, 12)}`, + ], + { + timeout: 60_000, + maxBuffer: 64 * 1024, + }, + ); + await runLocalGit(input.localDir, ["update-ref", headRef, mergeCommit.stdout.trim(), currentHead], { + timeout: 10_000, + maxBuffer: 16 * 1024, + }); +} + async function clearRemoteDirectory(input: { spec: SshConnectionConfig; remoteDir: string; @@ -1117,7 +1204,7 @@ export async function prepareWorkspaceForSshExecution(input: { spec: SshRemoteExecutionSpec; localDir: string; remoteDir?: string; -}): Promise { +}): Promise<{ gitBacked: boolean }> { const remoteDir = input.remoteDir ?? input.spec.remoteCwd; const gitSnapshot = await readLocalGitWorkspaceSnapshot(input.localDir); @@ -1139,7 +1226,7 @@ export async function prepareWorkspaceForSshExecution(input: { remoteDir, deletedPaths: gitSnapshot.deletedPaths, }); - return; + return { gitBacked: true }; } await clearRemoteDirectory({ @@ -1153,14 +1240,64 @@ export async function prepareWorkspaceForSshExecution(input: { remoteDir, exclude: [".paperclip-runtime"], }); + return { gitBacked: false }; } export async function restoreWorkspaceFromSshExecution(input: { spec: SshRemoteExecutionSpec; localDir: string; remoteDir?: string; + baselineSnapshot?: DirectorySnapshot; + restoreGitHistory?: boolean; }): Promise { const remoteDir = input.remoteDir ?? input.spec.remoteCwd; + if (input.baselineSnapshot) { + const stagingDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-sync-back-")); + const importedRef = input.restoreGitHistory + ? `refs/paperclip/ssh-sync/imported/${randomUUID()}` + : null; + try { + const importedHead = input.restoreGitHistory + ? await exportGitWorkspaceFromSsh({ + spec: input.spec, + remoteDir, + localDir: input.localDir, + importedRef: importedRef ?? undefined, + resetLocalWorkspace: false, + }) + : null; + await syncDirectoryFromSsh({ + spec: input.spec, + remoteDir, + localDir: stagingDir, + exclude: input.baselineSnapshot.exclude, + }); + await mergeDirectoryWithBaseline({ + baseline: input.baselineSnapshot, + sourceDir: stagingDir, + targetDir: input.localDir, + // Git history advances via integrateImportedGitHead; the working tree + // still comes from the remote file snapshot so dirty remote edits win. + beforeApply: importedHead + ? async () => { + await integrateImportedGitHead({ + localDir: input.localDir, + importedHead, + }); + } + : undefined, + }); + } finally { + if (importedRef) { + await runLocalGit(input.localDir, ["update-ref", "-d", importedRef], { + timeout: 10_000, + maxBuffer: 16 * 1024, + }).catch(() => undefined); + } + await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined); + } + return; + } const gitSnapshot = await readLocalGitWorkspaceSnapshot(input.localDir); if (gitSnapshot) { diff --git a/packages/adapter-utils/src/workspace-restore-merge.test.ts b/packages/adapter-utils/src/workspace-restore-merge.test.ts new file mode 100644 index 00000000..32ad9582 --- /dev/null +++ b/packages/adapter-utils/src/workspace-restore-merge.test.ts @@ -0,0 +1,61 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { captureDirectorySnapshot, mergeDirectoryWithBaseline } from "./workspace-restore-merge.js"; + +describe("workspace restore merge", () => { + const cleanupDirs: string[] = []; + + afterEach(async () => { + while (cleanupDirs.length > 0) { + const dir = cleanupDirs.pop(); + if (!dir) continue; + await rm(dir, { recursive: true, force: true }).catch(() => undefined); + } + }); + + it("preserves sibling files when sequential stale-baseline restores create the same nested directory tree", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-restore-merge-")); + cleanupDirs.push(rootDir); + + const targetDir = path.join(rootDir, "target"); + const sourceADir = path.join(rootDir, "source-a"); + const sourceBDir = path.join(rootDir, "source-b"); + await mkdir(targetDir, { recursive: true }); + await mkdir(path.join(sourceADir, "manual-qa", "environment-matrix", "ssh"), { recursive: true }); + await mkdir(path.join(sourceBDir, "manual-qa", "environment-matrix", "ssh"), { recursive: true }); + + const baseline = await captureDirectorySnapshot(targetDir, { exclude: [] }); + + await writeFile( + path.join(sourceADir, "manual-qa", "environment-matrix", "ssh", "claude_local.md"), + "ssh claude\n", + "utf8", + ); + await writeFile( + path.join(sourceBDir, "manual-qa", "environment-matrix", "ssh", "codex_local.md"), + "ssh codex\n", + "utf8", + ); + + await mergeDirectoryWithBaseline({ + baseline, + sourceDir: sourceADir, + targetDir, + }); + await mergeDirectoryWithBaseline({ + baseline, + sourceDir: sourceBDir, + targetDir, + }); + + await expect( + readFile(path.join(targetDir, "manual-qa", "environment-matrix", "ssh", "claude_local.md"), "utf8"), + ).resolves.toBe("ssh claude\n"); + await expect( + readFile(path.join(targetDir, "manual-qa", "environment-matrix", "ssh", "codex_local.md"), "utf8"), + ).resolves.toBe("ssh codex\n"); + }); +}); diff --git a/packages/adapter-utils/src/workspace-restore-merge.ts b/packages/adapter-utils/src/workspace-restore-merge.ts new file mode 100644 index 00000000..df75b236 --- /dev/null +++ b/packages/adapter-utils/src/workspace-restore-merge.ts @@ -0,0 +1,257 @@ +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { constants as fsConstants, promises as fs } from "node:fs"; +import path from "node:path"; + +type SnapshotEntry = + | { kind: "dir" } + | { kind: "file"; mode: number; hash: string } + | { kind: "symlink"; target: string }; + +export interface DirectorySnapshot { + exclude: string[]; + entries: Map; +} + +function isRelativePathOrDescendant(relative: string, candidate: string): boolean { + return relative === candidate || relative.startsWith(`${candidate}/`); +} + +function shouldExclude(relative: string, exclude: readonly string[]): boolean { + return exclude.some((candidate) => isRelativePathOrDescendant(relative, candidate)); +} + +async function hashFile(filePath: string): Promise { + return await new Promise((resolve, reject) => { + const hash = createHash("sha256"); + const stream = createReadStream(filePath); + stream.on("data", (chunk) => hash.update(chunk)); + stream.on("error", reject); + stream.on("end", () => resolve(hash.digest("hex"))); + }); +} + +async function walkDirectory( + root: string, + exclude: readonly string[], + relative = "", + out: Map = new Map(), +): Promise> { + const current = relative ? path.join(root, relative) : root; + const entries = await fs.readdir(current, { withFileTypes: true }).catch(() => []); + entries.sort((left, right) => left.name.localeCompare(right.name)); + + for (const entry of entries) { + const nextRelative = relative ? path.posix.join(relative, entry.name) : entry.name; + if (shouldExclude(nextRelative, exclude)) continue; + + const fullPath = path.join(root, nextRelative); + const stats = await fs.lstat(fullPath); + if (stats.isDirectory()) { + out.set(nextRelative, { kind: "dir" }); + await walkDirectory(root, exclude, nextRelative, out); + continue; + } + + if (stats.isSymbolicLink()) { + out.set(nextRelative, { + kind: "symlink", + target: await fs.readlink(fullPath), + }); + continue; + } + + out.set(nextRelative, { + kind: "file", + mode: stats.mode, + hash: await hashFile(fullPath), + }); + } + + return out; +} + +async function readSnapshotEntry(root: string, relative: string): Promise { + const fullPath = path.join(root, relative); + let stats; + try { + stats = await fs.lstat(fullPath); + } catch { + return null; + } + + if (stats.isDirectory()) return { kind: "dir" }; + if (stats.isSymbolicLink()) { + return { + kind: "symlink", + target: await fs.readlink(fullPath), + }; + } + return { + kind: "file", + mode: stats.mode, + hash: await hashFile(fullPath), + }; +} + +function entriesMatch(left: SnapshotEntry | null | undefined, right: SnapshotEntry | null | undefined): boolean { + if (!left || !right) return false; + if (left.kind !== right.kind) return false; + if (left.kind === "dir") return true; + if (left.kind === "symlink" && right.kind === "symlink") { + return left.target === right.target; + } + if (left.kind === "file" && right.kind === "file") { + return left.mode === right.mode && left.hash === right.hash; + } + return false; +} + +async function isHolderAlive(lockDir: string): Promise { + try { + const raw = await fs.readFile(path.join(lockDir, "owner.json"), "utf8"); + const owner = JSON.parse(raw) as { pid?: unknown }; + const pid = typeof owner.pid === "number" && Number.isFinite(owner.pid) && owner.pid > 0 ? owner.pid : null; + if (pid === null) { + // Owner record is unparseable / missing pid — treat as stale. + return false; + } + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + } catch { + // owner.json missing or unreadable — treat as stale. + return false; + } +} + +async function acquireDirectoryMergeLock(lockDir: string): Promise<() => Promise> { + const deadline = Date.now() + 30_000; + while (true) { + try { + await fs.mkdir(lockDir); + await fs.writeFile( + path.join(lockDir, "owner.json"), + `${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`, + "utf8", + ); + return async () => { + await fs.rm(lockDir, { recursive: true, force: true }).catch(() => undefined); + }; + } catch (error) { + const code = error && typeof error === "object" ? (error as { code?: unknown }).code : null; + if (code !== "EEXIST") throw error; + // Stale-lock detection: if the owner PID is dead (SIGKILL / OOM / crash), + // the lockDir would otherwise persist forever and stall restores. Mirror + // the materializePaperclipSkillCopy lock pattern — remove and retry. + if (!(await isHolderAlive(lockDir))) { + await fs.rm(lockDir, { recursive: true, force: true }).catch(() => undefined); + continue; + } + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for workspace restore lock at ${lockDir}`); + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } +} + +export async function withDirectoryMergeLock( + targetDir: string, + fn: () => Promise, +): Promise { + const releaseLock = await acquireDirectoryMergeLock(`${targetDir}.paperclip-restore.lock`); + try { + return await fn(); + } finally { + await releaseLock(); + } +} + +async function copySnapshotEntry(sourceDir: string, targetDir: string, relative: string, entry: SnapshotEntry): Promise { + const sourcePath = path.join(sourceDir, relative); + const targetPath = path.join(targetDir, relative); + + if (entry.kind === "dir") { + const existing = await fs.lstat(targetPath).catch(() => null); + if (existing?.isDirectory()) { + return; + } + if (existing) { + await fs.rm(targetPath, { recursive: true, force: true }).catch(() => undefined); + } + await fs.mkdir(targetPath, { recursive: true }); + return; + } + + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.rm(targetPath, { recursive: true, force: true }).catch(() => undefined); + if (entry.kind === "symlink") { + await fs.symlink(entry.target, targetPath); + return; + } + + await fs.copyFile(sourcePath, targetPath, fsConstants.COPYFILE_FICLONE).catch(async () => { + await fs.copyFile(sourcePath, targetPath); + }); + await fs.chmod(targetPath, entry.mode); +} + +export async function captureDirectorySnapshot( + rootDir: string, + options: { exclude?: string[] } = {}, +): Promise { + const exclude = [...new Set(options.exclude ?? [])]; + return { + exclude, + entries: await walkDirectory(rootDir, exclude), + }; +} + +export async function mergeDirectoryWithBaseline(input: { + baseline: DirectorySnapshot; + sourceDir: string; + targetDir: string; + beforeApply?: () => Promise; +}): Promise { + const source = await captureDirectorySnapshot(input.sourceDir, { exclude: input.baseline.exclude }); + await withDirectoryMergeLock(input.targetDir, async () => { + await input.beforeApply?.(); + const current = await captureDirectorySnapshot(input.targetDir, { exclude: input.baseline.exclude }); + const deletedLeafEntries = [...input.baseline.entries.entries()] + .filter(([relative, entry]) => entry.kind !== "dir" && !source.entries.has(relative)) + .sort(([left], [right]) => right.length - left.length); + + for (const [relative, baselineEntry] of deletedLeafEntries) { + if (!entriesMatch(current.entries.get(relative), baselineEntry)) continue; + await fs.rm(path.join(input.targetDir, relative), { recursive: true, force: true }).catch(() => undefined); + } + + const deletedDirs = [...input.baseline.entries.entries()] + .filter(([relative, entry]) => entry.kind === "dir" && !source.entries.has(relative)) + .sort(([left], [right]) => right.length - left.length); + + for (const [relative] of deletedDirs) { + await fs.rmdir(path.join(input.targetDir, relative)).catch(() => undefined); + } + + const changedSourceEntries = [...source.entries.entries()] + .filter(([relative, entry]) => !entriesMatch(input.baseline.entries.get(relative), entry)) + .sort(([left], [right]) => left.localeCompare(right)); + + for (const [relative, entry] of changedSourceEntries) { + await copySnapshotEntry(input.sourceDir, input.targetDir, relative, entry); + } + }); +} + +export async function directoryEntryMatchesBaseline( + rootDir: string, + relative: string, + baselineEntry: SnapshotEntry, +): Promise { + return entriesMatch(await readSnapshotEntry(rootDir, relative), baselineEntry); +} diff --git a/packages/adapters/acpx-local/src/server/execute.ts b/packages/adapters/acpx-local/src/server/execute.ts index 3ef832b6..3450cb9e 100644 --- a/packages/adapters/acpx-local/src/server/execute.ts +++ b/packages/adapters/acpx-local/src/server/execute.ts @@ -22,6 +22,7 @@ import { renderPaperclipWakePrompt, renderTemplate, resolvePaperclipDesiredSkillNames, + rewriteWorkspaceCwdEnvVarsForExecution, shapePaperclipWorkspaceEnvForExecution, stringifyPaperclipWakePayload, type PaperclipSkillEntry, @@ -649,10 +650,11 @@ async function buildRuntime(input: { remoteExecutionIdentity && typeof remoteExecutionIdentity.remoteCwd === "string" ? remoteExecutionIdentity.remoteCwd : cwd; + const executionTargetIsRemote = remoteExecutionIdentity !== null; const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({ workspaceCwd: effectiveWorkspaceCwd, workspaceWorktreePath, - executionTargetIsRemote: remoteExecutionIdentity !== null, + executionTargetIsRemote, executionCwd: effectiveExecutionCwd, }); await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); @@ -707,7 +709,13 @@ async function buildRuntime(input: { workspaceWorktreePath: shapedWorkspaceEnv.workspaceWorktreePath, agentHome, }); - for (const [key, value] of Object.entries(envConfig)) { + const shapedEnvConfig = rewriteWorkspaceCwdEnvVarsForExecution({ + env: envConfig, + workspaceCwd: effectiveWorkspaceCwd, + executionCwd: shapedWorkspaceEnv.workspaceCwd, + executionTargetIsRemote, + }); + for (const [key, value] of Object.entries(shapedEnvConfig)) { if (typeof value === "string") env[key] = value; } if (!hasExplicitApiKey && authToken) env.PAPERCLIP_API_KEY = authToken; diff --git a/packages/adapters/claude-local/src/server/execute.remote.test.ts b/packages/adapters/claude-local/src/server/execute.remote.test.ts index dd5ab54c..ddc3959a 100644 --- a/packages/adapters/claude-local/src/server/execute.remote.test.ts +++ b/packages/adapters/claude-local/src/server/execute.remote.test.ts @@ -27,7 +27,7 @@ const { })), ensureCommandResolvable: vi.fn(async () => undefined), resolveCommandForLogs: vi.fn(async () => "ssh://fixture@127.0.0.1:2222/remote/workspace :: claude"), - prepareWorkspaceForSshExecution: vi.fn(async () => undefined), + prepareWorkspaceForSshExecution: vi.fn(async () => ({ gitBacked: false })), restoreWorkspaceFromSshExecution: vi.fn(async () => undefined), syncDirectoryToSsh: vi.fn(async () => undefined), startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({ @@ -94,6 +94,7 @@ describe("claude remote execution", () => { const workspaceDir = path.join(rootDir, "workspace"); const alternateWorkspaceDir = path.join(rootDir, "workspace-other"); const instructionsPath = path.join(rootDir, "instructions.md"); + const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-1/workspace"; await mkdir(workspaceDir, { recursive: true }); await mkdir(alternateWorkspaceDir, { recursive: true }); await writeFile(instructionsPath, "Use the remote workspace.\n", "utf8"); @@ -116,6 +117,11 @@ describe("claude remote execution", () => { config: { command: "claude", instructionsFilePath: instructionsPath, + env: { + QA_PROJECT_WORKSPACE_CWD: workspaceDir, + RANDOM_WORKSPACE_CWD: workspaceDir, + OTHER_ENV: workspaceDir, + }, }, context: { paperclipWorkspace: { @@ -161,11 +167,11 @@ describe("claude remote execution", () => { expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1); expect(prepareWorkspaceForSshExecution).toHaveBeenCalledWith(expect.objectContaining({ localDir: workspaceDir, - remoteDir: "/remote/workspace", + remoteDir: managedRemoteWorkspace, })); expect(syncDirectoryToSsh).toHaveBeenCalledTimes(1); expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({ - remoteDir: "/remote/workspace/.paperclip-runtime/claude/skills", + remoteDir: `${managedRemoteWorkspace}/.paperclip-runtime/claude/skills`, followSymlinks: true, })); expect(runChildProcess).toHaveBeenCalledTimes(1); @@ -173,15 +179,17 @@ describe("claude remote execution", () => { | [string, string, string[], { env: Record; remoteExecution?: { remoteCwd: string } | null }] | undefined; expect(call?.[2]).toContain("--append-system-prompt-file"); - expect(call?.[2]).toContain("/remote/workspace/.paperclip-runtime/claude/skills/agent-instructions.md"); + expect(call?.[2]).toContain( + `${managedRemoteWorkspace}/.paperclip-runtime/claude/skills/agent-instructions.md`, + ); expect(call?.[2]).toContain("--add-dir"); - expect(call?.[2]).toContain("/remote/workspace/.paperclip-runtime/claude/skills"); - expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe("/remote/workspace"); + expect(call?.[2]).toContain(`${managedRemoteWorkspace}/.paperclip-runtime/claude/skills`); + expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe(managedRemoteWorkspace); expect(call?.[3].env.PAPERCLIP_WORKSPACE_WORKTREE_PATH).toBeUndefined(); expect(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([ { workspaceId: "workspace-1", - cwd: "/remote/workspace", + cwd: managedRemoteWorkspace, repoUrl: "https://github.com/paperclipai/paperclip.git", repoRef: "main", }, @@ -193,12 +201,15 @@ describe("claude remote execution", () => { ]); expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310"); expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); - expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); + expect(call?.[3].env.QA_PROJECT_WORKSPACE_CWD).toBe(managedRemoteWorkspace); + expect(call?.[3].env.RANDOM_WORKSPACE_CWD).toBe(managedRemoteWorkspace); + expect(call?.[3].env.OTHER_ENV).toBe(workspaceDir); + expect(call?.[3].remoteExecution?.remoteCwd).toBe(managedRemoteWorkspace); expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1); expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledWith(expect.objectContaining({ localDir: workspaceDir, - remoteDir: "/remote/workspace", + remoteDir: managedRemoteWorkspace, })); }); @@ -259,6 +270,7 @@ describe("claude remote execution", () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-claude-remote-resume-match-")); cleanupDirs.push(rootDir); const workspaceDir = path.join(rootDir, "workspace"); + const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-ssh-resume/workspace"; await mkdir(workspaceDir, { recursive: true }); await execute({ @@ -274,13 +286,13 @@ describe("claude remote execution", () => { sessionId: "session-123", sessionParams: { sessionId: "session-123", - cwd: "/remote/workspace", + cwd: managedRemoteWorkspace, remoteExecution: { transport: "ssh", host: "127.0.0.1", port: 2222, username: "fixture", - remoteCwd: "/remote/workspace", + remoteCwd: managedRemoteWorkspace, }, }, sessionDisplayId: "session-123", diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 7c671fc6..c69d27f7 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -6,6 +6,7 @@ import type { RunProcessResult } from "@paperclipai/adapter-utils/server-utils"; import { adapterExecutionTargetIsRemote, adapterExecutionTargetRemoteCwd, + overrideAdapterExecutionTargetRemoteCwd, adapterExecutionTargetSessionIdentity, adapterExecutionTargetSessionMatches, adapterExecutionTargetUsesManagedHome, @@ -35,8 +36,10 @@ import { buildInvocationEnvForLogs, ensureAbsoluteDirectory, ensurePathInEnv, + refreshPaperclipWorkspaceEnvForExecution, renderTemplate, renderPaperclipWakePrompt, + rewriteWorkspaceCwdEnvVarsForExecution, shapePaperclipWorkspaceEnvForExecution, stringifyPaperclipWakePayload, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, @@ -152,7 +155,7 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise => typeof value === "object" && value !== null, + ) + : []; + const configuredCwd = asString(config.cwd, ""); + const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; + const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; const hasExplicitClaudeConfigDir = typeof configEnv.CLAUDE_CONFIG_DIR === "string" && configEnv.CLAUDE_CONFIG_DIR.trim().length > 0; const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); @@ -379,7 +403,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise preparedExecutionTargetRuntime.restoreWorkspace() : null; @@ -502,10 +547,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise> = null; - if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(executionTarget)) { + if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(runtimeExecutionTarget)) { paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({ runId, - target: executionTarget, + target: runtimeExecutionTarget, runtimeRootDir: preparedExecutionTargetRuntime?.runtimeRootDir, adapterKey: "claude", hostApiToken: env.PAPERCLIP_API_KEY, @@ -536,7 +581,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 && hasMatchingPromptBundle && (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) && - adapterExecutionTargetSessionMatches(runtimeRemoteExecution, executionTarget); + adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget); const sessionId = canResumeSession ? runtimeSessionId : null; if ( executionTargetIsRemote && @@ -672,7 +717,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise undefined), resolveCommandForLogs: vi.fn(async () => "/usr/bin/codex"), - prepareWorkspaceForSshExecution: vi.fn(async () => undefined), + prepareWorkspaceForSshExecution: vi.fn(async () => ({ gitBacked: false })), restoreWorkspaceFromSshExecution: vi.fn(async () => undefined), syncDirectoryToSsh: vi.fn(async () => undefined), startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({ @@ -89,6 +89,7 @@ describe("codex remote execution", () => { cleanupDirs.push(rootDir); const workspaceDir = path.join(rootDir, "workspace"); const codexHomeDir = path.join(rootDir, "codex-home"); + const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-1/workspace"; await mkdir(workspaceDir, { recursive: true }); await mkdir(codexHomeDir, { recursive: true }); await writeFile(path.join(rootDir, "instructions.md"), "Use the remote workspace.\n", "utf8"); @@ -161,12 +162,12 @@ describe("codex remote execution", () => { expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1); expect(prepareWorkspaceForSshExecution).toHaveBeenCalledWith(expect.objectContaining({ localDir: workspaceDir, - remoteDir: "/remote/workspace", + remoteDir: managedRemoteWorkspace, })); expect(syncDirectoryToSsh).toHaveBeenCalledTimes(1); expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({ localDir: codexHomeDir, - remoteDir: "/remote/workspace/.paperclip-runtime/codex/home", + remoteDir: `${managedRemoteWorkspace}/.paperclip-runtime/codex/home`, followSymlinks: true, })); @@ -174,13 +175,13 @@ describe("codex remote execution", () => { const call = runChildProcess.mock.calls[0] as unknown as | [string, string, string[], { env: Record; remoteExecution?: { remoteCwd: string } | null }] | undefined; - expect(call?.[3].env.CODEX_HOME).toBe("/remote/workspace/.paperclip-runtime/codex/home"); - expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe("/remote/workspace"); + expect(call?.[3].env.CODEX_HOME).toBe(`${managedRemoteWorkspace}/.paperclip-runtime/codex/home`); + expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe(managedRemoteWorkspace); expect(call?.[3].env.PAPERCLIP_WORKSPACE_WORKTREE_PATH).toBeUndefined(); expect(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([ { workspaceId: "workspace-1", - cwd: "/remote/workspace", + cwd: managedRemoteWorkspace, repoUrl: "https://github.com/paperclipai/paperclip.git", repoRef: "main", }, @@ -192,12 +193,12 @@ describe("codex remote execution", () => { ]); expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310"); expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); - expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); + expect(call?.[3].remoteExecution?.remoteCwd).toBe(managedRemoteWorkspace); expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1); expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledWith(expect.objectContaining({ localDir: workspaceDir, - remoteDir: "/remote/workspace", + remoteDir: managedRemoteWorkspace, })); }); @@ -269,6 +270,7 @@ describe("codex remote execution", () => { cleanupDirs.push(rootDir); const workspaceDir = path.join(rootDir, "workspace"); const codexHomeDir = path.join(rootDir, "codex-home"); + const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-ssh-resume/workspace"; await mkdir(workspaceDir, { recursive: true }); await mkdir(codexHomeDir, { recursive: true }); await writeFile(path.join(codexHomeDir, "auth.json"), "{}", "utf8"); @@ -286,13 +288,13 @@ describe("codex remote execution", () => { sessionId: "session-123", sessionParams: { sessionId: "session-123", - cwd: "/remote/workspace", + cwd: managedRemoteWorkspace, remoteExecution: { transport: "ssh", host: "127.0.0.1", port: 2222, username: "fixture", - remoteCwd: "/remote/workspace", + remoteCwd: managedRemoteWorkspace, }, }, sessionDisplayId: "session-123", @@ -341,6 +343,7 @@ describe("codex remote execution", () => { cleanupDirs.push(rootDir); const workspaceDir = path.join(rootDir, "workspace"); const codexHomeDir = path.join(rootDir, "codex-home"); + const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-target/workspace"; await mkdir(workspaceDir, { recursive: true }); await mkdir(codexHomeDir, { recursive: true }); await writeFile(path.join(codexHomeDir, "auth.json"), "{}", "utf8"); @@ -358,13 +361,13 @@ describe("codex remote execution", () => { sessionId: "session-123", sessionParams: { sessionId: "session-123", - cwd: "/remote/workspace", + cwd: managedRemoteWorkspace, remoteExecution: { transport: "ssh", host: "127.0.0.1", port: 2222, username: "fixture", - remoteCwd: "/remote/workspace", + remoteCwd: managedRemoteWorkspace, }, }, sessionDisplayId: "session-123", @@ -412,7 +415,7 @@ describe("codex remote execution", () => { "session-123", "-", ]); - expect(call?.[3].env.CODEX_HOME).toBe("/remote/workspace/.paperclip-runtime/codex/home"); - expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); + expect(call?.[3].env.CODEX_HOME).toBe(`${managedRemoteWorkspace}/.paperclip-runtime/codex/home`); + expect(call?.[3].remoteExecution?.remoteCwd).toBe(managedRemoteWorkspace); }); }); diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index a8d8c174..4ce83201 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -5,6 +5,7 @@ import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type Adapter import { adapterExecutionTargetIsRemote, adapterExecutionTargetRemoteCwd, + overrideAdapterExecutionTargetRemoteCwd, adapterExecutionTargetSessionIdentity, adapterExecutionTargetSessionMatches, adapterExecutionTargetUsesPaperclipBridge, @@ -21,18 +22,17 @@ import { asString, asNumber, parseObject, - applyPaperclipWorkspaceEnv, buildPaperclipEnv, buildInvocationEnvForLogs, ensureAbsoluteDirectory, ensurePaperclipSkillSymlink, ensurePathInEnv, + refreshPaperclipWorkspaceEnvForExecution, readPaperclipRuntimeSkillEntries, readPaperclipIssueWorkModeFromContext, resolvePaperclipDesiredSkillNames, renderTemplate, renderPaperclipWakePrompt, - shapePaperclipWorkspaceEnvForExecution, stringifyPaperclipWakePayload, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, joinPromptSections, @@ -358,14 +358,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { await onLog( @@ -373,6 +366,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise preparedExecutionTargetRuntime.restoreWorkspace() : null; @@ -449,20 +447,22 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) { - env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(shapedWorkspaceEnv.workspaceHints); - } if (runtimeServiceIntents.length > 0) { env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents); } @@ -472,17 +472,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 && (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) && - adapterExecutionTargetSessionMatches(runtimeRemoteExecution, executionTarget); + adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget); const codexTransientFallbackMode = readCodexTransientFallbackMode(context); const forceSaferInvocation = fallbackModeUsesSaferInvocation(codexTransientFallbackMode); const forceFreshSession = fallbackModeUsesFreshSession(codexTransientFallbackMode); @@ -695,7 +692,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise undefined), resolveCommandForLogs: vi.fn(async () => "ssh://fixture@127.0.0.1:2222/remote/workspace :: agent"), - prepareWorkspaceForSshExecution: vi.fn(async () => undefined), + prepareWorkspaceForSshExecution: vi.fn(async () => ({ gitBacked: false })), restoreWorkspaceFromSshExecution: vi.fn(async () => undefined), runSshCommand: vi.fn(async () => ({ stdout: "/home/agent", @@ -170,7 +170,9 @@ describe("cursor remote execution", () => { expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1); expect(syncDirectoryToSsh).toHaveBeenCalledTimes(1); expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({ - remoteDir: "/remote/workspace/.paperclip-runtime/cursor/skills", + // Asset sync targets the per-run managed subdirectory even though the + // cursor adapter still runs commands from the workspace root. + remoteDir: "/remote/workspace/.paperclip-runtime/runs/run-1/workspace/.paperclip-runtime/cursor/skills", followSymlinks: true, })); expect(runSshCommand).toHaveBeenCalledWith( diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index cadd83e2..27180aa8 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -372,6 +372,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise undefined), resolveCommandForLogs: vi.fn(async () => "ssh://fixture@127.0.0.1:2222/remote/workspace :: gemini"), - prepareWorkspaceForSshExecution: vi.fn(async () => undefined), + prepareWorkspaceForSshExecution: vi.fn(async () => ({ gitBacked: false })), restoreWorkspaceFromSshExecution: vi.fn(async () => undefined), runSshCommand: vi.fn(async () => ({ stdout: "/home/agent", @@ -105,6 +105,7 @@ describe("gemini remote execution", () => { cleanupDirs.push(rootDir); const workspaceDir = path.join(rootDir, "workspace"); const alternateWorkspaceDir = path.join(rootDir, "workspace-other"); + const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-1/workspace"; await mkdir(workspaceDir, { recursive: true }); await mkdir(alternateWorkspaceDir, { recursive: true }); @@ -163,19 +164,19 @@ describe("gemini remote execution", () => { expect(result.sessionParams).toMatchObject({ sessionId: "gemini-session-1", - cwd: "/remote/workspace", + cwd: managedRemoteWorkspace, remoteExecution: { transport: "ssh", host: "127.0.0.1", port: 2222, username: "fixture", - remoteCwd: "/remote/workspace", + remoteCwd: managedRemoteWorkspace, }, }); expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1); expect(syncDirectoryToSsh).toHaveBeenCalledTimes(1); expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({ - remoteDir: "/remote/workspace/.paperclip-runtime/gemini/skills", + remoteDir: `${managedRemoteWorkspace}/.paperclip-runtime/gemini/skills`, followSymlinks: true, })); expect(runSshCommand).toHaveBeenCalledWith( @@ -186,11 +187,11 @@ describe("gemini remote execution", () => { const call = runChildProcess.mock.calls[0] as unknown as | [string, string, string[], { env: Record; remoteExecution?: { remoteCwd: string } | null }] | undefined; - expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe("/remote/workspace"); + expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe(managedRemoteWorkspace); expect(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([ { workspaceId: "workspace-1", - cwd: "/remote/workspace", + cwd: managedRemoteWorkspace, repoUrl: "https://github.com/paperclipai/paperclip.git", repoRef: "main", }, @@ -202,7 +203,7 @@ describe("gemini remote execution", () => { ]); expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310"); expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); - expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); + expect(call?.[3].remoteExecution?.remoteCwd).toBe(managedRemoteWorkspace); expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1); expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); }); @@ -211,6 +212,7 @@ describe("gemini remote execution", () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-remote-resume-")); cleanupDirs.push(rootDir); const workspaceDir = path.join(rootDir, "workspace"); + const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-ssh-resume/workspace"; await mkdir(workspaceDir, { recursive: true }); await execute({ @@ -226,13 +228,13 @@ describe("gemini remote execution", () => { sessionId: "session-123", sessionParams: { sessionId: "session-123", - cwd: "/remote/workspace", + cwd: managedRemoteWorkspace, remoteExecution: { transport: "ssh", host: "127.0.0.1", port: 2222, username: "fixture", - remoteCwd: "/remote/workspace", + remoteCwd: managedRemoteWorkspace, }, }, sessionDisplayId: "session-123", diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts index 6141249a..44d5a798 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -7,6 +7,7 @@ import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclip import { adapterExecutionTargetIsRemote, adapterExecutionTargetRemoteCwd, + overrideAdapterExecutionTargetRemoteCwd, adapterExecutionTargetSessionIdentity, adapterExecutionTargetSessionMatches, adapterExecutionTargetUsesManagedHome, @@ -27,13 +28,13 @@ import { asNumber, asString, asStringArray, - applyPaperclipWorkspaceEnv, buildPaperclipEnv, buildInvocationEnvForLogs, ensureAbsoluteDirectory, ensurePaperclipSkillSymlink, joinPromptSections, ensurePathInEnv, + refreshPaperclipWorkspaceEnvForExecution, readPaperclipRuntimeSkillEntries, readPaperclipIssueWorkModeFromContext, resolvePaperclipDesiredSkillNames, @@ -41,7 +42,6 @@ import { parseObject, renderTemplate, renderPaperclipWakePrompt, - shapePaperclipWorkspaceEnvForExecution, stringifyPaperclipWakePayload, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, runChildProcess, @@ -202,13 +202,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0; const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); - const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd); - const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({ - workspaceCwd: effectiveWorkspaceCwd, - workspaceHints, - executionTargetIsRemote, - executionCwd: effectiveExecutionCwd, - }); + let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd); await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); const geminiSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); const desiredGeminiSkillNames = resolvePaperclipDesiredSkillNames(config, geminiSkillEntries); @@ -254,20 +248,19 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; - applyPaperclipWorkspaceEnv(env, { - workspaceCwd: shapedWorkspaceEnv.workspaceCwd, + refreshPaperclipWorkspaceEnvForExecution({ + env, + envConfig, + workspaceCwd: effectiveWorkspaceCwd, workspaceSource, workspaceId, workspaceRepoUrl, workspaceRepoRef, + workspaceHints, agentHome, + executionTargetIsRemote, + executionCwd: effectiveExecutionCwd, }); - if (shapedWorkspaceEnv.workspaceHints.length > 0) { - env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(shapedWorkspaceEnv.workspaceHints); - } - for (const [key, value] of Object.entries(envConfig)) { - if (typeof value === "string") env[key] = value; - } if (!hasExplicitApiKey && authToken) { env.PAPERCLIP_API_KEY = authToken; } @@ -322,6 +315,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise preparedExecutionTargetRuntime.restoreWorkspace(); + effectiveExecutionCwd = preparedExecutionTargetRuntime.workspaceRemoteDir ?? effectiveExecutionCwd; + refreshPaperclipWorkspaceEnvForExecution({ + env, + envConfig, + workspaceCwd: effectiveWorkspaceCwd, + workspaceSource, + workspaceId, + workspaceRepoUrl, + workspaceRepoRef, + workspaceHints, + agentHome, + executionTargetIsRemote, + executionCwd: effectiveExecutionCwd, + }); remoteRuntimeRootDir = preparedExecutionTargetRuntime.runtimeRootDir; const managedHome = adapterExecutionTargetUsesManagedHome(executionTarget); if (managedHome && preparedExecutionTargetRuntime.runtimeRootDir) { @@ -366,9 +374,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 && (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) && - adapterExecutionTargetSessionMatches(runtimeRemoteExecution, executionTarget); + adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget); const sessionId = canResumeSession ? runtimeSessionId : null; if (executionTargetIsRemote && runtimeSessionId && !canResumeSession) { await onLog( @@ -512,7 +522,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise) diff --git a/packages/adapters/opencode-local/src/server/execute.remote.test.ts b/packages/adapters/opencode-local/src/server/execute.remote.test.ts index 8fc27379..f13fa4d5 100644 --- a/packages/adapters/opencode-local/src/server/execute.remote.test.ts +++ b/packages/adapters/opencode-local/src/server/execute.remote.test.ts @@ -45,7 +45,7 @@ const { }), ensureCommandResolvable: vi.fn(async () => undefined), resolveCommandForLogs: vi.fn(async () => "ssh://fixture@127.0.0.1:2222/remote/workspace :: opencode"), - prepareWorkspaceForSshExecution: vi.fn(async () => undefined), + prepareWorkspaceForSshExecution: vi.fn(async () => ({ gitBacked: false })), restoreWorkspaceFromSshExecution: vi.fn(async () => undefined), runSshCommand: vi.fn(async () => ({ stdout: "/home/agent", @@ -117,6 +117,7 @@ describe("opencode remote execution", () => { cleanupDirs.push(rootDir); const workspaceDir = path.join(rootDir, "workspace"); const alternateWorkspaceDir = path.join(rootDir, "workspace-other"); + const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-1/workspace"; await mkdir(workspaceDir, { recursive: true }); await mkdir(alternateWorkspaceDir, { recursive: true }); @@ -176,22 +177,22 @@ describe("opencode remote execution", () => { expect(result.sessionParams).toMatchObject({ sessionId: "session_123", - cwd: "/remote/workspace", + cwd: managedRemoteWorkspace, remoteExecution: { transport: "ssh", host: "127.0.0.1", port: 2222, username: "fixture", - remoteCwd: "/remote/workspace", + remoteCwd: managedRemoteWorkspace, }, }); expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1); expect(syncDirectoryToSsh).toHaveBeenCalledTimes(2); expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({ - remoteDir: "/remote/workspace/.paperclip-runtime/opencode/xdgConfig", + remoteDir: `${managedRemoteWorkspace}/.paperclip-runtime/opencode/xdgConfig`, })); expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({ - remoteDir: "/remote/workspace/.paperclip-runtime/opencode/skills", + remoteDir: `${managedRemoteWorkspace}/.paperclip-runtime/opencode/skills`, followSymlinks: true, })); expect(runSshCommand).toHaveBeenCalledWith( @@ -206,18 +207,22 @@ describe("opencode remote execution", () => { | [string, string, string[], { env: Record; remoteExecution?: { remoteCwd: string } | null }] | undefined; expect(modelProbeCall?.[2]).toEqual(["models"]); + // The model probe runs after the runtime workspace is prepared (so XDG + // points at the managed subdirectory) but the SSH session targets the + // original target remoteCwd — the per-run subdirectory is layered + // underneath via XDG/runtime config rather than by switching the cwd. expect(modelProbeCall?.[3].env.XDG_CONFIG_HOME).toBe( - "/remote/workspace/.paperclip-runtime/opencode/xdgConfig", + `${managedRemoteWorkspace}/.paperclip-runtime/opencode/xdgConfig`, ); expect(modelProbeCall?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); const call = runCall as | [string, string, string[], { env: Record; remoteExecution?: { remoteCwd: string } | null }] | undefined; - expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe("/remote/workspace"); + expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe(managedRemoteWorkspace); expect(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([ { workspaceId: "workspace-1", - cwd: "/remote/workspace", + cwd: managedRemoteWorkspace, repoUrl: "https://github.com/paperclipai/paperclip.git", repoRef: "main", }, @@ -229,8 +234,8 @@ describe("opencode remote execution", () => { ]); expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310"); expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); - expect(call?.[3].env.XDG_CONFIG_HOME).toBe("/remote/workspace/.paperclip-runtime/opencode/xdgConfig"); - expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); + expect(call?.[3].env.XDG_CONFIG_HOME).toBe(`${managedRemoteWorkspace}/.paperclip-runtime/opencode/xdgConfig`); + expect(call?.[3].remoteExecution?.remoteCwd).toBe(managedRemoteWorkspace); expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1); expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); }); @@ -302,6 +307,7 @@ describe("opencode remote execution", () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-remote-resume-")); cleanupDirs.push(rootDir); const workspaceDir = path.join(rootDir, "workspace"); + const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-ssh-resume/workspace"; await mkdir(workspaceDir, { recursive: true }); await execute({ @@ -317,13 +323,13 @@ describe("opencode remote execution", () => { sessionId: "session-123", sessionParams: { sessionId: "session-123", - cwd: "/remote/workspace", + cwd: managedRemoteWorkspace, remoteExecution: { transport: "ssh", host: "127.0.0.1", port: 2222, username: "fixture", - remoteCwd: "/remote/workspace", + remoteCwd: managedRemoteWorkspace, }, }, sessionDisplayId: "session-123", diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 55511b0a..847a331a 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -6,6 +6,7 @@ import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type Adapter import { adapterExecutionTargetIsRemote, adapterExecutionTargetRemoteCwd, + overrideAdapterExecutionTargetRemoteCwd, adapterExecutionTargetSessionIdentity, adapterExecutionTargetSessionMatches, adapterExecutionTargetUsesManagedHome, @@ -26,16 +27,15 @@ import { asNumber, asStringArray, parseObject, - applyPaperclipWorkspaceEnv, buildPaperclipEnv, joinPromptSections, buildInvocationEnvForLogs, ensureAbsoluteDirectory, ensurePaperclipSkillSymlink, ensurePathInEnv, + refreshPaperclipWorkspaceEnvForExecution, renderTemplate, renderPaperclipWakePrompt, - shapePaperclipWorkspaceEnvForExecution, stringifyPaperclipWakePayload, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, runChildProcess, @@ -220,13 +220,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0; const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); - const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd); - const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({ - workspaceCwd: effectiveWorkspaceCwd, - workspaceHints, - executionTargetIsRemote, - executionCwd: effectiveExecutionCwd, - }); + let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd); await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); const openCodeSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); const desiredOpenCodeSkillNames = resolvePaperclipDesiredSkillNames(config, openCodeSkillEntries); @@ -276,20 +270,19 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; - applyPaperclipWorkspaceEnv(env, { - workspaceCwd: shapedWorkspaceEnv.workspaceCwd, + refreshPaperclipWorkspaceEnvForExecution({ + env, + envConfig, + workspaceCwd: effectiveWorkspaceCwd, workspaceSource, workspaceId, workspaceRepoUrl, workspaceRepoRef, + workspaceHints, agentHome, + executionTargetIsRemote, + executionCwd: effectiveExecutionCwd, }); - if (shapedWorkspaceEnv.workspaceHints.length > 0) { - env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(shapedWorkspaceEnv.workspaceHints); - } - for (const [key, value] of Object.entries(envConfig)) { - if (typeof value === "string") env[key] = value; - } // Prevent OpenCode from writing an opencode.json config file into the // project working directory (which would pollute the git repo). Model // selection is already handled via the --model CLI flag. Set after the @@ -353,6 +346,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise preparedExecutionTargetRuntime.restoreWorkspace(); + effectiveExecutionCwd = preparedExecutionTargetRuntime.workspaceRemoteDir ?? effectiveExecutionCwd; + refreshPaperclipWorkspaceEnvForExecution({ + env: preparedRuntimeConfig.env, + envConfig, + workspaceCwd: effectiveWorkspaceCwd, + workspaceSource, + workspaceId, + workspaceRepoUrl, + workspaceRepoRef, + workspaceHints, + agentHome, + executionTargetIsRemote, + executionCwd: effectiveExecutionCwd, + }); remoteRuntimeRootDir = preparedExecutionTargetRuntime.runtimeRootDir; const managedHome = adapterExecutionTargetUsesManagedHome(executionTarget); if (managedHome && preparedExecutionTargetRuntime.runtimeRootDir) { @@ -410,10 +418,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 && (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) && - adapterExecutionTargetSessionMatches(runtimeRemoteExecution, executionTarget); + adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget); const sessionId = canResumeSession ? runtimeSessionId : null; if (executionTargetIsRemote && runtimeSessionId && !canResumeSession) { await onLog( @@ -550,7 +559,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise) diff --git a/packages/adapters/pi-local/src/server/execute.remote.test.ts b/packages/adapters/pi-local/src/server/execute.remote.test.ts index 140ec366..6b9b64b9 100644 --- a/packages/adapters/pi-local/src/server/execute.remote.test.ts +++ b/packages/adapters/pi-local/src/server/execute.remote.test.ts @@ -37,7 +37,7 @@ const { })), ensureCommandResolvable: vi.fn(async () => undefined), resolveCommandForLogs: vi.fn(async () => "ssh://fixture@127.0.0.1:2222/remote/workspace :: pi"), - prepareWorkspaceForSshExecution: vi.fn(async () => undefined), + prepareWorkspaceForSshExecution: vi.fn(async () => ({ gitBacked: false })), restoreWorkspaceFromSshExecution: vi.fn(async () => undefined), runSshCommand: vi.fn(async () => ({ stdout: "", @@ -109,6 +109,7 @@ describe("pi remote execution", () => { cleanupDirs.push(rootDir); const workspaceDir = path.join(rootDir, "workspace"); const alternateWorkspaceDir = path.join(rootDir, "workspace-other"); + const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-1/workspace"; await mkdir(workspaceDir, { recursive: true }); await mkdir(alternateWorkspaceDir, { recursive: true }); @@ -167,20 +168,20 @@ describe("pi remote execution", () => { }); expect(result.sessionParams).toMatchObject({ - cwd: "/remote/workspace", + cwd: managedRemoteWorkspace, remoteExecution: { transport: "ssh", host: "127.0.0.1", port: 2222, username: "fixture", - remoteCwd: "/remote/workspace", + remoteCwd: managedRemoteWorkspace, }, }); - expect(String(result.sessionId)).toContain("/remote/workspace/.paperclip-runtime/pi/sessions/"); + expect(String(result.sessionId)).toContain(`${managedRemoteWorkspace}/.paperclip-runtime/pi/sessions/`); expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1); expect(syncDirectoryToSsh).toHaveBeenCalledTimes(1); expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({ - remoteDir: "/remote/workspace/.paperclip-runtime/pi/skills", + remoteDir: `${managedRemoteWorkspace}/.paperclip-runtime/pi/skills`, followSymlinks: true, })); expect(runSshCommand).toHaveBeenCalledWith( @@ -193,12 +194,12 @@ describe("pi remote execution", () => { | undefined; expect(call?.[2]).toContain("--session"); expect(call?.[2]).toContain("--skill"); - expect(call?.[2]).toContain("/remote/workspace/.paperclip-runtime/pi/skills"); - expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe("/remote/workspace"); + expect(call?.[2]).toContain(`${managedRemoteWorkspace}/.paperclip-runtime/pi/skills`); + expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe(managedRemoteWorkspace); expect(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([ { workspaceId: "workspace-1", - cwd: "/remote/workspace", + cwd: managedRemoteWorkspace, repoUrl: "https://github.com/paperclipai/paperclip.git", repoRef: "main", }, @@ -210,7 +211,7 @@ describe("pi remote execution", () => { ]); expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310"); expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); - expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); + expect(call?.[3].remoteExecution?.remoteCwd).toBe(managedRemoteWorkspace); expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1); expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); }); @@ -219,13 +220,14 @@ describe("pi remote execution", () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pi-remote-resume-")); cleanupDirs.push(rootDir); const workspaceDir = path.join(rootDir, "workspace"); + const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-ssh-resume/workspace"; await mkdir(workspaceDir, { recursive: true }); runSshCommand.mockImplementation(async (...args: unknown[]) => { const command = String(args[1] ?? ""); if (command.includes("head -n 1") && command.includes("session-123.jsonl")) { return { - stdout: `${JSON.stringify({ type: "session", cwd: "/remote/workspace" })}\n`, + stdout: `${JSON.stringify({ type: "session", cwd: managedRemoteWorkspace })}\n`, stderr: "", exitCode: 0, }; @@ -247,16 +249,16 @@ describe("pi remote execution", () => { adapterConfig: {}, }, runtime: { - sessionId: "/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl", + sessionId: `${managedRemoteWorkspace}/.paperclip-runtime/pi/sessions/session-123.jsonl`, sessionParams: { - sessionId: "/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl", - cwd: "/remote/workspace", + sessionId: `${managedRemoteWorkspace}/.paperclip-runtime/pi/sessions/session-123.jsonl`, + cwd: managedRemoteWorkspace, remoteExecution: { transport: "ssh", host: "127.0.0.1", port: 2222, username: "fixture", - remoteCwd: "/remote/workspace", + remoteCwd: managedRemoteWorkspace, }, }, sessionDisplayId: "session-123", @@ -289,7 +291,7 @@ describe("pi remote execution", () => { const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined; expect(call?.[2]).toContain("--session"); - expect(call?.[2]).toContain("/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl"); + expect(call?.[2]).toContain(`${managedRemoteWorkspace}/.paperclip-runtime/pi/sessions/session-123.jsonl`); }); it("starts a fresh remote Pi session when the saved session header cwd points at a different workspace", async () => { @@ -364,11 +366,12 @@ describe("pi remote execution", () => { onLog: async () => {}, }); + const managedRemoteWorkspaceFresh = "/remote/workspace/.paperclip-runtime/runs/run-ssh-stale-session/workspace"; const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined; const sessionIndex = call?.[2].indexOf("--session") ?? -1; expect(sessionIndex).toBeGreaterThanOrEqual(0); const usedSession = sessionIndex >= 0 ? call?.[2][sessionIndex + 1] : null; - expect(usedSession).toContain("/remote/workspace/.paperclip-runtime/pi/sessions/"); + expect(usedSession).toContain(`${managedRemoteWorkspaceFresh}/.paperclip-runtime/pi/sessions/`); expect(usedSession).not.toBe("/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl"); }); diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index e6c0769f..21d3f61c 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -6,6 +6,7 @@ import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type Adapter import { adapterExecutionTargetIsRemote, adapterExecutionTargetRemoteCwd, + overrideAdapterExecutionTargetRemoteCwd, adapterExecutionTargetSessionIdentity, adapterExecutionTargetSessionMatches, adapterExecutionTargetUsesManagedHome, @@ -26,20 +27,19 @@ import { asNumber, asStringArray, parseObject, - applyPaperclipWorkspaceEnv, buildPaperclipEnv, joinPromptSections, buildInvocationEnvForLogs, ensureAbsoluteDirectory, ensurePaperclipSkillSymlink, ensurePathInEnv, + refreshPaperclipWorkspaceEnvForExecution, readPaperclipRuntimeSkillEntries, readPaperclipIssueWorkModeFromContext, resolvePaperclipDesiredSkillNames, removeMaintainerOnlySkillSymlinks, renderTemplate, renderPaperclipWakePrompt, - shapePaperclipWorkspaceEnvForExecution, stringifyPaperclipWakePayload, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, runChildProcess, @@ -246,13 +246,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0; const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); - const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd); - const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({ - workspaceCwd: effectiveWorkspaceCwd, - workspaceHints, - executionTargetIsRemote, - executionCwd: effectiveExecutionCwd, - }); + let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd); await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); if (!executionTargetIsRemote) { @@ -306,20 +300,19 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; - applyPaperclipWorkspaceEnv(env, { - workspaceCwd: shapedWorkspaceEnv.workspaceCwd, + refreshPaperclipWorkspaceEnvForExecution({ + env, + envConfig, + workspaceCwd: effectiveWorkspaceCwd, workspaceSource, workspaceId, workspaceRepoUrl, workspaceRepoRef, + workspaceHints, agentHome, + executionTargetIsRemote, + executionCwd: effectiveExecutionCwd, }); - if (shapedWorkspaceEnv.workspaceHints.length > 0) { - env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(shapedWorkspaceEnv.workspaceHints); - } - for (const [key, value] of Object.entries(envConfig)) { - if (typeof value === "string") env[key] = value; - } if (!hasExplicitApiKey && authToken) { env.PAPERCLIP_API_KEY = authToken; } @@ -401,6 +394,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise preparedRemoteRuntime.restoreWorkspace(); + effectiveExecutionCwd = preparedRemoteRuntime.workspaceRemoteDir ?? effectiveExecutionCwd; + refreshPaperclipWorkspaceEnvForExecution({ + env, + envConfig, + workspaceCwd: effectiveWorkspaceCwd, + workspaceSource, + workspaceId, + workspaceRepoUrl, + workspaceRepoRef, + workspaceHints, + agentHome, + executionTargetIsRemote, + executionCwd: effectiveExecutionCwd, + }); if (adapterExecutionTargetUsesManagedHome(executionTarget) && preparedRemoteRuntime.runtimeRootDir) { env.HOME = preparedRemoteRuntime.runtimeRootDir; } @@ -428,10 +436,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise