mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
## Thinking Path > - Paperclip lets operators assign tools and credentials to agents. > - Legacy Codex starts provider processes and builds a separate shell environment policy. > - Both ACP and CLI removed every variable whose name contained KEY, SECRET, or TOKEN. > - This also removed service credentials that the operator explicitly assigned, such as SLACK_BOT_TOKEN. > - This pull request uses the resolved configuration to admit assigned service secrets by name. > - Every Codex launch keeps a bounded shell allowlist, including launches without an agent identity. > - Assigned scripts can use their credentials without adding unassigned host secrets to shell tools. ## Linked Issues or Issue Description Related: #16255. That PR fixes project-level exclude patterns. This PR fixes the removal of explicitly assigned service secrets and the fallback to an inherited policy when no agent identity is present. **What happened?** A Codex task could not run a Slack posting script. SLACK_BOT_TOKEN was present in its resolved launch environment, but Paperclip removed its name from shell_environment_policy.include_only. A live removal check also showed that an identity-free launch without an explicit policy could inherit an unassigned host token. **Expected behavior** An explicitly assigned service credential reaches shell commands. Removing that binding removes its shell access on the next launch. Unassigned host credentials stay outside the allowlist. Codex login credentials and observability headers keep their separate provider paths. **Steps to reproduce** 1. Configure a Legacy Codex agent with a synthetic SLACK_BOT_TOKEN value. 2. Run a shell command that checks whether the variable exists without printing its value. 3. Before this fix, the variable is absent. Repeat through ACP and CLI. 4. Remove the binding and run a second turn. Check that neither the removed token nor an unassigned host token reaches the command. **Paperclip version or commit** Reproduced in the executor regressions before the fix on master at4dfcb4c9b8. The final change is rebased on25f2d05282. **Deployment mode** Source checkout. Local macOS arm64 provider acceptance with Codex CLI 0.160.0 and Codex ACP 1.6.2, gpt-5.6-sol, and an existing ChatGPT login. ## What Changed - Added one shared Codex shell-name policy for Legacy ACP and CLI execution. - Admit explicitly resolved service-secret bindings. Keep Codex login keys, observability credentials, and private runtime transport tokens outside that opt-in. - Reject glob-shaped environment names so one configured name cannot match other credentials. - Always emit the bounded policy. Do not fall back to host inheritance after a binding is removed. - Updated executor regressions, the identity contract, and the paired verification maps. ## Verification - Red before the fix: executor regressions failed because SLACK_BOT_TOKEN was absent in ACP and CLI, with and without agent identities. - Green: 267 tests passed across the two executor suites and the shared shell-policy suite. Coverage includes host/provider isolation, unresolved and removed bindings, glob names, identity delivery, and names-only metadata. - The complete Codex adapter source suite passed after the CI follow-up: 571 tests passed, 1 skipped across 31 files. Its typecheck also passed. - The first CI run caught four stale expectations in ACP config and SSH launch tests. Those now assert the bounded policy and the existing command/resume behavior. No production code changed in that follow-up. - Live CLI and ACP acceptance: run a Python presence check through each real adapter, remove the synthetic Slack binding, and run again. The assigned token was present only on the first turn. The unassigned host token and OpenAI API key were absent on both turns. CLI retained its session; ACP correctly invalidated its session after the configuration changed. Workspace proof files persisted. - An earlier live CLI check failed after binding removal because the no-policy fallback exposed the synthetic host token. The final change removes that fallback. Both live checks then passed. - Full pnpm -r typecheck and pnpm build passed. The changed packages were compiled again after the safety correction. - node scripts/check-maps.mjs and git diff --check passed. - The local pnpm test:run attempt reported failures in native warm-session tests and was interrupted. That full-suite result is not a pass; PR CI must establish the final repository-wide gate. One named native failure passed alone, and the complete native executor file then passed separately (774 tests). The original broad-run failure cause remains unconfirmed. - After rebase, one Gemini skill-root case timed out at five seconds while the broad run was active. The case passed alone, and the full focused set then passed again at the PR head (267 tests). The earlier timeout remains part of the verification record. - An initial broad adapter run failed because sandbox restrictions denied loopback listeners. Running the same suites with loopback access passed. ## Risks - Explicitly assigned service secrets now reach shell commands. This is the intended behavior change: assigning a credential authorizes the run to use it, and a model-directed command can read it. This is not a broker that hides assigned credentials from the model. Operators must assign only credentials and service permissions authorized for the run. Configuration and secret-access authorization remain with their existing owners. - Superagent initially flagged this intended assigned-credential exposure as a P2. The inline reply records the trust-model rationale and the documentation states it explicitly. The security scan on the final documented revision passed without any check override. - The launch policy contains names, not values. Log redaction remains in place. Provider login credentials and observability headers remain excluded from the service-secret opt-in. - Identity-free Codex runs now also receive a bounded policy. This removes reliance on the operator's inherited shell policy. - Existing explicit project exclude patterns retain their behavior; #16255 addresses that separate case. - The change has not been deployed to a shared instance. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository tools, and code execution. The exact served model ID and context-window size are not exposed in this session. ## 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 - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run the focused tests locally and they pass; the full-suite limitation is recorded above - [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>
699 lines
25 KiB
TypeScript
699 lines
25 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 { createPromptContextFixture } from "@paperclipai/adapter-utils/test-fixtures/prompt-context";
|
|
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: {
|
|
engine: "cli",
|
|
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: {
|
|
engine: "cli",
|
|
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: {
|
|
engine: "cli",
|
|
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].slice(call[2].indexOf("exec"))).toEqual([
|
|
"exec",
|
|
"--json",
|
|
"--dangerously-bypass-approvals-and-sandbox",
|
|
"-",
|
|
]);
|
|
});
|
|
|
|
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: {
|
|
engine: "cli",
|
|
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].slice(call[2].indexOf("exec"))).toEqual([
|
|
"exec",
|
|
"--json",
|
|
"--dangerously-bypass-approvals-and-sandbox",
|
|
"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: {
|
|
engine: "cli",
|
|
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].slice(call[2].indexOf("exec"))).toEqual([
|
|
"exec",
|
|
"--json",
|
|
"--dangerously-bypass-approvals-and-sandbox",
|
|
"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: { engine: "cli", 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");
|
|
});
|
|
|
|
it("reselects the full assignment and bootstrap guidance after a failed resume", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-cli-fallback-context-"));
|
|
cleanupDirs.push(rootDir);
|
|
const workspaceDir = path.join(rootDir, "workspace");
|
|
await mkdir(workspaceDir, { recursive: true });
|
|
|
|
runChildProcess
|
|
.mockResolvedValueOnce({
|
|
exitCode: 1,
|
|
signal: null,
|
|
timedOut: false,
|
|
stdout: "",
|
|
stderr: "unknown thread id session-old",
|
|
pid: 123,
|
|
startedAt: new Date().toISOString(),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
exitCode: 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
stdout: [
|
|
JSON.stringify({ type: "thread.started", thread_id: "session-fresh" }),
|
|
JSON.stringify({ type: "turn.completed", usage: { input_tokens: 1, output_tokens: 1 } }),
|
|
].join("\\n"),
|
|
stderr: "",
|
|
pid: 124,
|
|
startedAt: new Date().toISOString(),
|
|
});
|
|
|
|
await execute({
|
|
runId: "run-codex-cli-fallback-context",
|
|
agent: {
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
name: "Codex Coder",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
},
|
|
runtime: {
|
|
sessionId: "session-old",
|
|
sessionParams: { sessionId: "session-old", cwd: workspaceDir },
|
|
sessionDisplayId: "session-old",
|
|
taskKey: null,
|
|
},
|
|
config: {
|
|
engine: "cli",
|
|
command: "codex",
|
|
env: { OPENAI_API_KEY: "fixture-openai-key" },
|
|
},
|
|
context: {
|
|
...createPromptContextFixture(),
|
|
paperclipWorkspace: { cwd: workspaceDir, source: "project_primary" },
|
|
},
|
|
onLog: async () => {},
|
|
});
|
|
|
|
expect(runChildProcess).toHaveBeenCalledTimes(2);
|
|
const first = (runChildProcess.mock.calls[0] as unknown as [string, string, string[], { stdin?: string }])[3]?.stdin ?? "";
|
|
const retry = (runChildProcess.mock.calls[1] as unknown as [string, string, string[], { stdin?: string }])[3]?.stdin ?? "";
|
|
expect(first).toContain("## Compact assignment");
|
|
expect(first).not.toContain("Explain the next step before starting work.");
|
|
expect(retry).toContain("## Owned assignment");
|
|
expect(retry).toContain("Explain the next step before starting work.");
|
|
expect(retry).not.toContain("## Compact assignment");
|
|
expect(retry.indexOf("comment-first")).toBeLessThan(retry.indexOf("comment-second"));
|
|
expect(retry.split("Append the same ledger entry.")).toHaveLength(3);
|
|
});
|
|
|
|
});
|