From 856c6cb192e53a992875821297b5fd8d29c95c2d Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 3 May 2026 13:17:52 -0700 Subject: [PATCH] Fix remote workspace environment shaping (#5118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > **Stacked PR (part 5 of 7).** Depends on: - PR #5114 - PR #5115 - PR #5116 - PR #5117 > Diff against `master` includes commits from earlier PRs in the stack — the new commit in this PR is the topmost one. ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Agents run with a Paperclip-shaped environment (`PAPERCLIP_WORKSPACE_CWD`, > worktree path, `PAPERCLIP_WORKSPACES_JSON` hints) so the CLI can locate the > correct project tree > - SSH testing reproduced a real failure: a Codex SSH run wrote to > `/tmp/paperclip-env-matrix-...` (the *host* path) instead of the realized > remote workspace at `/home//paperclip-env-matrix-ssh-claude/...` > because the adapter injected `PAPERCLIP_WORKSPACE_CWD=/tmp/...` into the > remote env > - Code review on the initial codex-only fix asked to roll the same approach > into every other SSH-capable adapter (claude, acpx, cursor, opencode, gemini, > pi) via a shared helper rather than duplicating per-adapter > - This PR adds `shapePaperclipWorkspaceEnvForExecution` in adapter-utils that, > when the execution target is remote: replaces local cwd with the realized > execution cwd, nulls out worktree path (which has no remote meaning), and > rewrites/strips `cwd` entries in workspace hints based on what was actually > synced. Every adapter calls it before invoking the remote runner > - The benefit is that remote runs see the realized remote workspace, host-local > paths stop leaking into remote env, and the rule is unit-tested in one place ## What Changed - Added `shapePaperclipWorkspaceEnvForExecution` to `packages/adapter-utils/src/server-utils.ts` with full unit coverage (`server-utils.test.ts`) - Each of acpx-local, claude-local, codex-local, cursor-local, gemini-local, opencode-local, pi-local now calls the new shaper before issuing the remote command and feeds the shaped values into `applyPaperclipWorkspaceEnv` - Per-adapter `execute.remote.test.ts` files extended to cover the new shaping behaviour: localhost paths replaced with remote cwd, foreign-cwd hints stripped, worktree path nulled out for remote targets - `acpx-local/src/server/execute.test.ts` extended with shaping coverage ## Verification - `pnpm test -- server-utils execute.remote` - `pnpm --filter @paperclipai/adapter-acpx-local test` - Manual QA reproducing the original failure: 1. Provision an E2B sandbox environment for the Paperclip QA company 2. Assign an issue to a remote-targeted claude-local agent and confirm the run starts in the correct remote cwd (no `/Users/...` path leakage in the run logs) 3. Repeat for opencode-local and pi-local ## Risks - Behavioural shift: hints whose `cwd` doesn't match the workspace cwd are now stripped on remote targets. If any adapter relied on a leaked local hint cwd, it will see a missing `cwd` instead. Reviewed all current callers — none do. - Adds a small per-run cost (path resolve + string normalisation) on every remote execution. Negligible. - Worktree path is now nulled out on remote (it has no meaning there). Adapters that previously read the value defensively will continue to work. ## Model Used - OpenAI GPT-5.4 (reasoning effort: high) via Codex CLI - Provider: OpenAI - Used to author the code changes in this PR ## 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 - [ ] If this change affects the UI, I have included before/after screenshots — N/A - [ ] I have updated relevant documentation to reflect my changes — N/A - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- .../adapter-utils/src/server-utils.test.ts | 65 +++++++++++++++++ packages/adapter-utils/src/server-utils.ts | 73 +++++++++++++++++++ .../acpx-local/src/server/execute.test.ts | 72 ++++++++++++++++-- .../adapters/acpx-local/src/server/execute.ts | 25 +++++-- .../src/server/execute.remote.test.ts | 37 ++++++++++ .../claude-local/src/server/execute.ts | 18 ++++- .../src/server/execute.remote.test.ts | 37 ++++++++++ .../codex-local/src/server/execute.ts | 16 +++- .../src/server/execute.remote.test.ts | 30 ++++++++ .../cursor-local/src/server/execute.ts | 15 +++- .../src/server/execute.remote.test.ts | 30 ++++++++ .../gemini-local/src/server/execute.ts | 15 +++- .../src/server/execute.remote.test.ts | 30 ++++++++ .../opencode-local/src/server/execute.ts | 14 +++- .../src/server/execute.remote.test.ts | 30 ++++++++ .../adapters/pi-local/src/server/execute.ts | 13 +++- 16 files changed, 487 insertions(+), 33 deletions(-) diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index 16ad5303..1b74aefe 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -12,6 +12,7 @@ import { renderPaperclipWakePrompt, runningProcesses, runChildProcess, + shapePaperclipWorkspaceEnvForExecution, stringifyPaperclipWakePayload, } from "./server-utils.js"; @@ -551,6 +552,70 @@ describe("applyPaperclipWorkspaceEnv", () => { }); }); +describe("shapePaperclipWorkspaceEnvForExecution", () => { + it("rewrites workspace env paths for remote execution", () => { + const shaped = shapePaperclipWorkspaceEnvForExecution({ + workspaceCwd: "/tmp/workspace", + workspaceWorktreePath: "/tmp/worktree", + workspaceHints: [ + { + workspaceId: "workspace-1", + cwd: "/tmp/workspace", + repoUrl: "https://github.com/paperclipai/paperclip.git", + }, + { + workspaceId: "workspace-2", + cwd: "/tmp/other-workspace", + repoUrl: "https://github.com/paperclipai/paperclip.git", + }, + { + workspaceId: "workspace-3", + repoUrl: "https://github.com/paperclipai/paperclip.git", + }, + ], + executionTargetIsRemote: true, + executionCwd: "/remote/workspace", + }); + + expect(shaped).toEqual({ + workspaceCwd: "/remote/workspace", + workspaceWorktreePath: null, + workspaceHints: [ + { + workspaceId: "workspace-1", + cwd: "/remote/workspace", + repoUrl: "https://github.com/paperclipai/paperclip.git", + }, + { + workspaceId: "workspace-2", + repoUrl: "https://github.com/paperclipai/paperclip.git", + }, + { + workspaceId: "workspace-3", + repoUrl: "https://github.com/paperclipai/paperclip.git", + }, + ], + }); + }); + + it("leaves local execution workspace paths unchanged", () => { + const workspaceHints = [{ workspaceId: "workspace-1", cwd: "/tmp/workspace" }]; + const shaped = shapePaperclipWorkspaceEnvForExecution({ + workspaceCwd: "/tmp/workspace", + workspaceWorktreePath: "/tmp/worktree", + workspaceHints, + executionTargetIsRemote: false, + executionCwd: "/remote/workspace", + }); + + expect(shaped).toEqual({ + workspaceCwd: "/tmp/workspace", + workspaceWorktreePath: "/tmp/worktree", + workspaceHints, + }); + }); +}); + 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 bb4eb40d..cc6e4566 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -885,6 +885,79 @@ export function applyPaperclipWorkspaceEnv( return env; } +export function shapePaperclipWorkspaceEnvForExecution(input: { + workspaceCwd?: string | null; + workspaceWorktreePath?: string | null; + workspaceHints?: Array>; + executionTargetIsRemote?: boolean; + executionCwd?: string | null; +}): { + workspaceCwd: string | null; + workspaceWorktreePath: string | null; + workspaceHints: Array>; +} { + const workspaceCwd = + typeof input.workspaceCwd === "string" && input.workspaceCwd.trim().length > 0 + ? input.workspaceCwd.trim() + : null; + const workspaceWorktreePath = + typeof input.workspaceWorktreePath === "string" && input.workspaceWorktreePath.trim().length > 0 + ? input.workspaceWorktreePath.trim() + : null; + const workspaceHints = Array.isArray(input.workspaceHints) ? input.workspaceHints : []; + + if (!input.executionTargetIsRemote) { + return { + workspaceCwd, + workspaceWorktreePath, + workspaceHints, + }; + } + + const executionCwd = + typeof input.executionCwd === "string" && input.executionCwd.trim().length > 0 + ? input.executionCwd.trim() + : null; + // On a remote target we must never fall back to the local workspaceCwd — + // doing so leaks host paths into the remote env (the exact failure mode + // this helper exists to prevent). Callers are expected to resolve + // executionCwd via adapterExecutionTargetRemoteCwd before calling this + // helper, which always returns a non-empty string. Surface a warning so + // future callers don't silently regress to the leak. + if (executionCwd === null) { + // eslint-disable-next-line no-console + console.warn( + "[paperclip] shapePaperclipWorkspaceEnvForExecution called with executionCwd=null on a remote target; " + + "stripping workspaceCwd to avoid leaking local paths into the remote environment.", + ); + } + const realizedWorkspaceCwd = executionCwd; + const localWorkspaceCwd = workspaceCwd ? path.resolve(workspaceCwd) : null; + const shapedWorkspaceHints = workspaceHints.map((hint) => { + const nextHint = { ...hint }; + const hintCwd = typeof nextHint.cwd === "string" ? nextHint.cwd.trim() : ""; + if (!hintCwd) return nextHint; + + if (localWorkspaceCwd && path.resolve(hintCwd) === localWorkspaceCwd) { + if (realizedWorkspaceCwd) { + nextHint.cwd = realizedWorkspaceCwd; + } else { + delete nextHint.cwd; + } + return nextHint; + } + + delete nextHint.cwd; + return nextHint; + }); + + return { + workspaceCwd: realizedWorkspaceCwd, + workspaceWorktreePath: null, + workspaceHints: shapedWorkspaceHints, + }; +} + export function sanitizeInheritedPaperclipEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...baseEnv }; for (const key of Object.keys(env)) { diff --git a/packages/adapters/acpx-local/src/server/execute.test.ts b/packages/adapters/acpx-local/src/server/execute.test.ts index 5ebc239e..b009b1bc 100644 --- a/packages/adapters/acpx-local/src/server/execute.test.ts +++ b/packages/adapters/acpx-local/src/server/execute.test.ts @@ -56,7 +56,13 @@ function buildRuntime() { }; } -async function runExecutor(config: Record) { +async function runExecutor( + config: Record, + options: { + context?: Record; + executionTransport?: Record; + } = {}, +) { const runtimeOptions: Record[] = []; const meta: Record[] = []; const logs: Array<{ stream: string; text: string }> = []; @@ -73,12 +79,13 @@ async function runExecutor(config: Record) { id: "agent-1", companyId: "company-1", }, - runtime: {}, - config, - context: {}, - onLog: async (stream: "stdout" | "stderr", text: string) => { - logs.push({ stream, text }); - }, + runtime: {}, + config, + context: options.context ?? {}, + executionTransport: options.executionTransport, + onLog: async (stream: "stdout" | "stderr", text: string) => { + logs.push({ stream, text }); + }, onMeta: async (payload: unknown) => { meta.push(payload as Record); }, @@ -257,6 +264,57 @@ describe("acpx_local runtime skill isolation", () => { expect(env).not.toContain("old-key"); }); + it("shapes ACPX wrapper workspace env for remote execution identities", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const workspaceDir = path.join(root, "workspace"); + await fs.mkdir(workspaceDir, { recursive: true }); + + await runExecutor( + { + agentCommand: "node ./fake-acp.js", + stateDir, + }, + { + context: { + paperclipWorkspace: { + cwd: workspaceDir, + source: "project_primary", + strategy: "git_worktree", + workspaceId: "workspace-1", + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "main", + branchName: "feature/remote-acpx", + worktreePath: workspaceDir, + }, + }, + executionTransport: { + remoteExecution: { + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteWorkspacePath: "/remote/workspace", + remoteCwd: "/remote/workspace", + privateKey: "PRIVATE KEY", + knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", + strictHostKeyChecking: true, + }, + }, + }, + ); + + const wrappers = await fs.readdir(path.join(stateDir, "wrappers")); + const envPath = path.join( + stateDir, + "wrappers", + wrappers.find((name) => name.endsWith(".env"))!, + ); + const env = await fs.readFile(envPath, "utf8"); + + expect(env).toContain("PAPERCLIP_WORKSPACE_CWD='/remote/workspace'"); + expect(env).not.toContain("PAPERCLIP_WORKSPACE_WORKTREE_PATH="); + }); + it("cleans aged credential wrapper scripts across ACPX agent changes", async () => { const root = await makeTempRoot(); const stateDir = path.join(root, "state"); diff --git a/packages/adapters/acpx-local/src/server/execute.ts b/packages/adapters/acpx-local/src/server/execute.ts index 6c2840e0..dc5d4d0c 100644 --- a/packages/adapters/acpx-local/src/server/execute.ts +++ b/packages/adapters/acpx-local/src/server/execute.ts @@ -21,6 +21,7 @@ import { renderPaperclipWakePrompt, renderTemplate, resolvePaperclipDesiredSkillNames, + shapePaperclipWorkspaceEnvForExecution, stringifyPaperclipWakePayload, type PaperclipSkillEntry, } from "@paperclipai/adapter-utils/server-utils"; @@ -622,6 +623,21 @@ async function buildRuntime(input: { const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); + const executionTarget = readAdapterExecutionTarget({ + executionTarget: input.ctx.executionTarget, + legacyRemoteExecution: input.ctx.executionTransport?.remoteExecution, + }); + const remoteExecutionIdentity = adapterExecutionTargetSessionIdentity(executionTarget); + const effectiveExecutionCwd = + remoteExecutionIdentity && typeof remoteExecutionIdentity.remoteCwd === "string" + ? remoteExecutionIdentity.remoteCwd + : cwd; + const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({ + workspaceCwd: effectiveWorkspaceCwd, + workspaceWorktreePath, + executionTargetIsRemote: remoteExecutionIdentity !== null, + executionCwd: effectiveExecutionCwd, + }); await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); const acpxAgent = normalizeAgent(config); @@ -659,14 +675,14 @@ async function buildRuntime(input: { if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; applyPaperclipWorkspaceEnv(env, { - workspaceCwd: effectiveWorkspaceCwd, + workspaceCwd: shapedWorkspaceEnv.workspaceCwd, workspaceSource, workspaceStrategy, workspaceId, workspaceRepoUrl, workspaceRepoRef, workspaceBranch, - workspaceWorktreePath, + workspaceWorktreePath: shapedWorkspaceEnv.workspaceWorktreePath, agentHome, }); for (const [key, value] of Object.entries(envConfig)) { @@ -718,11 +734,6 @@ async function buildRuntime(input: { const wrapperPath = wrapper?.wrapperPath ?? null; const overrides = wrapperPath ? { [acpxAgent]: wrapperPath } : undefined; const agentRegistry = createAgentRegistry({ overrides }); - const executionTarget = readAdapterExecutionTarget({ - executionTarget: input.ctx.executionTarget, - legacyRemoteExecution: input.ctx.executionTransport?.remoteExecution, - }); - const remoteExecutionIdentity = adapterExecutionTargetSessionIdentity(executionTarget); const fingerprint = shortHash({ acpxAgent, agentCommand: agentCommand ?? acpxAgent, 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 e435ffc3..dd5ab54c 100644 --- a/packages/adapters/claude-local/src/server/execute.remote.test.ts +++ b/packages/adapters/claude-local/src/server/execute.remote.test.ts @@ -92,8 +92,10 @@ describe("claude remote execution", () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-claude-remote-")); cleanupDirs.push(rootDir); const workspaceDir = path.join(rootDir, "workspace"); + const alternateWorkspaceDir = path.join(rootDir, "workspace-other"); const instructionsPath = path.join(rootDir, "instructions.md"); await mkdir(workspaceDir, { recursive: true }); + await mkdir(alternateWorkspaceDir, { recursive: true }); await writeFile(instructionsPath, "Use the remote workspace.\n", "utf8"); await execute({ @@ -119,7 +121,27 @@ describe("claude remote execution", () => { paperclipWorkspace: { cwd: workspaceDir, source: "project_primary", + strategy: "git_worktree", + workspaceId: "workspace-1", + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "main", + branchName: "feature/remote-claude", + worktreePath: workspaceDir, }, + paperclipWorkspaces: [ + { + workspaceId: "workspace-1", + cwd: workspaceDir, + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "main", + }, + { + workspaceId: "workspace-2", + cwd: alternateWorkspaceDir, + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "feature/other", + }, + ], }, executionTransport: { remoteExecution: { @@ -154,6 +176,21 @@ describe("claude remote execution", () => { expect(call?.[2]).toContain("/remote/workspace/.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?.[3].env.PAPERCLIP_WORKSPACE_WORKTREE_PATH).toBeUndefined(); + expect(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([ + { + workspaceId: "workspace-1", + cwd: "/remote/workspace", + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "main", + }, + { + workspaceId: "workspace-2", + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "feature/other", + }, + ]); 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"); diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 804a9c29..7c77b66b 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -35,6 +35,7 @@ import { ensurePathInEnv, renderTemplate, renderPaperclipWakePrompt, + shapePaperclipWorkspaceEnvForExecution, stringifyPaperclipWakePayload, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, } from "@paperclipai/adapter-utils/server-utils"; @@ -144,6 +145,15 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise 0; const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); + const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget); + const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd); + const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({ + workspaceCwd: effectiveWorkspaceCwd, + workspaceWorktreePath, + workspaceHints, + executionTargetIsRemote, + executionCwd: effectiveExecutionCwd, + }); await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); const envConfig = parseObject(config.env); @@ -199,18 +209,18 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise 0) { - env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); + if (shapedWorkspaceEnv.workspaceHints.length > 0) { + env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(shapedWorkspaceEnv.workspaceHints); } if (runtimeServiceIntents.length > 0) { env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents); diff --git a/packages/adapters/codex-local/src/server/execute.remote.test.ts b/packages/adapters/codex-local/src/server/execute.remote.test.ts index acbf0f78..74e11ab4 100644 --- a/packages/adapters/codex-local/src/server/execute.remote.test.ts +++ b/packages/adapters/codex-local/src/server/execute.remote.test.ts @@ -93,6 +93,8 @@ describe("codex remote execution", () => { await mkdir(codexHomeDir, { recursive: true }); await writeFile(path.join(rootDir, "instructions.md"), "Use the remote workspace.\n", "utf8"); await writeFile(path.join(codexHomeDir, "auth.json"), "{}", "utf8"); + const alternateWorkspaceDir = path.join(rootDir, "alternate-workspace"); + await mkdir(alternateWorkspaceDir, { recursive: true }); await execute({ runId: "run-1", @@ -119,7 +121,27 @@ describe("codex remote execution", () => { paperclipWorkspace: { cwd: workspaceDir, source: "project_primary", + strategy: "git_worktree", + workspaceId: "workspace-1", + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "main", + branchName: "feature/remote-codex", + worktreePath: workspaceDir, }, + paperclipWorkspaces: [ + { + workspaceId: "workspace-1", + cwd: workspaceDir, + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "main", + }, + { + workspaceId: "workspace-2", + cwd: alternateWorkspaceDir, + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "feature/other", + }, + ], }, executionTransport: { remoteExecution: { @@ -153,6 +175,21 @@ describe("codex remote execution", () => { | [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.PAPERCLIP_WORKSPACE_WORKTREE_PATH).toBeUndefined(); + expect(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([ + { + workspaceId: "workspace-1", + cwd: "/remote/workspace", + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "main", + }, + { + workspaceId: "workspace-2", + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "feature/other", + }, + ]); 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"); diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index e2617e36..dd2489ed 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -30,6 +30,7 @@ import { resolvePaperclipDesiredSkillNames, renderTemplate, renderPaperclipWakePrompt, + shapePaperclipWorkspaceEnvForExecution, stringifyPaperclipWakePayload, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, joinPromptSections, @@ -347,6 +348,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise { await onLog( @@ -425,18 +433,18 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) { - env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); + if (shapedWorkspaceEnv.workspaceHints.length > 0) { + env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(shapedWorkspaceEnv.workspaceHints); } if (runtimeServiceIntents.length > 0) { env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents); diff --git a/packages/adapters/cursor-local/src/server/execute.remote.test.ts b/packages/adapters/cursor-local/src/server/execute.remote.test.ts index fcf602e3..c6450aea 100644 --- a/packages/adapters/cursor-local/src/server/execute.remote.test.ts +++ b/packages/adapters/cursor-local/src/server/execute.remote.test.ts @@ -99,7 +99,9 @@ describe("cursor remote execution", () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-remote-")); cleanupDirs.push(rootDir); const workspaceDir = path.join(rootDir, "workspace"); + const alternateWorkspaceDir = path.join(rootDir, "workspace-other"); await mkdir(workspaceDir, { recursive: true }); + await mkdir(alternateWorkspaceDir, { recursive: true }); const result = await execute({ runId: "run-1", @@ -124,6 +126,20 @@ describe("cursor remote execution", () => { cwd: workspaceDir, source: "project_primary", }, + paperclipWorkspaces: [ + { + workspaceId: "workspace-1", + cwd: workspaceDir, + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "main", + }, + { + workspaceId: "workspace-2", + cwd: alternateWorkspaceDir, + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "feature/other", + }, + ], }, executionTransport: { remoteExecution: { @@ -167,6 +183,20 @@ describe("cursor remote execution", () => { | undefined; expect(call?.[2]).toContain("--workspace"); expect(call?.[2]).toContain("/remote/workspace"); + expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe("/remote/workspace"); + expect(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([ + { + workspaceId: "workspace-1", + cwd: "/remote/workspace", + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "main", + }, + { + workspaceId: "workspace-2", + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "feature/other", + }, + ]); 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"); diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index 123b75be..2f521128 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -36,6 +36,7 @@ import { removeMaintainerOnlySkillSymlinks, renderTemplate, renderPaperclipWakePrompt, + shapePaperclipWorkspaceEnvForExecution, stringifyPaperclipWakePayload, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, joinPromptSections, @@ -221,6 +222,13 @@ 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, + }); await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); const cursorSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); const desiredCursorSkillNames = resolvePaperclipDesiredSkillNames(config, cursorSkillEntries); @@ -281,15 +289,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) { - env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); + if (shapedWorkspaceEnv.workspaceHints.length > 0) { + env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(shapedWorkspaceEnv.workspaceHints); } for (const [k, v] of Object.entries(envConfig)) { if (typeof v === "string") env[k] = v; @@ -334,7 +342,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise Promise) | null = null; let localSkillsDir: string | null = null; let remoteRuntimeRootDir: string | null = null; diff --git a/packages/adapters/gemini-local/src/server/execute.remote.test.ts b/packages/adapters/gemini-local/src/server/execute.remote.test.ts index 563d56dc..f76ab555 100644 --- a/packages/adapters/gemini-local/src/server/execute.remote.test.ts +++ b/packages/adapters/gemini-local/src/server/execute.remote.test.ts @@ -105,7 +105,9 @@ describe("gemini remote execution", () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-remote-")); cleanupDirs.push(rootDir); const workspaceDir = path.join(rootDir, "workspace"); + const alternateWorkspaceDir = path.join(rootDir, "workspace-other"); await mkdir(workspaceDir, { recursive: true }); + await mkdir(alternateWorkspaceDir, { recursive: true }); const result = await execute({ runId: "run-1", @@ -130,6 +132,20 @@ describe("gemini remote execution", () => { cwd: workspaceDir, source: "project_primary", }, + paperclipWorkspaces: [ + { + workspaceId: "workspace-1", + cwd: workspaceDir, + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "main", + }, + { + workspaceId: "workspace-2", + cwd: alternateWorkspaceDir, + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "feature/other", + }, + ], }, executionTransport: { remoteExecution: { @@ -171,6 +187,20 @@ 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(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([ + { + workspaceId: "workspace-1", + cwd: "/remote/workspace", + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "main", + }, + { + workspaceId: "workspace-2", + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "feature/other", + }, + ]); 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"); diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts index 4c7e8242..c68dd8cd 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -39,6 +39,7 @@ import { parseObject, renderTemplate, renderPaperclipWakePrompt, + shapePaperclipWorkspaceEnvForExecution, stringifyPaperclipWakePayload, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, runChildProcess, @@ -199,6 +200,13 @@ 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, + }); await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); const geminiSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); const desiredGeminiSkillNames = resolvePaperclipDesiredSkillNames(config, geminiSkillEntries); @@ -243,14 +251,16 @@ 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: effectiveWorkspaceCwd, + workspaceCwd: shapedWorkspaceEnv.workspaceCwd, workspaceSource, workspaceId, workspaceRepoUrl, workspaceRepoRef, agentHome, }); - if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); + 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; } @@ -279,7 +289,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) return fromExtraArgs; return asStringArray(config.args); })(); - const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd); let restoreRemoteWorkspace: (() => Promise) | null = null; let remoteSkillsDir: string | null = null; let localSkillsDir: string | null = null; 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 21687730..a4287926 100644 --- a/packages/adapters/opencode-local/src/server/execute.remote.test.ts +++ b/packages/adapters/opencode-local/src/server/execute.remote.test.ts @@ -103,7 +103,9 @@ describe("opencode remote execution", () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-remote-")); cleanupDirs.push(rootDir); const workspaceDir = path.join(rootDir, "workspace"); + const alternateWorkspaceDir = path.join(rootDir, "workspace-other"); await mkdir(workspaceDir, { recursive: true }); + await mkdir(alternateWorkspaceDir, { recursive: true }); const result = await execute({ runId: "run-1", @@ -129,6 +131,20 @@ describe("opencode remote execution", () => { cwd: workspaceDir, source: "project_primary", }, + paperclipWorkspaces: [ + { + workspaceId: "workspace-1", + cwd: workspaceDir, + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "main", + }, + { + workspaceId: "workspace-2", + cwd: alternateWorkspaceDir, + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "feature/other", + }, + ], }, executionTransport: { remoteExecution: { @@ -173,6 +189,20 @@ describe("opencode 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(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([ + { + workspaceId: "workspace-1", + cwd: "/remote/workspace", + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "main", + }, + { + workspaceId: "workspace-2", + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "feature/other", + }, + ]); 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"); diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index ca273ac8..6630ce98 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -34,6 +34,7 @@ import { ensurePathInEnv, renderTemplate, renderPaperclipWakePrompt, + shapePaperclipWorkspaceEnvForExecution, stringifyPaperclipWakePayload, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, runChildProcess, @@ -154,6 +155,13 @@ 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, + }); await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); const openCodeSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); const desiredOpenCodeSkillNames = resolvePaperclipDesiredSkillNames(config, openCodeSkillEntries); @@ -202,14 +210,16 @@ 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: effectiveWorkspaceCwd, + workspaceCwd: shapedWorkspaceEnv.workspaceCwd, workspaceSource, workspaceId, workspaceRepoUrl, workspaceRepoRef, agentHome, }); - if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); + 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; } 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 c20df55b..9a36bad7 100644 --- a/packages/adapters/pi-local/src/server/execute.remote.test.ts +++ b/packages/adapters/pi-local/src/server/execute.remote.test.ts @@ -108,7 +108,9 @@ describe("pi remote execution", () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pi-remote-")); cleanupDirs.push(rootDir); const workspaceDir = path.join(rootDir, "workspace"); + const alternateWorkspaceDir = path.join(rootDir, "workspace-other"); await mkdir(workspaceDir, { recursive: true }); + await mkdir(alternateWorkspaceDir, { recursive: true }); const result = await execute({ runId: "run-1", @@ -134,6 +136,20 @@ describe("pi remote execution", () => { cwd: workspaceDir, source: "project_primary", }, + paperclipWorkspaces: [ + { + workspaceId: "workspace-1", + cwd: workspaceDir, + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "main", + }, + { + workspaceId: "workspace-2", + cwd: alternateWorkspaceDir, + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "feature/other", + }, + ], }, executionTransport: { remoteExecution: { @@ -178,6 +194,20 @@ describe("pi remote execution", () => { 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(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([ + { + workspaceId: "workspace-1", + cwd: "/remote/workspace", + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "main", + }, + { + workspaceId: "workspace-2", + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "feature/other", + }, + ]); 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"); diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index 827eccf7..1d1172b7 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -36,6 +36,7 @@ import { removeMaintainerOnlySkillSymlinks, renderTemplate, renderPaperclipWakePrompt, + shapePaperclipWorkspaceEnvForExecution, stringifyPaperclipWakePayload, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, runChildProcess, @@ -179,6 +180,12 @@ 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, + workspaceCwd: shapedWorkspaceEnv.workspaceCwd, workspaceSource, workspaceId, workspaceRepoUrl, workspaceRepoRef, agentHome, }); - if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); + 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; }