Files
PaperClipAI/packages/adapters/codex-local/src/server/execute.remote.test.ts
T
7985c1e000 refactor(codex-local): stage an allowlist for sandbox CODEX_HOME sync (#9972)
## Thinking Path

> - Paperclip manages AI agents for work; agents can run in remote
sandboxes via the codex-local adapter
> - When a remote sandbox run starts, the codex-local adapter syncs the
host CODEX_HOME directory into the sandbox so Codex can authenticate and
load its configuration
> - The old approach staged the entire CODEX_HOME and relied on a 4-name
denylist (`tmp`, `.tmp`, `sessions`, `shell_snapshots`) to exclude
unnecessary state
> - The denylist was too narrow: large host-local runtime files
(`logs_2.sqlite`, `plugins/`, etc.) still uploaded on every remote run —
state the sandbox never uses
> - Additionally, `config.toml` (which embeds the managed MCP
`Authorization: Bearer …` header) was staged at 0644, relying solely on
the staging directory's 0700 permission for protection — a
credential-bearing file without per-file least-privilege
> - This pull request replaces the denylist with an explicit allowlist:
only `{auth.json, config.toml, config.json, instructions.md, skills/}`
are staged into a private `0700` temp dir; all staged regular files are
written `0600` (fail-closed on any staging error)
> - The fix is adapter-local to `packages/adapters/codex-local` with
zero changes to the shared `packages/adapter-utils` seam; inbound
auth-merge and outbound copy-back are preserved unchanged

## Linked Issues or Issue Description

No public GitHub issue for this specific bug. Describing the problem
directly:

**Bug:** The codex-local remote sandbox sync uploads more CODEX_HOME
content than the sandbox uses, and stages a credential-bearing file with
an over-permissive mode.

- **What happened:** The codex-local adapter synced the entire
CODEX_HOME directory minus four directory names (`tmp`, `.tmp`,
`sessions`, `shell_snapshots`). Large host-local runtime artifacts — a
SQLite write-ahead log that can reach 420 MB, plugin directories, crash
logs — uploaded on every remote run. `config.toml` (which embeds the
managed MCP `Authorization: Bearer …` header) was staged at `0644`; only
the staging temp dir's `0700` mode prevented broader exposure.
- **Expected behavior:** Only the files Codex needs inside the sandbox
should sync: `auth.json`, `config.toml`, `config.json`,
`instructions.md`, and `skills/`. All staged regular files should use
mode `0600` (single-layer credential protection is insufficient for
secret-bearing files).
- **Steps to reproduce:** Run a remote codex sandbox from a host with a
mature CODEX_HOME. Observe the sync payload includes `sessions/`,
`*.sqlite`, `plugins/`, etc.

Related work: Refs #9621 (outbound auth sync-back in the same
codex-local adapter area; this PR covers the inbound staging direction)

## What Changed

- New `stageCodexHomeForSync(codexHomeDir)` helper in
`packages/adapters/codex-local/src/server/codex-home.ts` stages only
allowlisted files into a fresh `fs.mkdtemp`-created `0700` temp dir,
then registers that dir as the `home` asset (dropping the old `exclude`
field entirely)
- All staged regular files written with mode `0600` — auth.json holds
single-use OAuth tokens; config.toml embeds MCP bearer headers; least
privilege applies throughout
- Symlinks dereferenced to resolved file bytes (`auth.json` single-use
token, each `skills/` entry land as real files)
- Missing-but-optional allowlist entries skipped silently (keyring-only
mode with no `auth.json`; absent `config.json`)
- Staged temp dir removed on teardown AND on error; staging is
fail-closed — a staging error aborts the run rather than shipping a
partial CODEX_HOME
- `execute.ts` updated to call `stageCodexHomeForSync` instead of the
old whole-dir + denylist path; the `exclude` field is removed from the
`home` asset
- Adapter-local fix: zero diff to `packages/adapter-utils` (shared
managed-runtime seam untouched); inbound auth-merge (`provision`) and
outbound copy-back (`restore`) preserved unchanged

## Verification

```sh
# Full codex-local test suite
npx vitest run packages/adapters/codex-local
# 174/174 pass

# Shared seam regression — must be zero diff to packages/adapter-utils
npx vitest run packages/adapter-utils
# 14/14 pass; zero changes to packages/adapter-utils source

# TypeScript (both packages)
npx tsc --noEmit -p packages/adapters/codex-local/tsconfig.json
npx tsc --noEmit -p packages/adapter-utils/tsconfig.json
# Both clean
```

Key new test coverage
(`packages/adapters/codex-local/src/server/codex-home.test.ts`):

- Stager copies exactly the allowlisted entries and nothing else
- Staged `auth.json` dereferenced from symlink and written mode `0600`
- Staged temp dir created with mode `0700`
- ALL staged regular files mode `0600`, including `config.toml` carrying
an MCP bearer token (regression test fails against the pre-fix `0644`
path)
- Fail-closed: I/O error during staging removes the temp dir and
re-throws
- Missing-but-optional entries (`config.json`) skipped without error
- Dangling symlink entries skipped without error

Execute-path integration test (`execute.remote.test.ts`): home asset
uses staged dir; staged config preserves provider block; skills survive;
staged temp dir removed after run teardown.

## Risks

- **Allowlist narrower than denylist:** Any CODEX_HOME file not in
`{auth.json, config.toml, config.json, instructions.md, skills/}` is now
excluded. If a codex-local configuration stores additional files in
CODEX_HOME that the sandbox requires, those runs will miss that state.
This tradeoff is intentional — only known-necessary files sync.
- **Permission change:** `config.toml` previously staged at `0644`, now
staged at `0600`. Strictly more restrictive; no known behavioral impact
(the sandbox reads as the file owner). This was the security gap that
motivated the fix.
- **Fail-closed staging:** A staging error (disk full, temp dir creation
failed) now aborts the run rather than falling back to the raw
CODEX_HOME. This is the safer behavior, but it changes the failure mode
from "run with potentially wrong state" to "run aborted cleanly."

## Model Used

Claude Opus 4.8 (claude-opus-4-8), Anthropic. Reasoning/agentic tool-use
mode (extended thinking, multi-step tool calls).

## 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Harold Kim <harold@paperclip.ing>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 19:50:21 -07:00

549 lines
19 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,
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),
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,
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);
});
});