fix(runtime): support in-place workspace realization (#10230)

## 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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-25 08:29:26 -05:00 committed by GitHub
parent cca2806e57
commit 4e00818574
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 681 additions and 103 deletions

View File

@ -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);

View File

@ -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,

View File

@ -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,

View File

@ -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);

View File

@ -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-"));

View File

@ -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');
});
});

View File

@ -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<void>;
}
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<Buffer> {
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<PreparedRemoteManagedRuntime> {
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<string, string> = {};
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),
});
}
},
};
}

View File

@ -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<PreparedSandboxManagedRuntime> {
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

View File

@ -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(

View File

@ -81,6 +81,22 @@ export async function resolveCodexExecutionEngineForRun(
input: CodexEngineResolutionInput,
): Promise<CodexEngineSelection> {
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) {

View File

@ -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

View File

@ -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

View File

@ -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<string, string>; 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");
});
});

View File

@ -495,15 +495,18 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
)
: [];
const runtimePrimaryUrl = asString(context.paperclipRuntimePrimaryUrl, "");
const configuredCwd = asString(config.cwd, "");
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 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<AdapterExec
: null;
const codexSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredSkillNames = resolveCodexDesiredSkillNames(config, codexSkillEntries);
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
if (!executionTargetIsRemote) {
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
}
const configuredOpenAiApiKey =
typeof envConfig.OPENAI_API_KEY === "string" && envConfig.OPENAI_API_KEY.trim().length > 0
? envConfig.OPENAI_API_KEY.trim()
@ -620,12 +625,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
asNumber(config.timeoutSec, 0),
);
const graceSec = asNumber(config.graceSec, 20);
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
let effectiveExecutionCwd = targetWorkspaceRealization?.mode === "in_place"
? targetWorkspaceRealization.authoritativeRoot
: adapterExecutionTargetRemoteCwd(executionTarget, cwd);
const preparedExecutionTargetRuntime = executionTargetIsRemote
? await (async () => {
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<AdapterExec
adapterKey: "codex",
timeoutSec,
workspaceLocalDir: cwd,
workspaceRemoteDir:
targetWorkspaceRealization?.mode === "in_place"
? targetWorkspaceRealization.authoritativeRoot
: undefined,
syncWorkspace: targetWorkspaceRealization?.mode !== "in_place",
installCommand: SANDBOX_INSTALL_COMMAND,
detectCommand: command,
onProgress: (line) => onLog("stdout", line),
@ -770,6 +782,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
executionTargetIsRemote,
executionCwd: effectiveExecutionCwd,
});
if (targetWorkspaceRealization) {
env.PAPERCLIP_WORKSPACE_REALIZATION_MODE = targetWorkspaceRealization.mode;
env.PAPERCLIP_WORKSPACE_AUTHORITATIVE_ROOT = targetWorkspaceRealization.authoritativeRoot;
}
if (runtimeServiceIntents.length > 0) {
env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
}
@ -812,6 +828,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
filesystemScope,
managedPaths: [{ path: effectiveCodexHome, access: "rw" }],
extraPaths: parseLocalProcessSandboxExtraPaths(config.filesystemExtraPaths),
pathAliases: targetWorkspaceRealization?.mode === "copy"
? targetWorkspaceRealization.pathAliases
: [],
outboundRestorePaths: targetWorkspaceRealization?.outboundRestorePaths ?? [],
homeDir: filesystemScope ? effectiveCodexHome : null,
networkScope,
networkAllowlist: parseLocalProcessNetworkAllowlist(config.networkAllowlist),

View File

@ -297,6 +297,12 @@ export interface WorkspaceRuntimeService {
}
export type WorkspaceRealizationTransport = "local" | "ssh" | "sandbox" | "plugin";
export type WorkspaceRealizationMode = "copy" | "in_place";
export interface WorkspaceRealizationPathAlias {
path: string;
target: string;
}
export type WorkspaceRealizationSyncStrategy =
| "none"
@ -334,6 +340,10 @@ export interface WorkspaceRealizationRequest {
export interface WorkspaceRealizationRecord {
version: 1;
mode: WorkspaceRealizationMode;
authoritativeRoot: string;
pathAliases: WorkspaceRealizationPathAlias[];
outboundRestorePaths: string[];
transport: WorkspaceRealizationTransport;
provider: string | null;
environmentId: string;

View File

@ -186,6 +186,10 @@ function makeMockRuntime(overrides: Partial<EnvironmentRuntimeService> = {}): 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")),

View File

@ -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",

View File

@ -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,