From 4e00818574ef7d45977593b4e63a21dc42c87094 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:29:26 -0500 Subject: [PATCH] fix(runtime): support in-place workspace realization (#10230) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the control plane people use to coordinate AI agents and their execution environments. > - Environment realization decides where an agent runs and which filesystem and toolchain are authoritative. > - Copy-based realization is unsafe for container-anchored tasks because absolute paths such as `/app` can point outside the synchronized tree and task-specific binaries may be absent. > - That mismatch can let an agent successfully verify work in a phantom writable path while sync-back silently discards the result. > - Existing task environments already provide the authoritative filesystem and toolchain, so they should be executed in place rather than copied. > - Copy mode still needs explicit confinement rules so aliases target the synchronized workspace and unsynchronized writable paths fail visibly. > - This pull request adds typed realization metadata, propagates the authoritative root through orchestration, and teaches Codex to honor it. > - The benefit is that container-anchored tasks operate on verifier-visible state with the intended tools, while copy mode remains safe and backward compatible. ## Linked Issues or Issue Description No public GitHub issue exists for this defect. GitHub duplicate searches for in-place execution, workspace realization, and authoritative workspace roots found no related pull request to link. ### What happened? Environment-backed agent runs were always realized through a copied workspace. Tasks anchored to absolute container paths could therefore write outside the synchronized tree, and task-provided toolchains were unavailable in the copy. A run could report success even though sync-back discarded its output. ### Expected behavior Existing task environments should run against their real authoritative root and toolchain. Copy-mode runs should map declared absolute aliases into the synchronized tree and reject writable paths that cannot be restored. ### Steps to reproduce 1. Run a Codex task environment whose required files live under `/app` or `/workspace` and whose required binary exists only in the task container. 2. Observe that copy realization changes the effective filesystem/toolchain or permits writes outside the synchronized root. 3. Complete and verify the task inside the agent sandbox. 4. Observe that the verifier cannot see out-of-tree artifacts or that task-specific commands were unavailable. ### Reproduction context - Paperclip commit: `3a16b91217483d2c233926de5b7f7bc3a1077924` - Deployment: built from source in a task-container execution environment - Adapter: Codex local - Database: not database-related - Access context: agent execution ## What Changed - Added typed `copy | in_place` workspace-realization metadata, authoritative roots, confined aliases, and outbound restore paths to shared execution-target contracts. - Selected in-place realization for existing task environments and skipped archive prepare/restore when the authoritative environment is used directly. - Propagated the authoritative root into adapter context so Codex uses it for cwd and `PAPERCLIP_WORKSPACE_*` semantics, including ACP execution. - Bound copy-mode aliases such as `/app` to the synchronized workspace and rejected writable out-of-tree paths without explicit restore mappings. - Added focused regression coverage while preserving existing copy-mode archive restore behavior. ## Verification - `pnpm --filter @paperclipai/shared typecheck` — passed. - `pnpm exec vitest run packages/adapter-utils/src/local-process-sandbox.test.ts packages/adapters/codex-local/src/server/acp.test.ts packages/adapters/codex-local/src/server/execute.remote.test.ts server/src/__tests__/environment-run-orchestrator.test.ts` — 48 passed, 4 skipped. - `pnpm -r typecheck` — passed. - `env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN pnpm test:run` — passed across all general and serialized Vitest shards. - `pnpm build` — passed. - Codex `k=1` acceptance run completed July 24, 2026 at 23:54:30 UTC with 4 completed, 0 exceptions, and mean reward 1.0: `build-cython-ext`, `openssl-selfsigned-cert`, `prove-plus-comm`, and `sqlite-db-truncate` each received terminal grade 1.0 against real task-environment paths and toolchains. ## Risks - In-place mode deliberately exposes the authoritative task root to the adapter; incorrect environment metadata could point execution at the wrong root. Typed metadata and focused orchestration tests cover selection and propagation. - Copy-mode writable-path validation is stricter and may reject previously accepted unsafe configurations. The rejection is intentional and produces a visible error instead of silently losing output. - The acceptance run is focused on four Codex task-environment workloads, not a broad cross-adapter benchmark. Existing copy-mode archive tests and the full repository suite remain green. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex CLI coding agent; exact model ID and context-window size were not exposed to this runtime. Capabilities used: extended reasoning, repository editing, shell execution, test/build execution, Git, GitHub CLI, and Paperclip API tool use. ## 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 searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../src/command-managed-runtime.test.ts | 54 ++++++++ .../src/command-managed-runtime.ts | 3 + .../adapter-utils/src/execution-target.ts | 27 +++- .../src/local-process-sandbox.test.ts | 40 ++++++ .../src/local-process-sandbox.ts | 40 ++++++ .../src/remote-managed-runtime.test.ts | 95 +++++++++++++ .../src/remote-managed-runtime.ts | 100 +++++++++----- .../src/sandbox-managed-runtime.ts | 126 ++++++++++-------- .../codex-local/src/server/acp.test.ts | 24 ++++ .../adapters/codex-local/src/server/acp.ts | 16 +++ .../src/server/codex-auth-copyback.test.ts | 19 +++ .../src/server/codex-auth-copyback.ts | 3 +- .../src/server/execute.remote.test.ts | 72 ++++++++++ .../codex-local/src/server/execute.ts | 36 +++-- .../shared/src/types/workspace-runtime.ts | 10 ++ .../environment-run-orchestrator.test.ts | 53 +++++++- .../services/environment-run-orchestrator.ts | 30 +++++ server/src/services/workspace-realization.ts | 36 ++++- 18 files changed, 681 insertions(+), 103 deletions(-) create mode 100644 packages/adapter-utils/src/remote-managed-runtime.test.ts diff --git a/packages/adapter-utils/src/command-managed-runtime.test.ts b/packages/adapter-utils/src/command-managed-runtime.test.ts index 2cd4d853a2..7dc7d3a02a 100644 --- a/packages/adapter-utils/src/command-managed-runtime.test.ts +++ b/packages/adapter-utils/src/command-managed-runtime.test.ts @@ -239,6 +239,60 @@ describe("command managed runtime", () => { expect(calls.filter((call) => call.stdin != null).length).toBe(1); }); + it("stages runtime assets without replacing or restoring an in-place workspace", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-command-runtime-assets-only-")); + cleanupDirs.push(rootDir); + + const localWorkspaceDir = path.join(rootDir, "local-workspace"); + const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); + const localHomeDir = path.join(rootDir, "local-home"); + await mkdir(localWorkspaceDir, { recursive: true }); + await mkdir(remoteWorkspaceDir, { recursive: true }); + await mkdir(localHomeDir, { recursive: true }); + await writeFile(path.join(localWorkspaceDir, "README.md"), "local workspace\n", "utf8"); + await writeFile(path.join(remoteWorkspaceDir, "README.md"), "authoritative workspace\n", "utf8"); + await writeFile(path.join(localHomeDir, "auth.json"), '{"token":"host"}\n', "utf8"); + + const { runner } = makeSpawnRunner(); + let restoredAuth = ""; + const prepared = await prepareCommandManagedRuntime({ + runner, + spec: { + remoteCwd: remoteWorkspaceDir, + timeoutMs: 30_000, + }, + adapterKey: "codex", + workspaceLocalDir: localWorkspaceDir, + syncWorkspace: false, + assets: [ + { + key: "home", + localDir: localHomeDir, + restore: async ({ assetDir, readFile }) => { + restoredAuth = (await readFile(path.join(assetDir, "auth.json"))).toString("utf8"); + }, + }, + ], + }); + + expect(prepared.workspaceRemoteDir).toBe(remoteWorkspaceDir); + expect(prepared.assetDirs.home).toBe(path.join(remoteWorkspaceDir, ".paperclip-runtime", "codex", "home")); + await expect(readFile(path.join(remoteWorkspaceDir, "README.md"), "utf8")).resolves.toBe( + "authoritative workspace\n", + ); + await expect(readFile(path.join(prepared.assetDirs.home, "auth.json"), "utf8")).resolves.toBe( + '{"token":"host"}\n', + ); + + await writeFile(path.join(prepared.assetDirs.home, "auth.json"), '{"token":"remote"}\n', "utf8"); + await prepared.restoreWorkspace(); + + expect(restoredAuth).toBe('{"token":"remote"}\n'); + await expect(readFile(path.join(localWorkspaceDir, "README.md"), "utf8")).resolves.toBe( + "local workspace\n", + ); + }); + it("runs setup commands from a stable root cwd when staging into a nested remote workspace dir", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-command-runtime-nested-")); cleanupDirs.push(rootDir); diff --git a/packages/adapter-utils/src/command-managed-runtime.ts b/packages/adapter-utils/src/command-managed-runtime.ts index b8b3043cca..0e93ebb175 100644 --- a/packages/adapter-utils/src/command-managed-runtime.ts +++ b/packages/adapter-utils/src/command-managed-runtime.ts @@ -271,6 +271,7 @@ export async function prepareCommandManagedRuntime(input: { adapterKey: string; workspaceLocalDir: string; workspaceRemoteDir?: string; + syncWorkspace?: boolean; workspaceExclude?: string[]; preserveAbsentOnRestore?: string[]; assets?: CommandManagedRuntimeAsset[]; @@ -325,6 +326,7 @@ export async function prepareCommandManagedRuntime(input: { adapterKey: input.adapterKey, workspaceLocalDir: input.workspaceLocalDir, workspaceRemoteDir, + syncWorkspace: input.syncWorkspace, workspaceExclude: mergeRuntimeExcludes(input.workspaceExclude), preserveAbsentOnRestore: input.preserveAbsentOnRestore, assets: input.assets, @@ -361,6 +363,7 @@ export async function prepareCommandManagedRuntime(input: { adapterKey: input.adapterKey, workspaceLocalDir: input.workspaceLocalDir, workspaceRemoteDir, + syncWorkspace: input.syncWorkspace, workspaceExclude: mergeRuntimeExcludes(input.workspaceExclude), preserveAbsentOnRestore: input.preserveAbsentOnRestore, assets: input.assets, diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index f70e8e01b4..b0a93bed05 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -42,13 +42,31 @@ import type { LocalProcessSandboxOptions } from "./local-process-sandbox.js"; export type { RuntimeProgressSink } from "./runtime-progress.js"; -export interface AdapterLocalExecutionTarget { +export type AdapterWorkspaceRealizationMode = "copy" | "in_place"; + +export interface AdapterWorkspacePathAlias { + path: string; + target: string; +} + +export interface AdapterWorkspaceRealization { + mode: AdapterWorkspaceRealizationMode; + authoritativeRoot: string; + pathAliases: AdapterWorkspacePathAlias[]; + outboundRestorePaths: string[]; +} + +interface AdapterExecutionTargetWorkspaceMetadata { + workspaceRealization?: AdapterWorkspaceRealization | null; +} + +export interface AdapterLocalExecutionTarget extends AdapterExecutionTargetWorkspaceMetadata { kind: "local"; environmentId?: string | null; leaseId?: string | null; } -export interface AdapterSshExecutionTarget { +export interface AdapterSshExecutionTarget extends AdapterExecutionTargetWorkspaceMetadata { kind: "remote"; transport: "ssh"; environmentId?: string | null; @@ -57,7 +75,7 @@ export interface AdapterSshExecutionTarget { spec: SshRemoteExecutionSpec; } -export interface AdapterSandboxExecutionTarget { +export interface AdapterSandboxExecutionTarget extends AdapterExecutionTargetWorkspaceMetadata { kind: "remote"; transport: "sandbox"; providerKey?: string | null; @@ -1084,6 +1102,7 @@ export async function prepareAdapterExecutionTargetRuntime(input: { workspaceLocalDir: string; timeoutSec?: number; workspaceRemoteDir?: string; + syncWorkspace?: boolean; workspaceExclude?: string[]; preserveAbsentOnRestore?: string[]; assets?: AdapterManagedRuntimeAsset[]; @@ -1115,6 +1134,7 @@ export async function prepareAdapterExecutionTargetRuntime(input: { adapterKey: input.adapterKey, workspaceLocalDir: input.workspaceLocalDir, workspaceRemoteDir: input.workspaceRemoteDir, + syncWorkspace: input.syncWorkspace, assets: input.assets, onProgress: input.onProgress, }); @@ -1142,6 +1162,7 @@ export async function prepareAdapterExecutionTargetRuntime(input: { adapterKey: input.adapterKey, workspaceLocalDir: input.workspaceLocalDir, workspaceRemoteDir: input.workspaceRemoteDir, + syncWorkspace: input.syncWorkspace, workspaceExclude: input.workspaceExclude, preserveAbsentOnRestore: input.preserveAbsentOnRestore, assets: input.assets, diff --git a/packages/adapter-utils/src/local-process-sandbox.test.ts b/packages/adapter-utils/src/local-process-sandbox.test.ts index 17d6055b86..36b3f8a4d3 100644 --- a/packages/adapter-utils/src/local-process-sandbox.test.ts +++ b/packages/adapter-utils/src/local-process-sandbox.test.ts @@ -85,6 +85,46 @@ describe("local process sandbox", () => { expect(target.args.slice(-3)).toEqual([process.execPath, "-e", "console.log('ok')"]); }); + it("binds a confined absolute alias to the synchronized workspace", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-fs-alias-")); + cleanup.push(root); + const workspace = path.join(root, "workspace"); + await fs.mkdir(workspace); + + const target = await buildLocalProcessSandboxSpawnTarget({ + executable: process.execPath, + args: ["-e", "process.exit(0)"], + cwd: workspace, + options: { + workspaceDir: workspace, + filesystemScope: "workspace", + pathAliases: [{ path: "/app", target: workspace }], + }, + }); + + expect(target.args).toEqual(expect.arrayContaining(["--bind", workspace, "/app"])); + }); + + it("rejects writable out-of-tree paths without an outbound restore mapping", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-fs-outbound-")); + cleanup.push(root); + const workspace = path.join(root, "workspace"); + const outside = path.join(root, "outside"); + await fs.mkdir(workspace); + await fs.mkdir(outside); + + await expect(buildLocalProcessSandboxSpawnTarget({ + executable: process.execPath, + args: ["-e", "process.exit(0)"], + cwd: workspace, + options: { + workspaceDir: workspace, + filesystemScope: "workspace", + extraPaths: [{ path: outside, access: "rw" }], + }, + })).rejects.toThrow("has no outbound restore mapping"); + }); + it("builds a network-only namespace without changing filesystem visibility", async () => { const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-network-sandbox-")); cleanup.push(workspace); diff --git a/packages/adapter-utils/src/local-process-sandbox.ts b/packages/adapter-utils/src/local-process-sandbox.ts index 971aec31d2..7015d2a4ba 100644 --- a/packages/adapter-utils/src/local-process-sandbox.ts +++ b/packages/adapter-utils/src/local-process-sandbox.ts @@ -12,11 +12,18 @@ export interface LocalProcessSandboxPath { access: LocalProcessSandboxAccess; } +export interface LocalProcessSandboxPathAlias { + path: string; + target: string; +} + export interface LocalProcessSandboxOptions { workspaceDir: string; filesystemScope?: "workspace" | null; managedPaths?: LocalProcessSandboxPath[]; extraPaths?: LocalProcessSandboxPath[]; + pathAliases?: LocalProcessSandboxPathAlias[]; + outboundRestorePaths?: string[]; homeDir?: string | null; networkScope?: LocalProcessNetworkScope | null; networkAllowlist?: string[]; @@ -319,6 +326,23 @@ export async function buildLocalProcessSandboxSpawnTarget(input: { if (relativeCwd.startsWith("..") || path.isAbsolute(relativeCwd)) { throw new Error(`Sandbox cwd "${cwd}" must be inside workspaceDir "${workspaceDir}".`); } + const outboundRestorePaths = (input.options.outboundRestorePaths ?? []).map((candidate, index) => + normalizeAbsolutePath(candidate, `Sandbox outboundRestorePaths[${index}]`)); + for (const [index, extraPath] of (input.options.extraPaths ?? []).entries()) { + if (extraPath.access !== "rw") continue; + const normalizedExtraPath = normalizeAbsolutePath(extraPath.path, `Sandbox extraPaths[${index}].path`); + const relativeToWorkspace = path.relative(workspaceDir, normalizedExtraPath); + const synchronized = !relativeToWorkspace.startsWith("..") && !path.isAbsolute(relativeToWorkspace); + const restored = outboundRestorePaths.some((restorePath) => { + const relative = path.relative(restorePath, normalizedExtraPath); + return !relative.startsWith("..") && !path.isAbsolute(relative); + }); + if (!synchronized && !restored) { + throw new Error( + `Writable sandbox path "${normalizedExtraPath}" is outside synchronized workspace "${workspaceDir}" and has no outbound restore mapping.`, + ); + } + } } const bwrapCommand = input.options.command?.trim() || "bwrap"; @@ -354,6 +378,22 @@ export async function buildLocalProcessSandboxSpawnTarget(input: { for (const managedPath of input.options.managedPaths ?? []) await mount(managedPath.path, managedPath.access); for (const extraPath of input.options.extraPaths ?? []) await mount(extraPath.path, extraPath.access); await mount(workspaceDir, "rw"); + for (const [index, alias] of (input.options.pathAliases ?? []).entries()) { + const aliasPath = normalizeAbsolutePath(alias.path, `Sandbox pathAliases[${index}].path`); + const aliasTarget = normalizeAbsolutePath(alias.target, `Sandbox pathAliases[${index}].target`); + const relativeTarget = path.relative(workspaceDir, aliasTarget); + if (relativeTarget.startsWith("..") || path.isAbsolute(relativeTarget)) { + throw new Error( + `Sandbox path alias "${aliasPath}" must target the synchronized workspace "${workspaceDir}".`, + ); + } + if (!(await pathExists(aliasTarget))) { + throw new Error(`Sandbox path alias target "${aliasTarget}" does not exist.`); + } + addParentDirectories(args, created, aliasPath); + args.push("--bind", aliasTarget, aliasPath); + created.add(aliasPath); + } if (networkScope === "allowlist") { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-network-sandbox-")); diff --git a/packages/adapter-utils/src/remote-managed-runtime.test.ts b/packages/adapter-utils/src/remote-managed-runtime.test.ts new file mode 100644 index 0000000000..0d0c98fb0e --- /dev/null +++ b/packages/adapter-utils/src/remote-managed-runtime.test.ts @@ -0,0 +1,95 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { + prepareWorkspaceForSshExecution, + restoreWorkspaceFromSshExecution, + runSshCommand, + syncDirectoryToSsh, +} = vi.hoisted(() => ({ + prepareWorkspaceForSshExecution: vi.fn(async () => ({ gitBacked: false })), + restoreWorkspaceFromSshExecution: vi.fn(async () => undefined), + runSshCommand: vi.fn(async () => ({ + stdout: Buffer.from('{"token":"remote"}\n').toString("base64"), + stderr: "", + })), + syncDirectoryToSsh: vi.fn(async () => undefined), +})); + +vi.mock("./ssh.js", () => ({ + prepareWorkspaceForSshExecution, + restoreWorkspaceFromSshExecution, + runSshCommand, + syncDirectoryToSsh, +})); + +import { prepareRemoteManagedRuntime } from "./remote-managed-runtime.js"; + +describe("remote managed runtime", () => { + const cleanupDirs: string[] = []; + + afterEach(async () => { + vi.clearAllMocks(); + while (cleanupDirs.length > 0) { + const dir = cleanupDirs.pop(); + if (!dir) continue; + await rm(dir, { recursive: true, force: true }).catch(() => undefined); + } + }); + + it("restores runtime assets without restoring an in-place SSH workspace", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-remote-runtime-assets-only-")); + cleanupDirs.push(rootDir); + const workspaceDir = path.join(rootDir, "workspace"); + const homeDir = path.join(rootDir, "home"); + await mkdir(workspaceDir, { recursive: true }); + await mkdir(homeDir, { recursive: true }); + await writeFile(path.join(homeDir, "auth.json"), '{"token":"host"}\n', "utf8"); + + let restoredAuth = ""; + const prepared = await prepareRemoteManagedRuntime({ + spec: { + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteWorkspacePath: "/app", + remoteCwd: "/app", + privateKey: "PRIVATE KEY", + knownHosts: "KNOWN HOSTS", + strictHostKeyChecking: true, + }, + runId: "run-in-place", + adapterKey: "codex", + workspaceLocalDir: workspaceDir, + workspaceRemoteDir: "/app", + syncWorkspace: false, + assets: [ + { + key: "home", + localDir: homeDir, + restore: async ({ assetDir, readFile }) => { + restoredAuth = (await readFile(path.posix.join(assetDir, "auth.json"))).toString("utf8"); + }, + }, + ], + }); + + expect(prepareWorkspaceForSshExecution).not.toHaveBeenCalled(); + expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({ + localDir: homeDir, + remoteDir: "/app/.paperclip-runtime/codex/home", + })); + + await prepared.restoreWorkspace(); + + expect(restoreWorkspaceFromSshExecution).not.toHaveBeenCalled(); + expect(runSshCommand).toHaveBeenCalledWith( + expect.anything(), + "base64 < '/app/.paperclip-runtime/codex/home/auth.json'", + { maxBuffer: 1024 * 1024 }, + ); + expect(restoredAuth).toBe('{"token":"remote"}\n'); + }); +}); diff --git a/packages/adapter-utils/src/remote-managed-runtime.ts b/packages/adapter-utils/src/remote-managed-runtime.ts index 88bb9c53fd..6d8cd3aa4b 100644 --- a/packages/adapter-utils/src/remote-managed-runtime.ts +++ b/packages/adapter-utils/src/remote-managed-runtime.ts @@ -3,9 +3,11 @@ import { GIT_ARCHIVE_EXCLUDES } from "./git-workspace-sync.js"; import { type SshRemoteExecutionSpec, prepareWorkspaceForSshExecution, + runSshCommand, restoreWorkspaceFromSshExecution, syncDirectoryToSsh, } from "./ssh.js"; +import type { SandboxManagedRuntimeAssetRestoreContext } from "./sandbox-managed-runtime.js"; import { captureDirectorySnapshot } from "./workspace-restore-merge.js"; import type { RuntimeProgressSink } from "./runtime-progress.js"; @@ -14,6 +16,7 @@ export interface RemoteManagedRuntimeAsset { localDir: string; followSymlinks?: boolean; exclude?: string[]; + restore?: (ctx: SandboxManagedRuntimeAssetRestoreContext) => Promise; } export interface PreparedRemoteManagedRuntime { @@ -39,6 +42,17 @@ function asNumber(value: unknown): number { return typeof value === "number" ? value : Number(value); } +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + +async function readRemoteFile(spec: SshRemoteExecutionSpec, remotePath: string): Promise { + const result = await runSshCommand(spec, `base64 < ${shellQuote(remotePath)}`, { + maxBuffer: 1024 * 1024, + }); + return Buffer.from(result.stdout.replace(/\s+/g, ""), "base64"); +} + export function buildRemoteExecutionSessionIdentity(spec: SshRemoteExecutionSpec | null) { if (!spec) return null; return { @@ -70,31 +84,40 @@ export async function prepareRemoteManagedRuntime(input: { adapterKey: string; workspaceLocalDir: string; workspaceRemoteDir?: string; + syncWorkspace?: boolean; assets?: RemoteManagedRuntimeAsset[]; // Upload progress sink. Threaded for the byte-counting transport rewrite; the // child task wires it into the workspace/asset transfers. onProgress?: RuntimeProgressSink; }): Promise { const baseWorkspaceRemoteDir = input.workspaceRemoteDir ?? input.spec.remoteCwd; - const workspaceRemoteDir = path.posix.join( - baseWorkspaceRemoteDir, - ".paperclip-runtime", - "runs", - input.runId, - "workspace", - ); + const syncWorkspace = input.syncWorkspace !== false; + const workspaceRemoteDir = syncWorkspace + ? path.posix.join( + baseWorkspaceRemoteDir, + ".paperclip-runtime", + "runs", + input.runId, + "workspace", + ) + : baseWorkspaceRemoteDir; const runtimeRootDir = path.posix.join(workspaceRemoteDir, ".paperclip-runtime", input.adapterKey); - const preparedWorkspace = await prepareWorkspaceForSshExecution({ - spec: input.spec, - localDir: input.workspaceLocalDir, - remoteDir: workspaceRemoteDir, - onProgress: input.onProgress, - }); - const restoreExclude = preparedWorkspace.gitBacked ? [...GIT_ARCHIVE_EXCLUDES, ".paperclip-runtime"] : [".paperclip-runtime"]; - const baselineSnapshot = await captureDirectorySnapshot(input.workspaceLocalDir, { - exclude: restoreExclude, - }); + const preparedWorkspace = syncWorkspace + ? await prepareWorkspaceForSshExecution({ + spec: input.spec, + localDir: input.workspaceLocalDir, + remoteDir: workspaceRemoteDir, + onProgress: input.onProgress, + }) + : null; + const baselineSnapshot = preparedWorkspace + ? await captureDirectorySnapshot(input.workspaceLocalDir, { + exclude: preparedWorkspace.gitBacked + ? [...GIT_ARCHIVE_EXCLUDES, ".paperclip-runtime"] + : [".paperclip-runtime"], + }) + : null; const assetDirs: Record = {}; try { @@ -112,14 +135,16 @@ export async function prepareRemoteManagedRuntime(input: { }); } } catch (error) { - await restoreWorkspaceFromSshExecution({ - spec: input.spec, - localDir: input.workspaceLocalDir, - remoteDir: workspaceRemoteDir, - baselineSnapshot, - restoreGitHistory: preparedWorkspace.gitBacked, - onProgress: input.onProgress, - }); + if (preparedWorkspace && baselineSnapshot) { + await restoreWorkspaceFromSshExecution({ + spec: input.spec, + localDir: input.workspaceLocalDir, + remoteDir: workspaceRemoteDir, + baselineSnapshot, + restoreGitHistory: preparedWorkspace.gitBacked, + onProgress: input.onProgress, + }); + } throw error; } @@ -130,14 +155,23 @@ export async function prepareRemoteManagedRuntime(input: { runtimeRootDir, assetDirs, restoreWorkspace: async (onProgress?: RuntimeProgressSink) => { - await restoreWorkspaceFromSshExecution({ - spec: input.spec, - localDir: input.workspaceLocalDir, - remoteDir: workspaceRemoteDir, - baselineSnapshot, - restoreGitHistory: preparedWorkspace.gitBacked, - onProgress, - }); + if (preparedWorkspace && baselineSnapshot) { + await restoreWorkspaceFromSshExecution({ + spec: input.spec, + localDir: input.workspaceLocalDir, + remoteDir: workspaceRemoteDir, + baselineSnapshot, + restoreGitHistory: preparedWorkspace.gitBacked, + onProgress, + }); + } + for (const asset of input.assets ?? []) { + if (!asset.restore) continue; + await asset.restore({ + assetDir: path.posix.join(runtimeRootDir, asset.key), + readFile: (remotePath) => readRemoteFile(input.spec, remotePath), + }); + } }, }; } diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.ts b/packages/adapter-utils/src/sandbox-managed-runtime.ts index 62bf2cf7d7..9eae6aadf3 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.ts @@ -561,6 +561,7 @@ export async function prepareSandboxManagedRuntime(input: { client: SandboxManagedRuntimeClient; workspaceLocalDir: string; workspaceRemoteDir?: string; + syncWorkspace?: boolean; workspaceExclude?: string[]; preserveAbsentOnRestore?: string[]; assets?: SandboxManagedRuntimeAsset[]; @@ -571,7 +572,8 @@ export async function prepareSandboxManagedRuntime(input: { }): Promise { const workspaceRemoteDir = input.workspaceRemoteDir ?? input.spec.remoteCwd; const runtimeRootDir = path.posix.join(workspaceRemoteDir, ".paperclip-runtime", input.adapterKey); - const gitSnapshot = await readGitWorkspaceSnapshot(input.workspaceLocalDir); + const syncWorkspace = input.syncWorkspace !== false; + const gitSnapshot = syncWorkspace ? await readGitWorkspaceSnapshot(input.workspaceLocalDir) : null; const gitIgnoredExcludes = gitSnapshot?.ignoredPaths; const workspaceArchiveExclude = mergeExcludes( SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES, @@ -587,9 +589,9 @@ export async function prepareSandboxManagedRuntime(input: { input.workspaceExclude, gitIgnoredExcludes, ); - const baselineSnapshot = await captureDirectorySnapshot(input.workspaceLocalDir, { - exclude: restoreExclude, - }); + const baselineSnapshot = syncWorkspace + ? await captureDirectorySnapshot(input.workspaceLocalDir, { exclude: restoreExclude }) + : null; // Prefer the provider's native file transport when it advertised the sync // verbs; otherwise every branch below falls back to the byte-identical tar + @@ -606,7 +608,7 @@ export async function prepareSandboxManagedRuntime(input: { ...(gitSnapshot ? [".git"] : []), ...(input.preserveAbsentOnRestore ?? []), ]); - if (gitSnapshot) { + if (syncWorkspace && gitSnapshot) { await emitRuntimeStatus(input.onRuntimeProgress, "git_sync", "Syncing git history to sandbox"); await withShallowGitWorkspaceClone({ localDir: input.workspaceLocalDir, @@ -648,57 +650,59 @@ export async function prepareSandboxManagedRuntime(input: { }); } - const workspaceTarPath = path.join(tempDir, "workspace.tar"); - const workspaceArchiveDir = gitSnapshot ? path.join(tempDir, "workspace-overlay") : input.workspaceLocalDir; - await emitRuntimeStatus(input.onRuntimeProgress, "config_sync", "Syncing workspace to sandbox"); - if (gitSnapshot) { - await copySelectedWorkspaceEntries({ - sourceDir: input.workspaceLocalDir, - targetDir: workspaceArchiveDir, - relativePaths: gitSnapshot.overlayPaths, - exclude: workspaceArchiveExclude, - }); - } - await createTarballFromDirectory({ - localDir: workspaceArchiveDir, - archivePath: workspaceTarPath, - exclude: gitSnapshot ? undefined : workspaceArchiveExclude, - }); - const workspaceTarBytes = await fs.readFile(workspaceTarPath); - const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-upload.tar"); - await input.client.makeDir(runtimeRootDir); - const workspaceUpload = makeTransferProgress( - input.onProgress, - "Syncing", - "to", - "workspace", - { sink: input.onRuntimeProgress, phase: "config_sync" }, - ); - await input.client.writeFile( - remoteWorkspaceTar, - toArrayBuffer(workspaceTarBytes), - workspaceUpload.options, - ); - await workspaceUpload.finish(workspaceTarBytes.byteLength, workspaceTarBytes.byteLength); - const extractWorkspaceTarCommand = gitSnapshot - ? `mkdir -p ${shellQuote(workspaceRemoteDir)} && ` + - `tar -xf ${shellQuote(remoteWorkspaceTar)} -C ${shellQuote(workspaceRemoteDir)} && ` + - `rm -f ${shellQuote(remoteWorkspaceTar)}` - : `mkdir -p ${shellQuote(workspaceRemoteDir)} && ` + - `find ${shellQuote(workspaceRemoteDir)} -mindepth 1 -maxdepth 1 ${preserveFindArgs([...preservedNames])} -exec rm -rf -- {} + && ` + - `tar -xf ${shellQuote(remoteWorkspaceTar)} -C ${shellQuote(workspaceRemoteDir)} && ` + - `rm -f ${shellQuote(remoteWorkspaceTar)}`; - await input.client.run( - `sh -c ${shellQuote(extractWorkspaceTarCommand)}`, - { timeoutMs: input.spec.timeoutMs }, - ); - if (gitSnapshot) { - await removeDeletedPathsInSandbox({ - client: input.client, - spec: input.spec, - remoteDir: workspaceRemoteDir, - deletedPaths: gitSnapshot.deletedPaths, + if (syncWorkspace) { + const workspaceTarPath = path.join(tempDir, "workspace.tar"); + const workspaceArchiveDir = gitSnapshot ? path.join(tempDir, "workspace-overlay") : input.workspaceLocalDir; + await emitRuntimeStatus(input.onRuntimeProgress, "config_sync", "Syncing workspace to sandbox"); + if (gitSnapshot) { + await copySelectedWorkspaceEntries({ + sourceDir: input.workspaceLocalDir, + targetDir: workspaceArchiveDir, + relativePaths: gitSnapshot.overlayPaths, + exclude: workspaceArchiveExclude, + }); + } + await createTarballFromDirectory({ + localDir: workspaceArchiveDir, + archivePath: workspaceTarPath, + exclude: gitSnapshot ? undefined : workspaceArchiveExclude, }); + const workspaceTarBytes = await fs.readFile(workspaceTarPath); + const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-upload.tar"); + await input.client.makeDir(runtimeRootDir); + const workspaceUpload = makeTransferProgress( + input.onProgress, + "Syncing", + "to", + "workspace", + { sink: input.onRuntimeProgress, phase: "config_sync" }, + ); + await input.client.writeFile( + remoteWorkspaceTar, + toArrayBuffer(workspaceTarBytes), + workspaceUpload.options, + ); + await workspaceUpload.finish(workspaceTarBytes.byteLength, workspaceTarBytes.byteLength); + const extractWorkspaceTarCommand = gitSnapshot + ? `mkdir -p ${shellQuote(workspaceRemoteDir)} && ` + + `tar -xf ${shellQuote(remoteWorkspaceTar)} -C ${shellQuote(workspaceRemoteDir)} && ` + + `rm -f ${shellQuote(remoteWorkspaceTar)}` + : `mkdir -p ${shellQuote(workspaceRemoteDir)} && ` + + `find ${shellQuote(workspaceRemoteDir)} -mindepth 1 -maxdepth 1 ${preserveFindArgs([...preservedNames])} -exec rm -rf -- {} + && ` + + `tar -xf ${shellQuote(remoteWorkspaceTar)} -C ${shellQuote(workspaceRemoteDir)} && ` + + `rm -f ${shellQuote(remoteWorkspaceTar)}`; + await input.client.run( + `sh -c ${shellQuote(extractWorkspaceTarCommand)}`, + { timeoutMs: input.spec.timeoutMs }, + ); + if (gitSnapshot) { + await removeDeletedPathsInSandbox({ + client: input.client, + spec: input.spec, + remoteDir: workspaceRemoteDir, + deletedPaths: gitSnapshot.deletedPaths, + }); + } } for (const asset of input.assets ?? []) { @@ -793,6 +797,16 @@ export async function prepareSandboxManagedRuntime(input: { assetDirs, restoreWorkspace: async (onProgress?: RuntimeProgressSink) => { const restoreSink = onProgress ?? input.onProgress; + if (!syncWorkspace) { + for (const asset of input.assets ?? []) { + if (!asset.restore) continue; + await asset.restore({ + assetDir: path.posix.join(runtimeRootDir, asset.key), + readFile: async (remotePath) => toBuffer(await input.client.readFile(remotePath)), + }); + } + return; + } await withTempDir("paperclip-sandbox-restore-", async (tempDir) => { let importedRef: string | null = null; let importedHead: string | null = null; @@ -902,7 +916,7 @@ export async function prepareSandboxManagedRuntime(input: { } const gitHeadToIntegrate = importedHead; await mergeDirectoryWithBaseline({ - baseline: baselineSnapshot, + baseline: baselineSnapshot!, sourceDir: extractedDir, targetDir: input.workspaceLocalDir, beforeApply: gitHeadToIntegrate diff --git a/packages/adapters/codex-local/src/server/acp.test.ts b/packages/adapters/codex-local/src/server/acp.test.ts index 9af33f26c3..b789542efa 100644 --- a/packages/adapters/codex-local/src/server/acp.test.ts +++ b/packages/adapters/codex-local/src/server/acp.test.ts @@ -347,6 +347,30 @@ describe("codex_local ACP lane", () => { ).rejects.toThrow('filesystemScope must be "workspace"'); }); + it("selects the CLI lane for in-place realization and rejects explicitly required ACP", async () => { + const executionTarget = { + kind: "remote" as const, + transport: "sandbox" as const, + remoteCwd: "/app", + workspaceRealization: { + mode: "in_place" as const, + authoritativeRoot: "/app", + pathAliases: [], + outboundRestorePaths: [], + }, + }; + await expect( + resolveCodexExecutionEngineForRun({ config: {}, executionTarget }), + ).resolves.toMatchObject({ + engine: "cli", + explicit: false, + fallbackReason: expect.stringContaining("without ACP archive staging"), + }); + await expect( + resolveCodexExecutionEngineForRun({ config: { engine: "acp" }, executionTarget }), + ).rejects.toThrow("In-place workspace realization requires the Codex CLI engine"); + }); + it("uses ACP for bridged sandbox auto runs when the ACP command is configured as a shell command", async () => { setNodeVersion("v22.13.0"); await expect( diff --git a/packages/adapters/codex-local/src/server/acp.ts b/packages/adapters/codex-local/src/server/acp.ts index cf543a2a4e..351ce5d1c1 100644 --- a/packages/adapters/codex-local/src/server/acp.ts +++ b/packages/adapters/codex-local/src/server/acp.ts @@ -81,6 +81,22 @@ export async function resolveCodexExecutionEngineForRun( input: CodexEngineResolutionInput, ): Promise { const selection = normalizeEngine(input.config.engine); + const target = readAdapterExecutionTarget({ + executionTarget: input.executionTarget, + legacyRemoteExecution: input.executionTransport?.remoteExecution, + }); + if (target?.workspaceRealization?.mode === "in_place") { + if (selection.explicit && selection.engine === "acp") { + throw new Error("In-place workspace realization requires the Codex CLI engine; ACP archive staging is not supported."); + } + return { + engine: "cli", + explicit: selection.explicit, + ...(!selection.explicit + ? { fallbackReason: "In-place workspace realization must run without ACP archive staging." } + : {}), + }; + } const filesystemScope = parseLocalProcessFilesystemScope(input.config.filesystemScope); const networkScope = parseLocalProcessNetworkScope(input.config.networkScope); if (filesystemScope || networkScope) { diff --git a/packages/adapters/codex-local/src/server/codex-auth-copyback.test.ts b/packages/adapters/codex-local/src/server/codex-auth-copyback.test.ts index 18b706b075..4aff672ddb 100644 --- a/packages/adapters/codex-local/src/server/codex-auth-copyback.test.ts +++ b/packages/adapters/codex-local/src/server/codex-auth-copyback.test.ts @@ -197,6 +197,25 @@ describe("copyBackCodexAuth", () => { } }); + it("creates a missing shared Codex home before staging copy-back", async () => { + const rootDir = await makeHostDir(); + const hostDir = path.join(rootDir, "missing-codex-home"); + const hostAuthPath = path.join(hostDir, "auth.json"); + const logs: string[] = []; + + const outcome = await copyBackCodexAuth({ + readSandboxAuth: async () => Buffer.from(apiKeyAuth("sandbox-only"), "utf8"), + hostAuthPath, + log: (line) => { + logs.push(line); + }, + }); + + expect(outcome).toBe("kept-host"); + expect(await readdir(hostDir)).toEqual([]); + expect(logs.join("\n")).not.toContain("sandbox-only"); + }); + it("preserves the host file atomically when the install cannot be staged (no partial write, no leaked temp)", async () => { // Make the host directory read-only so staging the same-filesystem temp fails // with EACCES. The host credential must be left byte-for-byte intact and no diff --git a/packages/adapters/codex-local/src/server/codex-auth-copyback.ts b/packages/adapters/codex-local/src/server/codex-auth-copyback.ts index f2649ef28d..bf960701b7 100644 --- a/packages/adapters/codex-local/src/server/codex-auth-copyback.ts +++ b/packages/adapters/codex-local/src/server/codex-auth-copyback.ts @@ -1,5 +1,5 @@ import { execFile as execFileCallback } from "node:child_process"; -import { open, rename, rm } from "node:fs/promises"; +import { mkdir, open, rename, rm } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; @@ -115,6 +115,7 @@ export async function copyBackCodexAuth(input: CopyBackCodexAuthInput): Promise< } const hostDir = path.dirname(hostAuthPath); + await mkdir(hostDir, { recursive: true }); return withDirectoryMergeLock(hostDir, async () => { // Stage on the same filesystem as the host target so both the predicate read // and the final rename stay device-local (rename across devices is not 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 44c2ed036d..4be867d325 100644 --- a/packages/adapters/codex-local/src/server/execute.remote.test.ts +++ b/packages/adapters/codex-local/src/server/execute.remote.test.ts @@ -9,6 +9,7 @@ const { resolveCommandForLogs, prepareWorkspaceForSshExecution, restoreWorkspaceFromSshExecution, + runSshCommand, syncDirectoryToSsh, startAdapterExecutionTargetPaperclipBridge, } = vi.hoisted(() => ({ @@ -25,6 +26,7 @@ const { resolveCommandForLogs: vi.fn(async () => "/usr/bin/codex"), prepareWorkspaceForSshExecution: vi.fn(async () => ({ gitBacked: false })), restoreWorkspaceFromSshExecution: vi.fn(async () => undefined), + runSshCommand: vi.fn(async () => ({ stdout: Buffer.from("{}").toString("base64"), stderr: "" })), syncDirectoryToSsh: vi.fn(async () => undefined), startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({ env: { @@ -56,6 +58,7 @@ vi.mock("@paperclipai/adapter-utils/ssh", async () => { ...actual, prepareWorkspaceForSshExecution, restoreWorkspaceFromSshExecution, + runSshCommand, syncDirectoryToSsh, }; }); @@ -545,4 +548,73 @@ describe("codex remote execution", () => { expect(call?.[3].env.CODEX_HOME).toBe(`${managedRemoteWorkspace}/.paperclip-runtime/codex/home`); expect(call?.[3].remoteExecution?.remoteCwd).toBe(managedRemoteWorkspace); }); + + it("runs in place at the authoritative root without archive prepare or restore", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-in-place-")); + cleanupDirs.push(rootDir); + const workspaceDir = path.join(rootDir, "workspace"); + const codexHomeDir = path.join(rootDir, "codex-home"); + await mkdir(workspaceDir, { recursive: true }); + await mkdir(codexHomeDir, { recursive: true }); + await writeFile(path.join(codexHomeDir, "auth.json"), "{}", "utf8"); + + await execute({ + runId: "run-in-place", + agent: { + id: "agent-1", + companyId: "company-1", + name: "CodexCoder", + adapterType: "codex_local", + adapterConfig: {}, + }, + runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, + config: { command: "codex", env: { CODEX_HOME: codexHomeDir } }, + context: { + paperclipWorkspace: { + cwd: workspaceDir, + source: "task_session", + }, + }, + executionTarget: { + kind: "remote", + transport: "ssh", + remoteCwd: "/copied/workspace", + workspaceRealization: { + mode: "in_place", + authoritativeRoot: "/app", + pathAliases: [], + outboundRestorePaths: [], + }, + spec: { + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteWorkspacePath: "/app", + remoteCwd: "/app", + privateKey: "PRIVATE KEY", + knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", + strictHostKeyChecking: true, + }, + }, + onLog: async () => {}, + }); + + expect(prepareWorkspaceForSshExecution).not.toHaveBeenCalled(); + expect(syncDirectoryToSsh).toHaveBeenCalledTimes(1); + expect(restoreWorkspaceFromSshExecution).not.toHaveBeenCalled(); + const homeSyncArgs = (syncDirectoryToSsh.mock.calls[0] as unknown[])?.[0] as { + localDir: string; + remoteDir: string; + }; + expect(homeSyncArgs.localDir).toContain("paperclip-codex-home-sync"); + expect(homeSyncArgs.remoteDir).toBe("/app/.paperclip-runtime/codex/home"); + 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("/app"); + expect(call?.[3].env.PAPERCLIP_WORKSPACE_REALIZATION_MODE).toBe("in_place"); + expect(call?.[3].env.PAPERCLIP_WORKSPACE_AUTHORITATIVE_ROOT).toBe("/app"); + expect(call?.[3].env.CODEX_HOME).toBe("/app/.paperclip-runtime/codex/home"); + expect(call?.[3].remoteExecution?.remoteCwd).toBe("/app"); + }); }); diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 7284c4dc92..966f681583 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -495,15 +495,18 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0; - const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; - const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); - const envConfig = parseObject(config.env); const executionTarget = readAdapterExecutionTarget({ executionTarget: ctx.executionTarget, legacyRemoteExecution: ctx.executionTransport?.remoteExecution, }); + const targetWorkspaceRealization = executionTarget?.workspaceRealization ?? null; + const configuredCwd = asString(config.cwd, ""); + const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; + const effectiveWorkspaceCwd = targetWorkspaceRealization?.mode === "in_place" + ? targetWorkspaceRealization.authoritativeRoot + : useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; + const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); + const envConfig = parseObject(config.env); const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget); const configuredCodexHome = typeof envConfig.CODEX_HOME === "string" && envConfig.CODEX_HOME.trim().length > 0 @@ -511,7 +514,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? envConfig.OPENAI_API_KEY.trim() @@ -620,12 +625,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise { await onLog( "stdout", - `[paperclip] Syncing workspace and CODEX_HOME to ${describeAdapterExecutionTarget(executionTarget)}.\n`, + `[paperclip] Syncing ${targetWorkspaceRealization?.mode === "in_place" ? "CODEX_HOME" : "workspace and CODEX_HOME"} to ${describeAdapterExecutionTarget(executionTarget)}.\n`, ); // Stage only the files Codex actually needs into a curated temp dir and // ship THAT as the `home` asset, instead of the whole managed @@ -642,6 +649,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise onLog("stdout", line), @@ -770,6 +782,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) { env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents); } @@ -812,6 +828,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise = {}): En metadata: { workspaceRealization: { version: 1, + mode: "copy", + authoritativeRoot: "/workspace/project", + pathAliases: [], + outboundRestorePaths: [], driver: "local", cwd: "/workspace/project", }, @@ -254,7 +258,15 @@ describe("environmentRunOrchestrator — realizeForRun", () => { const result = await orchestrator.realizeForRun(makeRealizeInput()); expect(result.lease).toBeDefined(); - expect(result.executionTarget).toEqual(executionTarget); + expect(result.executionTarget).toEqual({ + ...executionTarget, + workspaceRealization: { + mode: "copy", + authoritativeRoot: "/workspace/project", + pathAliases: [], + outboundRestorePaths: [], + }, + }); expect(result.remoteExecution).toEqual(remoteExecution); expect(result.workspaceRealization).toEqual( expect.objectContaining({ version: 1, driver: "local" }), @@ -264,6 +276,45 @@ describe("environmentRunOrchestrator — realizeForRun", () => { expect(mockResolveEnvironmentExecutionTarget).toHaveBeenCalledOnce(); }); + it("uses an in-place authoritative root on the adapter execution target", async () => { + mockResolveEnvironmentExecutionTarget.mockResolvedValue({ + kind: "remote", + transport: "sandbox", + remoteCwd: "/copied/workspace", + }); + const runtime = makeMockRuntime({ + realizeWorkspace: vi.fn().mockResolvedValue({ + cwd: "/app", + metadata: { + workspaceRealization: { + version: 1, + mode: "in_place", + authoritativeRoot: "/app", + pathAliases: [], + outboundRestorePaths: [], + }, + }, + }), + }); + const orchestrator = environmentRunOrchestrator(mockDb, { environmentRuntime: runtime }); + + const result = await orchestrator.realizeForRun( + makeRealizeInput({ environment: makeEnvironment("sandbox") }), + ); + + expect(result.executionTarget).toEqual(expect.objectContaining({ + kind: "remote", + transport: "sandbox", + remoteCwd: "/app", + workspaceRealization: { + mode: "in_place", + authoritativeRoot: "/app", + pathAliases: [], + outboundRestorePaths: [], + }, + })); + }); + it("realization failure: runtime.realizeWorkspace throws → EnvironmentRunError with code workspace_realization_failed", async () => { const runtime = makeMockRuntime({ realizeWorkspace: vi.fn().mockRejectedValue(new Error("sandbox unreachable")), diff --git a/server/src/services/environment-run-orchestrator.ts b/server/src/services/environment-run-orchestrator.ts index c1b3656974..7ebad63739 100644 --- a/server/src/services/environment-run-orchestrator.ts +++ b/server/src/services/environment-run-orchestrator.ts @@ -40,6 +40,7 @@ import { adapterExecutionTargetToRemoteSpec, type AdapterExecutionTarget, type AdapterRemoteExecutionSpec, + type AdapterWorkspaceRealization, } from "@paperclipai/adapter-utils/execution-target"; import { buildWorkspaceRealizationRequest } from "./workspace-realization.js"; import { executionWorkspaceService } from "./execution-workspaces.js"; @@ -485,6 +486,35 @@ export function environmentRunOrchestrator( lease, environmentRuntime, }); + const realizationMode = workspaceRealization.mode === "in_place" ? "in_place" : "copy"; + const authoritativeRoot = + typeof workspaceRealization.authoritativeRoot === "string" && workspaceRealization.authoritativeRoot.trim().length > 0 + ? workspaceRealization.authoritativeRoot.trim() + : realizedCwd; + const workspaceTargetMetadata: AdapterWorkspaceRealization = { + mode: realizationMode, + authoritativeRoot, + pathAliases: Array.isArray(workspaceRealization.pathAliases) + ? workspaceRealization.pathAliases.filter( + (entry): entry is { path: string; target: string } => + typeof entry === "object" && entry !== null && + typeof (entry as { path?: unknown }).path === "string" && + typeof (entry as { target?: unknown }).target === "string", + ) + : [], + outboundRestorePaths: Array.isArray(workspaceRealization.outboundRestorePaths) + ? workspaceRealization.outboundRestorePaths.filter((entry): entry is string => typeof entry === "string") + : [], + }; + if (executionTarget) { + executionTarget = { + ...executionTarget, + ...(executionTarget.kind === "remote" && realizationMode === "in_place" + ? { remoteCwd: authoritativeRoot } + : {}), + workspaceRealization: workspaceTargetMetadata, + } as AdapterExecutionTarget; + } } catch (err) { throw new EnvironmentRunError( "transport_resolution_failed", diff --git a/server/src/services/workspace-realization.ts b/server/src/services/workspace-realization.ts index 66e8745342..ff9374a0b9 100644 --- a/server/src/services/workspace-realization.ts +++ b/server/src/services/workspace-realization.ts @@ -21,6 +21,22 @@ function readNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } +function readStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.map(readString).filter((entry): entry is string => entry !== null) + : []; +} + +function readPathAliases(value: unknown): Array<{ path: string; target: string }> { + if (!Array.isArray(value)) return []; + return value.flatMap((entry) => { + const parsed = parseObject(entry); + const aliasPath = readString(parsed.path); + const target = readString(parsed.target); + return aliasPath && target ? [{ path: aliasPath, target }] : []; + }); +} + function readWorkspaceRealizationRequest(value: unknown): WorkspaceRealizationRequest | null { const parsed = parseObject(value); if (parsed.version !== 1) return null; @@ -129,9 +145,23 @@ export function buildWorkspaceRealizationRecord(input: { const port = readNumber(leaseMetadata.port); const username = readString(leaseMetadata.username); const sandboxId = readString(leaseMetadata.sandboxId) ?? readString(providerMetadata.sandboxId); + const realizationMetadata = { + ...parseObject(leaseMetadata.workspaceRealization), + ...parseObject(providerMetadata.workspaceRealization), + ...providerMetadata, + }; + const mode = realizationMetadata.mode === "in_place" || realizationMetadata.realizationMode === "in_place" + ? "in_place" as const + : "copy" as const; + const authoritativeRoot = + readString(realizationMetadata.authoritativeRoot) ?? + (mode === "in_place" ? remotePath : null) ?? + input.request.source.localPath; + const pathAliases = readPathAliases(realizationMetadata.pathAliases ?? realizationMetadata.workspaceAliases); + const outboundRestorePaths = readStringArray(realizationMetadata.outboundRestorePaths); const sync = (() => { - if (transport === "local") { + if (mode === "in_place" || transport === "local") { return { strategy: "none" as const, prepare: "Use the realized local execution workspace directly.", @@ -174,6 +204,10 @@ export function buildWorkspaceRealizationRecord(input: { return { version: 1, + mode, + authoritativeRoot, + pathAliases, + outboundRestorePaths, transport, provider, environmentId: input.environment.id,