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:
parent
cca2806e57
commit
4e00818574
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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-"));
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue