mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
## 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>
621 lines
22 KiB
TypeScript
621 lines
22 KiB
TypeScript
import { mkdir, mkdtemp, readdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const {
|
|
runChildProcess,
|
|
ensureCommandResolvable,
|
|
resolveCommandForLogs,
|
|
prepareWorkspaceForSshExecution,
|
|
restoreWorkspaceFromSshExecution,
|
|
runSshCommand,
|
|
syncDirectoryToSsh,
|
|
startAdapterExecutionTargetPaperclipBridge,
|
|
} = vi.hoisted(() => ({
|
|
runChildProcess: vi.fn(async () => ({
|
|
exitCode: 1,
|
|
signal: null,
|
|
timedOut: false,
|
|
stdout: "",
|
|
stderr: "remote failure",
|
|
pid: 123,
|
|
startedAt: new Date().toISOString(),
|
|
})),
|
|
ensureCommandResolvable: vi.fn(async () => undefined),
|
|
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: {
|
|
PAPERCLIP_API_URL: "http://127.0.0.1:4310",
|
|
PAPERCLIP_API_KEY: "bridge-token",
|
|
PAPERCLIP_API_BRIDGE_MODE: "queue_v1",
|
|
},
|
|
stop: async () => {},
|
|
})),
|
|
}));
|
|
|
|
vi.mock("@paperclipai/adapter-utils/server-utils", async () => {
|
|
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/server-utils")>(
|
|
"@paperclipai/adapter-utils/server-utils",
|
|
);
|
|
return {
|
|
...actual,
|
|
ensureCommandResolvable,
|
|
resolveCommandForLogs,
|
|
runChildProcess,
|
|
};
|
|
});
|
|
|
|
vi.mock("@paperclipai/adapter-utils/ssh", async () => {
|
|
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/ssh")>(
|
|
"@paperclipai/adapter-utils/ssh",
|
|
);
|
|
return {
|
|
...actual,
|
|
prepareWorkspaceForSshExecution,
|
|
restoreWorkspaceFromSshExecution,
|
|
runSshCommand,
|
|
syncDirectoryToSsh,
|
|
};
|
|
});
|
|
|
|
vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
|
|
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/execution-target")>(
|
|
"@paperclipai/adapter-utils/execution-target",
|
|
);
|
|
return {
|
|
...actual,
|
|
startAdapterExecutionTargetPaperclipBridge,
|
|
};
|
|
});
|
|
|
|
import { execute } from "./execute.js";
|
|
|
|
describe("codex remote execution", () => {
|
|
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("prepares the workspace, syncs CODEX_HOME, and restores workspace changes for remote SSH execution", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-remote-"));
|
|
cleanupDirs.push(rootDir);
|
|
const workspaceDir = path.join(rootDir, "workspace");
|
|
const codexHomeDir = path.join(rootDir, "codex-home");
|
|
const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-1/workspace";
|
|
await mkdir(workspaceDir, { recursive: true });
|
|
await mkdir(codexHomeDir, { recursive: true });
|
|
await writeFile(path.join(rootDir, "instructions.md"), "Use the remote workspace.\n", "utf8");
|
|
await writeFile(path.join(codexHomeDir, "auth.json"), "{}", "utf8");
|
|
const alternateWorkspaceDir = path.join(rootDir, "alternate-workspace");
|
|
await mkdir(alternateWorkspaceDir, { recursive: true });
|
|
|
|
await execute({
|
|
runId: "run-1",
|
|
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: "project_primary",
|
|
strategy: "git_worktree",
|
|
workspaceId: "workspace-1",
|
|
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
|
repoRef: "main",
|
|
branchName: "feature/remote-codex",
|
|
worktreePath: workspaceDir,
|
|
},
|
|
paperclipWorkspaces: [
|
|
{
|
|
workspaceId: "workspace-1",
|
|
cwd: workspaceDir,
|
|
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
|
repoRef: "main",
|
|
},
|
|
{
|
|
workspaceId: "workspace-2",
|
|
cwd: alternateWorkspaceDir,
|
|
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
|
repoRef: "feature/other",
|
|
},
|
|
],
|
|
},
|
|
executionTransport: {
|
|
remoteExecution: {
|
|
host: "127.0.0.1",
|
|
port: 2222,
|
|
username: "fixture",
|
|
remoteWorkspacePath: "/remote/workspace",
|
|
remoteCwd: "/remote/workspace",
|
|
privateKey: "PRIVATE KEY",
|
|
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
|
|
strictHostKeyChecking: true,
|
|
},
|
|
},
|
|
onLog: async () => {},
|
|
});
|
|
|
|
expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1);
|
|
expect(prepareWorkspaceForSshExecution).toHaveBeenCalledWith(expect.objectContaining({
|
|
localDir: workspaceDir,
|
|
remoteDir: managedRemoteWorkspace,
|
|
}));
|
|
expect(syncDirectoryToSsh).toHaveBeenCalledTimes(1);
|
|
// The home asset now syncs a curated *staged* allowlist dir, not the raw
|
|
// managed CODEX_HOME, and carries no `exclude` denylist.
|
|
const homeSyncArgs = (syncDirectoryToSsh.mock.calls[0] as unknown[])?.[0] as {
|
|
localDir: string;
|
|
remoteDir: string;
|
|
followSymlinks?: boolean;
|
|
exclude?: string[];
|
|
};
|
|
expect(homeSyncArgs.localDir).not.toBe(codexHomeDir);
|
|
expect(homeSyncArgs.localDir).toContain("paperclip-codex-home-sync");
|
|
expect(homeSyncArgs.remoteDir).toBe(`${managedRemoteWorkspace}/.paperclip-runtime/codex/home`);
|
|
expect(homeSyncArgs.followSymlinks).toBe(true);
|
|
expect(homeSyncArgs.exclude).toBeUndefined();
|
|
|
|
expect(runChildProcess).toHaveBeenCalledTimes(1);
|
|
const call = runChildProcess.mock.calls[0] as unknown as
|
|
| [string, string, string[], { env: Record<string, string>; remoteExecution?: { remoteCwd: string } | null }]
|
|
| undefined;
|
|
expect(call?.[2]).not.toContain("--skip-git-repo-check");
|
|
expect(call?.[3].env.CODEX_HOME).toBe(`${managedRemoteWorkspace}/.paperclip-runtime/codex/home`);
|
|
expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe(managedRemoteWorkspace);
|
|
expect(call?.[3].env.PAPERCLIP_WORKSPACE_WORKTREE_PATH).toBeUndefined();
|
|
expect(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([
|
|
{
|
|
workspaceId: "workspace-1",
|
|
cwd: managedRemoteWorkspace,
|
|
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
|
repoRef: "main",
|
|
},
|
|
{
|
|
workspaceId: "workspace-2",
|
|
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
|
repoRef: "feature/other",
|
|
},
|
|
]);
|
|
expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310");
|
|
expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1");
|
|
expect(call?.[3].remoteExecution?.remoteCwd).toBe(managedRemoteWorkspace);
|
|
expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1);
|
|
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1);
|
|
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledWith(expect.objectContaining({
|
|
localDir: workspaceDir,
|
|
remoteDir: managedRemoteWorkspace,
|
|
}));
|
|
});
|
|
|
|
it("stages only the allowlist into the home asset: keeps config.toml/skills/auth, drops session+sqlite state, no exclude", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-allowlist-"));
|
|
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 });
|
|
|
|
// Seed the managed home with the files Codex needs (config.toml carries a
|
|
// provider-routing block; skills injected) plus large runtime decoys the
|
|
// old 4-name denylist missed.
|
|
await writeFile(path.join(codexHomeDir, "auth.json"), '{"tokens":{"account_id":"a","refresh_token":"r"}}', "utf8");
|
|
await writeFile(
|
|
path.join(codexHomeDir, "config.toml"),
|
|
'model_provider = "bifrost"\n\n[model_providers.bifrost]\nname = "bifrost"\n',
|
|
"utf8",
|
|
);
|
|
await writeFile(path.join(codexHomeDir, "instructions.md"), "hi\n", "utf8");
|
|
await mkdir(path.join(codexHomeDir, "skills", "demo"), { recursive: true });
|
|
await writeFile(path.join(codexHomeDir, "skills", "demo", "SKILL.md"), "# demo\n", "utf8");
|
|
// Decoys:
|
|
await writeFile(path.join(codexHomeDir, "logs_2.sqlite"), "x", "utf8");
|
|
await writeFile(path.join(codexHomeDir, "state_5.sqlite"), "x", "utf8");
|
|
await mkdir(path.join(codexHomeDir, "sessions"), { recursive: true });
|
|
await writeFile(path.join(codexHomeDir, "sessions", "rollout.jsonl"), "x", "utf8");
|
|
await mkdir(path.join(codexHomeDir, "tmp"), { recursive: true });
|
|
await symlink("/usr/bin/env", path.join(codexHomeDir, "tmp", "arg0"));
|
|
|
|
// Snapshot the staged dir contents at sync time — execute() removes the
|
|
// staged temp dir on teardown, so we cannot read it after execute returns.
|
|
let stagedSnapshot:
|
|
| { localDir: string; entries: string[]; skillEntries: string[]; configToml: string; authJson: string }
|
|
| null = null;
|
|
(syncDirectoryToSsh as unknown as {
|
|
mockImplementationOnce: (fn: (args: { localDir: string }) => Promise<void>) => void;
|
|
}).mockImplementationOnce(async (args: { localDir: string }) => {
|
|
const entries = (await readdir(args.localDir)).sort();
|
|
const skillEntries = entries.includes("skills")
|
|
? (await readdir(path.join(args.localDir, "skills"))).sort()
|
|
: [];
|
|
const configToml = entries.includes("config.toml")
|
|
? await readFile(path.join(args.localDir, "config.toml"), "utf8")
|
|
: "";
|
|
const authJson = entries.includes("auth.json")
|
|
? await readFile(path.join(args.localDir, "auth.json"), "utf8")
|
|
: "";
|
|
stagedSnapshot = { localDir: args.localDir, entries, skillEntries, configToml, authJson };
|
|
});
|
|
|
|
await execute({
|
|
runId: "run-allowlist",
|
|
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: "project_primary",
|
|
},
|
|
},
|
|
executionTransport: {
|
|
remoteExecution: {
|
|
host: "127.0.0.1",
|
|
port: 2222,
|
|
username: "fixture",
|
|
remoteWorkspacePath: "/remote/workspace",
|
|
remoteCwd: "/remote/workspace",
|
|
privateKey: "PRIVATE KEY",
|
|
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
|
|
strictHostKeyChecking: true,
|
|
},
|
|
},
|
|
onLog: async () => {},
|
|
});
|
|
|
|
expect(stagedSnapshot).not.toBeNull();
|
|
const snap = stagedSnapshot as unknown as {
|
|
localDir: string;
|
|
entries: string[];
|
|
skillEntries: string[];
|
|
configToml: string;
|
|
authJson: string;
|
|
};
|
|
// Allowlist present; decoys gone.
|
|
expect(snap.entries).toEqual(
|
|
["auth.json", "config.json", "config.toml", "instructions.md", "skills"]
|
|
.filter((e) => e !== "config.json") // no config.json was seeded
|
|
.sort(),
|
|
);
|
|
for (const decoy of ["logs_2.sqlite", "state_5.sqlite", "sessions", "tmp", "plugins"]) {
|
|
expect(snap.entries).not.toContain(decoy);
|
|
}
|
|
// Phase-3 behavioral invariants: provider routing + skills + auth survive staging.
|
|
expect(snap.configToml).toContain("[model_providers.bifrost]");
|
|
expect(snap.configToml).toContain("model_provider");
|
|
expect(snap.skillEntries).toContain("demo");
|
|
expect(snap.authJson).toContain("refresh_token");
|
|
|
|
// The staged temp dir is removed after execute completes (cleanup on teardown).
|
|
await expect(readdir(snap.localDir)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
|
|
it("does not resume saved Codex sessions for remote SSH execution without a matching remote identity", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-remote-resume-"));
|
|
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-ssh-no-resume",
|
|
agent: {
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
name: "CodexCoder",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
},
|
|
runtime: {
|
|
sessionId: "session-123",
|
|
sessionParams: {
|
|
sessionId: "session-123",
|
|
cwd: "/remote/workspace",
|
|
},
|
|
sessionDisplayId: "session-123",
|
|
taskKey: null,
|
|
},
|
|
config: {
|
|
command: "codex",
|
|
env: {
|
|
CODEX_HOME: codexHomeDir,
|
|
},
|
|
},
|
|
context: {
|
|
paperclipWorkspace: {
|
|
cwd: workspaceDir,
|
|
source: "project_primary",
|
|
},
|
|
},
|
|
executionTransport: {
|
|
remoteExecution: {
|
|
host: "127.0.0.1",
|
|
port: 2222,
|
|
username: "fixture",
|
|
remoteWorkspacePath: "/remote/workspace",
|
|
remoteCwd: "/remote/workspace",
|
|
privateKey: "PRIVATE KEY",
|
|
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
|
|
strictHostKeyChecking: true,
|
|
},
|
|
},
|
|
onLog: async () => {},
|
|
});
|
|
|
|
expect(runChildProcess).toHaveBeenCalledTimes(1);
|
|
const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined;
|
|
expect(call?.[2]).toEqual([
|
|
"exec",
|
|
"--json",
|
|
"-",
|
|
]);
|
|
});
|
|
|
|
it("resumes saved Codex sessions for remote SSH execution when the remote identity matches", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-remote-resume-match-"));
|
|
cleanupDirs.push(rootDir);
|
|
const workspaceDir = path.join(rootDir, "workspace");
|
|
const codexHomeDir = path.join(rootDir, "codex-home");
|
|
const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-ssh-resume/workspace";
|
|
await mkdir(workspaceDir, { recursive: true });
|
|
await mkdir(codexHomeDir, { recursive: true });
|
|
await writeFile(path.join(codexHomeDir, "auth.json"), "{}", "utf8");
|
|
|
|
await execute({
|
|
runId: "run-ssh-resume",
|
|
agent: {
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
name: "CodexCoder",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
},
|
|
runtime: {
|
|
sessionId: "session-123",
|
|
sessionParams: {
|
|
sessionId: "session-123",
|
|
cwd: managedRemoteWorkspace,
|
|
remoteExecution: {
|
|
transport: "ssh",
|
|
host: "127.0.0.1",
|
|
port: 2222,
|
|
username: "fixture",
|
|
remoteCwd: managedRemoteWorkspace,
|
|
},
|
|
},
|
|
sessionDisplayId: "session-123",
|
|
taskKey: null,
|
|
},
|
|
config: {
|
|
command: "codex",
|
|
env: {
|
|
CODEX_HOME: codexHomeDir,
|
|
},
|
|
},
|
|
context: {
|
|
paperclipWorkspace: {
|
|
cwd: workspaceDir,
|
|
source: "project_primary",
|
|
},
|
|
},
|
|
executionTransport: {
|
|
remoteExecution: {
|
|
host: "127.0.0.1",
|
|
port: 2222,
|
|
username: "fixture",
|
|
remoteWorkspacePath: "/remote/workspace",
|
|
remoteCwd: "/remote/workspace",
|
|
privateKey: "PRIVATE KEY",
|
|
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
|
|
strictHostKeyChecking: true,
|
|
},
|
|
},
|
|
onLog: async () => {},
|
|
});
|
|
|
|
expect(runChildProcess).toHaveBeenCalledTimes(1);
|
|
const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined;
|
|
expect(call?.[2]).toEqual([
|
|
"exec",
|
|
"--json",
|
|
"resume",
|
|
"session-123",
|
|
"-",
|
|
]);
|
|
});
|
|
|
|
it("uses the provider-neutral execution target contract for remote SSH execution", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-target-"));
|
|
cleanupDirs.push(rootDir);
|
|
const workspaceDir = path.join(rootDir, "workspace");
|
|
const codexHomeDir = path.join(rootDir, "codex-home");
|
|
const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-target/workspace";
|
|
await mkdir(workspaceDir, { recursive: true });
|
|
await mkdir(codexHomeDir, { recursive: true });
|
|
await writeFile(path.join(codexHomeDir, "auth.json"), "{}", "utf8");
|
|
|
|
await execute({
|
|
runId: "run-target",
|
|
agent: {
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
name: "CodexCoder",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
},
|
|
runtime: {
|
|
sessionId: "session-123",
|
|
sessionParams: {
|
|
sessionId: "session-123",
|
|
cwd: managedRemoteWorkspace,
|
|
remoteExecution: {
|
|
transport: "ssh",
|
|
host: "127.0.0.1",
|
|
port: 2222,
|
|
username: "fixture",
|
|
remoteCwd: managedRemoteWorkspace,
|
|
},
|
|
},
|
|
sessionDisplayId: "session-123",
|
|
taskKey: null,
|
|
},
|
|
config: {
|
|
command: "codex",
|
|
env: {
|
|
CODEX_HOME: codexHomeDir,
|
|
},
|
|
},
|
|
context: {
|
|
paperclipWorkspace: {
|
|
cwd: workspaceDir,
|
|
source: "project_primary",
|
|
},
|
|
},
|
|
executionTarget: {
|
|
kind: "remote",
|
|
transport: "ssh",
|
|
remoteCwd: "/remote/workspace",
|
|
spec: {
|
|
host: "127.0.0.1",
|
|
port: 2222,
|
|
username: "fixture",
|
|
remoteWorkspacePath: "/remote/workspace",
|
|
remoteCwd: "/remote/workspace",
|
|
privateKey: "PRIVATE KEY",
|
|
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
|
|
strictHostKeyChecking: true,
|
|
},
|
|
},
|
|
onLog: async () => {},
|
|
});
|
|
|
|
expect(syncDirectoryToSsh).toHaveBeenCalledTimes(1);
|
|
expect(runChildProcess).toHaveBeenCalledTimes(1);
|
|
const call = runChildProcess.mock.calls[0] as unknown as
|
|
| [string, string, string[], { env: Record<string, string>; remoteExecution?: { remoteCwd: string } | null }]
|
|
| undefined;
|
|
expect(call?.[2]).toEqual([
|
|
"exec",
|
|
"--json",
|
|
"resume",
|
|
"session-123",
|
|
"-",
|
|
]);
|
|
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");
|
|
});
|
|
});
|