fix: isolate codex home in worktrees
This commit is contained in:
@@ -118,6 +118,14 @@ Result:
|
|||||||
|
|
||||||
Local adapters inject repo skills into runtime skill directories.
|
Local adapters inject repo skills into runtime skill directories.
|
||||||
|
|
||||||
|
Important `codex_local` nuance:
|
||||||
|
|
||||||
|
- Codex does not read skills directly from the active worktree.
|
||||||
|
- Paperclip discovers repo skills from the current checkout, then symlinks them into `$CODEX_HOME/skills` or `~/.codex/skills`.
|
||||||
|
- If an existing Paperclip skill symlink already points at another live checkout, the current implementation skips it instead of repointing it.
|
||||||
|
- This can leave Codex using stale skill content from a different worktree even after Paperclip-side skill changes land.
|
||||||
|
- That is both a correctness risk and a token-analysis risk, because runtime behavior may not reflect the instructions in the checkout being tested.
|
||||||
|
|
||||||
Current repo skill sizes:
|
Current repo skill sizes:
|
||||||
|
|
||||||
- `skills/paperclip/SKILL.md`: 17,441 bytes
|
- `skills/paperclip/SKILL.md`: 17,441 bytes
|
||||||
@@ -215,6 +223,8 @@ This is the right version of the discussion’s bootstrap idea.
|
|||||||
|
|
||||||
Static instructions and dynamic wake context have different cache behavior and should be modeled separately.
|
Static instructions and dynamic wake context have different cache behavior and should be modeled separately.
|
||||||
|
|
||||||
|
For `codex_local`, this also requires isolating the Codex skill home per worktree or teaching Paperclip to repoint its own skill symlinks when the source checkout changes. Otherwise prompt and skill improvements in the active worktree may not reach the running agent.
|
||||||
|
|
||||||
### Success criteria
|
### Success criteria
|
||||||
|
|
||||||
- fresh-session prompts can remain richer without inflating every resumed heartbeat
|
- fresh-session prompts can remain richer without inflating every resumed heartbeat
|
||||||
@@ -305,6 +315,9 @@ Even when reuse is desirable, some sessions become too expensive to keep alive i
|
|||||||
- `para-memory-files`
|
- `para-memory-files`
|
||||||
- `create-agent-adapter`
|
- `create-agent-adapter`
|
||||||
- Expose active skill set in agent config and run metadata.
|
- Expose active skill set in agent config and run metadata.
|
||||||
|
- For `codex_local`, either:
|
||||||
|
- run with a worktree-specific `CODEX_HOME`, or
|
||||||
|
- treat Paperclip-owned Codex skill symlinks as repairable when they point at a different checkout
|
||||||
|
|
||||||
### Why
|
### Why
|
||||||
|
|
||||||
@@ -363,6 +376,7 @@ Initial targets:
|
|||||||
6. Rewrite `skills/paperclip/SKILL.md` around delta-fetch behavior.
|
6. Rewrite `skills/paperclip/SKILL.md` around delta-fetch behavior.
|
||||||
7. Add session rotation with carry-forward summaries.
|
7. Add session rotation with carry-forward summaries.
|
||||||
8. Replace global skill injection with explicit allowlists.
|
8. Replace global skill injection with explicit allowlists.
|
||||||
|
9. Fix `codex_local` skill resolution so worktree-local skill changes reliably reach the runtime.
|
||||||
|
|
||||||
## Recommendation
|
## Recommendation
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ Codex uses `previous_response_id` for session continuity. The adapter serializes
|
|||||||
|
|
||||||
The adapter symlinks Paperclip skills into the global Codex skills directory (`~/.codex/skills`). Existing user skills are not overwritten.
|
The adapter symlinks Paperclip skills into the global Codex skills directory (`~/.codex/skills`). Existing user skills are not overwritten.
|
||||||
|
|
||||||
|
When Paperclip is running inside a managed worktree instance (`PAPERCLIP_IN_WORKTREE=true`), the adapter instead uses a worktree-isolated `CODEX_HOME` under the Paperclip instance so Codex skills, sessions, logs, and other runtime state do not leak across checkouts. It seeds that isolated home from the user's main Codex home for shared auth/config continuity.
|
||||||
|
|
||||||
For manual local CLI usage outside heartbeat runs (for example running as `codexcoder` directly), use:
|
For manual local CLI usage outside heartbeat runs (for example running as `codexcoder` directly), use:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
101
packages/adapters/codex-local/src/server/codex-home.ts
Normal file
101
packages/adapters/codex-local/src/server/codex-home.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||||
|
|
||||||
|
const TRUTHY_ENV_RE = /^(1|true|yes|on)$/i;
|
||||||
|
const COPIED_SHARED_FILES = ["config.json", "config.toml", "instructions.md"] as const;
|
||||||
|
const SYMLINKED_SHARED_FILES = ["auth.json"] as const;
|
||||||
|
|
||||||
|
function nonEmpty(value: string | undefined): string | null {
|
||||||
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pathExists(candidate: string): Promise<boolean> {
|
||||||
|
return fs.access(candidate).then(() => true).catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCodexHomeDir(env: NodeJS.ProcessEnv = process.env): string {
|
||||||
|
const fromEnv = nonEmpty(env.CODEX_HOME);
|
||||||
|
if (fromEnv) return path.resolve(fromEnv);
|
||||||
|
return path.join(os.homedir(), ".codex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWorktreeMode(env: NodeJS.ProcessEnv): boolean {
|
||||||
|
return TRUTHY_ENV_RE.test(env.PAPERCLIP_IN_WORKTREE ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWorktreeCodexHomeDir(env: NodeJS.ProcessEnv): string | null {
|
||||||
|
if (!isWorktreeMode(env)) return null;
|
||||||
|
const paperclipHome = nonEmpty(env.PAPERCLIP_HOME);
|
||||||
|
if (!paperclipHome) return null;
|
||||||
|
const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID);
|
||||||
|
if (instanceId) {
|
||||||
|
return path.resolve(paperclipHome, "instances", instanceId, "codex-home");
|
||||||
|
}
|
||||||
|
return path.resolve(paperclipHome, "codex-home");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureParentDir(target: string): Promise<void> {
|
||||||
|
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSymlink(target: string, source: string): Promise<void> {
|
||||||
|
const existing = await fs.lstat(target).catch(() => null);
|
||||||
|
if (!existing) {
|
||||||
|
await ensureParentDir(target);
|
||||||
|
await fs.symlink(source, target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existing.isSymbolicLink()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkedPath = await fs.readlink(target).catch(() => null);
|
||||||
|
if (!linkedPath) return;
|
||||||
|
|
||||||
|
const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath);
|
||||||
|
if (resolvedLinkedPath === source) return;
|
||||||
|
|
||||||
|
await fs.unlink(target);
|
||||||
|
await fs.symlink(source, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureCopiedFile(target: string, source: string): Promise<void> {
|
||||||
|
const existing = await fs.lstat(target).catch(() => null);
|
||||||
|
if (existing) return;
|
||||||
|
await ensureParentDir(target);
|
||||||
|
await fs.copyFile(source, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareWorktreeCodexHome(
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
onLog: AdapterExecutionContext["onLog"],
|
||||||
|
): Promise<string | null> {
|
||||||
|
const targetHome = resolveWorktreeCodexHomeDir(env);
|
||||||
|
if (!targetHome) return null;
|
||||||
|
|
||||||
|
const sourceHome = resolveCodexHomeDir(env);
|
||||||
|
if (path.resolve(sourceHome) === path.resolve(targetHome)) return targetHome;
|
||||||
|
|
||||||
|
await fs.mkdir(targetHome, { recursive: true });
|
||||||
|
|
||||||
|
for (const name of SYMLINKED_SHARED_FILES) {
|
||||||
|
const source = path.join(sourceHome, name);
|
||||||
|
if (!(await pathExists(source))) continue;
|
||||||
|
await ensureSymlink(path.join(targetHome, name), source);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name of COPIED_SHARED_FILES) {
|
||||||
|
const source = path.join(sourceHome, name);
|
||||||
|
if (!(await pathExists(source))) continue;
|
||||||
|
await ensureCopiedFile(path.join(targetHome, name), source);
|
||||||
|
}
|
||||||
|
|
||||||
|
await onLog(
|
||||||
|
"stderr",
|
||||||
|
`[paperclip] Using worktree-isolated Codex home "${targetHome}" (seeded from "${sourceHome}").\n`,
|
||||||
|
);
|
||||||
|
return targetHome;
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||||
@@ -22,6 +21,7 @@ import {
|
|||||||
runChildProcess,
|
runChildProcess,
|
||||||
} from "@paperclipai/adapter-utils/server-utils";
|
} from "@paperclipai/adapter-utils/server-utils";
|
||||||
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
|
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
|
||||||
|
import { prepareWorktreeCodexHome, resolveCodexHomeDir } from "./codex-home.js";
|
||||||
|
|
||||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const CODEX_ROLLOUT_NOISE_RE =
|
const CODEX_ROLLOUT_NOISE_RE =
|
||||||
@@ -61,10 +61,36 @@ function resolveCodexBillingType(env: Record<string, string>): "api" | "subscrip
|
|||||||
return hasNonEmptyEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription";
|
return hasNonEmptyEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription";
|
||||||
}
|
}
|
||||||
|
|
||||||
function codexHomeDir(): string {
|
async function pathExists(candidate: string): Promise<boolean> {
|
||||||
const fromEnv = process.env.CODEX_HOME;
|
return fs.access(candidate).then(() => true).catch(() => false);
|
||||||
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim();
|
}
|
||||||
return path.join(os.homedir(), ".codex");
|
|
||||||
|
async function isLikelyPaperclipRepoRoot(candidate: string): Promise<boolean> {
|
||||||
|
const [hasWorkspace, hasPackageJson, hasServerDir, hasAdapterUtilsDir] = await Promise.all([
|
||||||
|
pathExists(path.join(candidate, "pnpm-workspace.yaml")),
|
||||||
|
pathExists(path.join(candidate, "package.json")),
|
||||||
|
pathExists(path.join(candidate, "server")),
|
||||||
|
pathExists(path.join(candidate, "packages", "adapter-utils")),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return hasWorkspace && hasPackageJson && hasServerDir && hasAdapterUtilsDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isLikelyPaperclipRuntimeSkillSource(candidate: string, skillName: string): Promise<boolean> {
|
||||||
|
if (path.basename(candidate) !== skillName) return false;
|
||||||
|
const skillsRoot = path.dirname(candidate);
|
||||||
|
if (path.basename(skillsRoot) !== "skills") return false;
|
||||||
|
if (!(await pathExists(path.join(candidate, "SKILL.md")))) return false;
|
||||||
|
|
||||||
|
let cursor = path.dirname(skillsRoot);
|
||||||
|
for (let depth = 0; depth < 6; depth += 1) {
|
||||||
|
if (await isLikelyPaperclipRepoRoot(cursor)) return true;
|
||||||
|
const parent = path.dirname(cursor);
|
||||||
|
if (parent === cursor) break;
|
||||||
|
cursor = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
type EnsureCodexSkillsInjectedOptions = {
|
type EnsureCodexSkillsInjectedOptions = {
|
||||||
@@ -80,7 +106,7 @@ export async function ensureCodexSkillsInjected(
|
|||||||
const skillsEntries = options.skillsEntries ?? await listPaperclipSkillEntries(__moduleDir);
|
const skillsEntries = options.skillsEntries ?? await listPaperclipSkillEntries(__moduleDir);
|
||||||
if (skillsEntries.length === 0) return;
|
if (skillsEntries.length === 0) return;
|
||||||
|
|
||||||
const skillsHome = options.skillsHome ?? path.join(codexHomeDir(), "skills");
|
const skillsHome = options.skillsHome ?? path.join(resolveCodexHomeDir(process.env), "skills");
|
||||||
await fs.mkdir(skillsHome, { recursive: true });
|
await fs.mkdir(skillsHome, { recursive: true });
|
||||||
const removedSkills = await removeMaintainerOnlySkillSymlinks(
|
const removedSkills = await removeMaintainerOnlySkillSymlinks(
|
||||||
skillsHome,
|
skillsHome,
|
||||||
@@ -97,6 +123,31 @@ export async function ensureCodexSkillsInjected(
|
|||||||
const target = path.join(skillsHome, entry.name);
|
const target = path.join(skillsHome, entry.name);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const existing = await fs.lstat(target).catch(() => null);
|
||||||
|
if (existing?.isSymbolicLink()) {
|
||||||
|
const linkedPath = await fs.readlink(target).catch(() => null);
|
||||||
|
const resolvedLinkedPath = linkedPath
|
||||||
|
? path.resolve(path.dirname(target), linkedPath)
|
||||||
|
: null;
|
||||||
|
if (
|
||||||
|
resolvedLinkedPath &&
|
||||||
|
resolvedLinkedPath !== entry.source &&
|
||||||
|
(await isLikelyPaperclipRuntimeSkillSource(resolvedLinkedPath, entry.name))
|
||||||
|
) {
|
||||||
|
await fs.unlink(target);
|
||||||
|
if (linkSkill) {
|
||||||
|
await linkSkill(entry.source, target);
|
||||||
|
} else {
|
||||||
|
await fs.symlink(entry.source, target);
|
||||||
|
}
|
||||||
|
await onLog(
|
||||||
|
"stderr",
|
||||||
|
`[paperclip] Repaired Codex skill "${entry.name}" into ${skillsHome}\n`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await ensurePaperclipSkillSymlink(entry.source, target, linkSkill);
|
const result = await ensurePaperclipSkillSymlink(entry.source, target, linkSkill);
|
||||||
if (result === "skipped") continue;
|
if (result === "skipped") continue;
|
||||||
|
|
||||||
@@ -161,12 +212,25 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||||
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
||||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
|
||||||
await ensureCodexSkillsInjected(onLog);
|
|
||||||
const envConfig = parseObject(config.env);
|
const envConfig = parseObject(config.env);
|
||||||
|
const configuredCodexHome =
|
||||||
|
typeof envConfig.CODEX_HOME === "string" && envConfig.CODEX_HOME.trim().length > 0
|
||||||
|
? path.resolve(envConfig.CODEX_HOME.trim())
|
||||||
|
: null;
|
||||||
|
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||||
|
const preparedWorktreeCodexHome =
|
||||||
|
configuredCodexHome ? null : await prepareWorktreeCodexHome(process.env, onLog);
|
||||||
|
const effectiveCodexHome = configuredCodexHome ?? preparedWorktreeCodexHome;
|
||||||
|
await ensureCodexSkillsInjected(
|
||||||
|
onLog,
|
||||||
|
effectiveCodexHome ? { skillsHome: path.join(effectiveCodexHome, "skills") } : {},
|
||||||
|
);
|
||||||
const hasExplicitApiKey =
|
const hasExplicitApiKey =
|
||||||
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
||||||
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
||||||
|
if (effectiveCodexHome) {
|
||||||
|
env.CODEX_HOME = effectiveCodexHome;
|
||||||
|
}
|
||||||
env.PAPERCLIP_RUN_ID = runId;
|
env.PAPERCLIP_RUN_ID = runId;
|
||||||
const wakeTaskId =
|
const wakeTaskId =
|
||||||
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
|
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ The main behavior changes are:
|
|||||||
- The agent config UI now explains the difference between bootstrap prompts and heartbeat prompts and warns about prompt churn.
|
- The agent config UI now explains the difference between bootstrap prompts and heartbeat prompts and warns about prompt churn.
|
||||||
- Runtime skill defaults now include `paperclip`, `para-memory-files`, and `paperclip-create-agent`. `create-agent-adapter` was moved to `.agents/skills/create-agent-adapter`.
|
- Runtime skill defaults now include `paperclip`, `para-memory-files`, and `paperclip-create-agent`. `create-agent-adapter` was moved to `.agents/skills/create-agent-adapter`.
|
||||||
|
|
||||||
|
Important follow-up finding from real-run review:
|
||||||
|
|
||||||
|
- `codex_local` currently injects Paperclip skills into the shared Codex skills home (`$CODEX_HOME/skills` or `~/.codex/skills`) rather than mounting a worktree-local skill directory.
|
||||||
|
- If a Paperclip-owned skill symlink already points at another live checkout, the adapter currently skips it instead of repointing it.
|
||||||
|
- In practice, this means a worktree can contain newer `skills/paperclip/SKILL.md` guidance while Codex still follows an older checkout's skill content.
|
||||||
|
- That likely explains why PAP-507 still showed full issue/comment reload behavior even though the incremental context work was already implemented in this branch.
|
||||||
|
- This should be treated as a separate follow-up item for `codex_local` skill isolation or symlink repair.
|
||||||
|
|
||||||
Files with the most important implementation work:
|
Files with the most important implementation work:
|
||||||
|
|
||||||
- `server/src/services/heartbeat.ts`
|
- `server/src/services/heartbeat.ts`
|
||||||
|
|||||||
208
server/src/__tests__/codex-local-execute.test.ts
Normal file
208
server/src/__tests__/codex-local-execute.test.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { execute } from "@paperclipai/adapter-codex-local/server";
|
||||||
|
|
||||||
|
async function writeFakeCodexCommand(commandPath: string): Promise<void> {
|
||||||
|
const script = `#!/usr/bin/env node
|
||||||
|
const fs = require("node:fs");
|
||||||
|
|
||||||
|
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
|
||||||
|
const payload = {
|
||||||
|
argv: process.argv.slice(2),
|
||||||
|
prompt: fs.readFileSync(0, "utf8"),
|
||||||
|
codexHome: process.env.CODEX_HOME || null,
|
||||||
|
paperclipEnvKeys: Object.keys(process.env)
|
||||||
|
.filter((key) => key.startsWith("PAPERCLIP_"))
|
||||||
|
.sort(),
|
||||||
|
};
|
||||||
|
if (capturePath) {
|
||||||
|
fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8");
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify({ type: "thread.started", thread_id: "codex-session-1" }));
|
||||||
|
console.log(JSON.stringify({ type: "item.completed", item: { type: "agent_message", text: "hello" } }));
|
||||||
|
console.log(JSON.stringify({ type: "turn.completed", usage: { input_tokens: 1, cached_input_tokens: 0, output_tokens: 1 } }));
|
||||||
|
`;
|
||||||
|
await fs.writeFile(commandPath, script, "utf8");
|
||||||
|
await fs.chmod(commandPath, 0o755);
|
||||||
|
}
|
||||||
|
|
||||||
|
type CapturePayload = {
|
||||||
|
argv: string[];
|
||||||
|
prompt: string;
|
||||||
|
codexHome: string | null;
|
||||||
|
paperclipEnvKeys: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("codex execute", () => {
|
||||||
|
it("uses a worktree-isolated CODEX_HOME while preserving shared auth and config", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-"));
|
||||||
|
const workspace = path.join(root, "workspace");
|
||||||
|
const commandPath = path.join(root, "codex");
|
||||||
|
const capturePath = path.join(root, "capture.json");
|
||||||
|
const sharedCodexHome = path.join(root, "shared-codex-home");
|
||||||
|
const paperclipHome = path.join(root, "paperclip-home");
|
||||||
|
const isolatedCodexHome = path.join(paperclipHome, "instances", "worktree-1", "codex-home");
|
||||||
|
await fs.mkdir(workspace, { recursive: true });
|
||||||
|
await fs.mkdir(sharedCodexHome, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"token":"shared"}\n', "utf8");
|
||||||
|
await fs.writeFile(path.join(sharedCodexHome, "config.toml"), 'model = "codex-mini-latest"\n', "utf8");
|
||||||
|
await writeFakeCodexCommand(commandPath);
|
||||||
|
|
||||||
|
const previousHome = process.env.HOME;
|
||||||
|
const previousPaperclipHome = process.env.PAPERCLIP_HOME;
|
||||||
|
const previousPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
|
||||||
|
const previousPaperclipInWorktree = process.env.PAPERCLIP_IN_WORKTREE;
|
||||||
|
const previousCodexHome = process.env.CODEX_HOME;
|
||||||
|
process.env.HOME = root;
|
||||||
|
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||||
|
process.env.PAPERCLIP_INSTANCE_ID = "worktree-1";
|
||||||
|
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
||||||
|
process.env.CODEX_HOME = sharedCodexHome;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await execute({
|
||||||
|
runId: "run-1",
|
||||||
|
agent: {
|
||||||
|
id: "agent-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
name: "Codex Coder",
|
||||||
|
adapterType: "codex_local",
|
||||||
|
adapterConfig: {},
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
sessionId: null,
|
||||||
|
sessionParams: null,
|
||||||
|
sessionDisplayId: null,
|
||||||
|
taskKey: null,
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
command: commandPath,
|
||||||
|
cwd: workspace,
|
||||||
|
env: {
|
||||||
|
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||||
|
},
|
||||||
|
promptTemplate: "Follow the paperclip heartbeat.",
|
||||||
|
},
|
||||||
|
context: {},
|
||||||
|
authToken: "run-jwt-token",
|
||||||
|
onLog: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(result.errorMessage).toBeNull();
|
||||||
|
|
||||||
|
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||||
|
expect(capture.codexHome).toBe(isolatedCodexHome);
|
||||||
|
expect(capture.argv).toEqual(expect.arrayContaining(["exec", "--json", "-"]));
|
||||||
|
expect(capture.prompt).toContain("Follow the paperclip heartbeat.");
|
||||||
|
expect(capture.paperclipEnvKeys).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"PAPERCLIP_AGENT_ID",
|
||||||
|
"PAPERCLIP_API_KEY",
|
||||||
|
"PAPERCLIP_API_URL",
|
||||||
|
"PAPERCLIP_COMPANY_ID",
|
||||||
|
"PAPERCLIP_RUN_ID",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const isolatedAuth = path.join(isolatedCodexHome, "auth.json");
|
||||||
|
const isolatedConfig = path.join(isolatedCodexHome, "config.toml");
|
||||||
|
const isolatedSkill = path.join(isolatedCodexHome, "skills", "paperclip");
|
||||||
|
|
||||||
|
expect((await fs.lstat(isolatedAuth)).isSymbolicLink()).toBe(true);
|
||||||
|
expect(await fs.realpath(isolatedAuth)).toBe(await fs.realpath(path.join(sharedCodexHome, "auth.json")));
|
||||||
|
expect((await fs.lstat(isolatedConfig)).isFile()).toBe(true);
|
||||||
|
expect(await fs.readFile(isolatedConfig, "utf8")).toBe('model = "codex-mini-latest"\n');
|
||||||
|
expect((await fs.lstat(isolatedSkill)).isSymbolicLink()).toBe(true);
|
||||||
|
} finally {
|
||||||
|
if (previousHome === undefined) delete process.env.HOME;
|
||||||
|
else process.env.HOME = previousHome;
|
||||||
|
if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
|
||||||
|
else process.env.PAPERCLIP_HOME = previousPaperclipHome;
|
||||||
|
if (previousPaperclipInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||||
|
else process.env.PAPERCLIP_INSTANCE_ID = previousPaperclipInstanceId;
|
||||||
|
if (previousPaperclipInWorktree === undefined) delete process.env.PAPERCLIP_IN_WORKTREE;
|
||||||
|
else process.env.PAPERCLIP_IN_WORKTREE = previousPaperclipInWorktree;
|
||||||
|
if (previousCodexHome === undefined) delete process.env.CODEX_HOME;
|
||||||
|
else process.env.CODEX_HOME = previousCodexHome;
|
||||||
|
await fs.rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects an explicit CODEX_HOME config override even in worktree mode", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-explicit-"));
|
||||||
|
const workspace = path.join(root, "workspace");
|
||||||
|
const commandPath = path.join(root, "codex");
|
||||||
|
const capturePath = path.join(root, "capture.json");
|
||||||
|
const sharedCodexHome = path.join(root, "shared-codex-home");
|
||||||
|
const explicitCodexHome = path.join(root, "explicit-codex-home");
|
||||||
|
const paperclipHome = path.join(root, "paperclip-home");
|
||||||
|
await fs.mkdir(workspace, { recursive: true });
|
||||||
|
await fs.mkdir(sharedCodexHome, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"token":"shared"}\n', "utf8");
|
||||||
|
await writeFakeCodexCommand(commandPath);
|
||||||
|
|
||||||
|
const previousHome = process.env.HOME;
|
||||||
|
const previousPaperclipHome = process.env.PAPERCLIP_HOME;
|
||||||
|
const previousPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
|
||||||
|
const previousPaperclipInWorktree = process.env.PAPERCLIP_IN_WORKTREE;
|
||||||
|
const previousCodexHome = process.env.CODEX_HOME;
|
||||||
|
process.env.HOME = root;
|
||||||
|
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||||
|
process.env.PAPERCLIP_INSTANCE_ID = "worktree-1";
|
||||||
|
process.env.PAPERCLIP_IN_WORKTREE = "true";
|
||||||
|
process.env.CODEX_HOME = sharedCodexHome;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await execute({
|
||||||
|
runId: "run-2",
|
||||||
|
agent: {
|
||||||
|
id: "agent-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
name: "Codex Coder",
|
||||||
|
adapterType: "codex_local",
|
||||||
|
adapterConfig: {},
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
sessionId: null,
|
||||||
|
sessionParams: null,
|
||||||
|
sessionDisplayId: null,
|
||||||
|
taskKey: null,
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
command: commandPath,
|
||||||
|
cwd: workspace,
|
||||||
|
env: {
|
||||||
|
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||||
|
CODEX_HOME: explicitCodexHome,
|
||||||
|
},
|
||||||
|
promptTemplate: "Follow the paperclip heartbeat.",
|
||||||
|
},
|
||||||
|
context: {},
|
||||||
|
authToken: "run-jwt-token",
|
||||||
|
onLog: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(result.errorMessage).toBeNull();
|
||||||
|
|
||||||
|
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||||
|
expect(capture.codexHome).toBe(explicitCodexHome);
|
||||||
|
await expect(fs.lstat(path.join(paperclipHome, "instances", "worktree-1", "codex-home"))).rejects.toThrow();
|
||||||
|
} finally {
|
||||||
|
if (previousHome === undefined) delete process.env.HOME;
|
||||||
|
else process.env.HOME = previousHome;
|
||||||
|
if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
|
||||||
|
else process.env.PAPERCLIP_HOME = previousPaperclipHome;
|
||||||
|
if (previousPaperclipInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||||
|
else process.env.PAPERCLIP_INSTANCE_ID = previousPaperclipInstanceId;
|
||||||
|
if (previousPaperclipInWorktree === undefined) delete process.env.PAPERCLIP_IN_WORKTREE;
|
||||||
|
else process.env.PAPERCLIP_IN_WORKTREE = previousPaperclipInWorktree;
|
||||||
|
if (previousCodexHome === undefined) delete process.env.CODEX_HOME;
|
||||||
|
else process.env.CODEX_HOME = previousCodexHome;
|
||||||
|
await fs.rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
91
server/src/__tests__/codex-local-skill-injection.test.ts
Normal file
91
server/src/__tests__/codex-local-skill-injection.test.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { ensureCodexSkillsInjected } from "@paperclipai/adapter-codex-local/server";
|
||||||
|
|
||||||
|
async function makeTempDir(prefix: string): Promise<string> {
|
||||||
|
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPaperclipRepoSkill(root: string, skillName: string) {
|
||||||
|
await fs.mkdir(path.join(root, "server"), { recursive: true });
|
||||||
|
await fs.mkdir(path.join(root, "packages", "adapter-utils"), { recursive: true });
|
||||||
|
await fs.mkdir(path.join(root, "skills", skillName), { recursive: true });
|
||||||
|
await fs.writeFile(path.join(root, "pnpm-workspace.yaml"), "packages:\n - packages/*\n", "utf8");
|
||||||
|
await fs.writeFile(path.join(root, "package.json"), '{"name":"paperclip"}\n', "utf8");
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(root, "skills", skillName, "SKILL.md"),
|
||||||
|
`---\nname: ${skillName}\n---\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCustomSkill(root: string, skillName: string) {
|
||||||
|
await fs.mkdir(path.join(root, "custom", skillName), { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(root, "custom", skillName, "SKILL.md"),
|
||||||
|
`---\nname: ${skillName}\n---\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("codex local adapter skill injection", () => {
|
||||||
|
const cleanupDirs = new Set<string>();
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||||
|
cleanupDirs.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("repairs a Codex Paperclip skill symlink that still points at another live checkout", async () => {
|
||||||
|
const currentRepo = await makeTempDir("paperclip-codex-current-");
|
||||||
|
const oldRepo = await makeTempDir("paperclip-codex-old-");
|
||||||
|
const skillsHome = await makeTempDir("paperclip-codex-home-");
|
||||||
|
cleanupDirs.add(currentRepo);
|
||||||
|
cleanupDirs.add(oldRepo);
|
||||||
|
cleanupDirs.add(skillsHome);
|
||||||
|
|
||||||
|
await createPaperclipRepoSkill(currentRepo, "paperclip");
|
||||||
|
await createPaperclipRepoSkill(oldRepo, "paperclip");
|
||||||
|
await fs.symlink(path.join(oldRepo, "skills", "paperclip"), path.join(skillsHome, "paperclip"));
|
||||||
|
|
||||||
|
const logs: string[] = [];
|
||||||
|
await ensureCodexSkillsInjected(
|
||||||
|
async (_stream, chunk) => {
|
||||||
|
logs.push(chunk);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
skillsHome,
|
||||||
|
skillsEntries: [{ name: "paperclip", source: path.join(currentRepo, "skills", "paperclip") }],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await fs.realpath(path.join(skillsHome, "paperclip"))).toBe(
|
||||||
|
await fs.realpath(path.join(currentRepo, "skills", "paperclip")),
|
||||||
|
);
|
||||||
|
expect(logs.some((line) => line.includes('Repaired Codex skill "paperclip"'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a custom Codex skill symlink outside Paperclip repo checkouts", async () => {
|
||||||
|
const currentRepo = await makeTempDir("paperclip-codex-current-");
|
||||||
|
const customRoot = await makeTempDir("paperclip-codex-custom-");
|
||||||
|
const skillsHome = await makeTempDir("paperclip-codex-home-");
|
||||||
|
cleanupDirs.add(currentRepo);
|
||||||
|
cleanupDirs.add(customRoot);
|
||||||
|
cleanupDirs.add(skillsHome);
|
||||||
|
|
||||||
|
await createPaperclipRepoSkill(currentRepo, "paperclip");
|
||||||
|
await createCustomSkill(customRoot, "paperclip");
|
||||||
|
await fs.symlink(path.join(customRoot, "custom", "paperclip"), path.join(skillsHome, "paperclip"));
|
||||||
|
|
||||||
|
await ensureCodexSkillsInjected(async () => {}, {
|
||||||
|
skillsHome,
|
||||||
|
skillsEntries: [{ name: "paperclip", source: path.join(currentRepo, "skills", "paperclip") }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await fs.realpath(path.join(skillsHome, "paperclip"))).toBe(
|
||||||
|
await fs.realpath(path.join(customRoot, "custom", "paperclip")),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user