Fix Codex skill injection to use ~/.codex/skills/ instead of cwd

The Codex adapter was the only one injecting skills into
<cwd>/.agents/skills/, polluting the project's git repo. All other
adapters (Gemini, Cursor, etc.) use a home-based directory. This
changes the Codex adapter to inject into ~/.codex/skills/ (resolved
via resolveSharedCodexHomeDir) to match the established pattern.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin Foley 2026-03-25 15:55:51 -07:00
parent db3883d2e7
commit eeec52ad74
5 changed files with 12 additions and 12 deletions

View file

@ -42,7 +42,7 @@ Notes:
- Prompts are piped via stdin (Codex receives "-" prompt argument). - Prompts are piped via stdin (Codex receives "-" prompt argument).
- If instructionsFilePath is configured, Paperclip prepends that file's contents to the stdin prompt on every run. - If instructionsFilePath is configured, Paperclip prepends that file's contents to the stdin prompt on every run.
- Codex exec automatically applies repo-scoped AGENTS.md instructions from the active workspace. Paperclip cannot suppress that discovery in exec mode, so repo AGENTS.md files may still apply even when you only configured an explicit instructionsFilePath. - Codex exec automatically applies repo-scoped AGENTS.md instructions from the active workspace. Paperclip cannot suppress that discovery in exec mode, so repo AGENTS.md files may still apply even when you only configured an explicit instructionsFilePath.
- Paperclip injects desired local skills into the active workspace's ".agents/skills" directory at execution time so Codex can discover "$paperclip" and related skills without coupling them to the user's login home. - Paperclip injects desired local skills into ~/.codex/skills/ (the Codex home directory) at execution time so Codex can discover "$paperclip" and related skills without polluting the project working directory.
- Unless explicitly overridden in adapter config, Paperclip runs Codex with a per-company managed CODEX_HOME under the active Paperclip instance and seeds auth/config from the shared Codex home (the CODEX_HOME env var, when set, or ~/.codex). - Unless explicitly overridden in adapter config, Paperclip runs Codex with a per-company managed CODEX_HOME under the active Paperclip instance and seeds auth/config from the shared Codex home (the CODEX_HOME env var, when set, or ~/.codex).
- Some model/tool combinations reject certain effort levels (for example minimal with web search enabled). - Some model/tool combinations reject certain effort levels (for example minimal with web search enabled).
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling. - When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.

View file

@ -21,7 +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 { pathExists, prepareManagedCodexHome, resolveManagedCodexHomeDir } from "./codex-home.js"; import { pathExists, prepareManagedCodexHome, resolveManagedCodexHomeDir, resolveSharedCodexHomeDir } from "./codex-home.js";
import { resolveCodexDesiredSkillNames } from "./skills.js"; import { resolveCodexDesiredSkillNames } from "./skills.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
@ -135,8 +135,8 @@ async function pruneBrokenUnavailablePaperclipSkillSymlinks(
} }
} }
function resolveCodexWorkspaceSkillsDir(cwd: string): string { function resolveCodexSkillsHome(): string {
return path.join(cwd, ".agents", "skills"); return path.join(resolveSharedCodexHomeDir(), "skills");
} }
type EnsureCodexSkillsInjectedOptions = { type EnsureCodexSkillsInjectedOptions = {
@ -157,7 +157,7 @@ export async function ensureCodexSkillsInjected(
const skillsEntries = allSkillsEntries.filter((entry) => desiredSet.has(entry.key)); const skillsEntries = allSkillsEntries.filter((entry) => desiredSet.has(entry.key));
if (skillsEntries.length === 0) return; if (skillsEntries.length === 0) return;
const skillsHome = options.skillsHome ?? resolveCodexWorkspaceSkillsDir(process.cwd()); const skillsHome = options.skillsHome ?? resolveCodexSkillsHome();
await fs.mkdir(skillsHome, { recursive: true }); await fs.mkdir(skillsHome, { recursive: true });
const linkSkill = options.linkSkill; const linkSkill = options.linkSkill;
for (const entry of skillsEntries) { for (const entry of skillsEntries) {
@ -273,11 +273,11 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const defaultCodexHome = resolveManagedCodexHomeDir(process.env, agent.companyId); const defaultCodexHome = resolveManagedCodexHomeDir(process.env, agent.companyId);
const effectiveCodexHome = configuredCodexHome ?? preparedManagedCodexHome ?? defaultCodexHome; const effectiveCodexHome = configuredCodexHome ?? preparedManagedCodexHome ?? defaultCodexHome;
await fs.mkdir(effectiveCodexHome, { recursive: true }); await fs.mkdir(effectiveCodexHome, { recursive: true });
const codexWorkspaceSkillsDir = resolveCodexWorkspaceSkillsDir(cwd); const codexSkillsDir = resolveCodexSkillsHome();
await ensureCodexSkillsInjected( await ensureCodexSkillsInjected(
onLog, onLog,
{ {
skillsHome: codexWorkspaceSkillsDir, skillsHome: codexSkillsDir,
skillsEntries: codexSkillEntries, skillsEntries: codexSkillEntries,
desiredSkillNames, desiredSkillNames,
}, },

View file

@ -31,7 +31,7 @@ async function buildCodexSkillSnapshot(
sourcePath: entry.source, sourcePath: entry.source,
targetPath: null, targetPath: null,
detail: desiredSet.has(entry.key) detail: desiredSet.has(entry.key)
? "Will be linked into the workspace .agents/skills directory on the next run." ? "Will be linked into ~/.codex/skills/ on the next run."
: null, : null,
required: Boolean(entry.required), required: Boolean(entry.required),
requiredReason: entry.requiredReason ?? null, requiredReason: entry.requiredReason ?? null,

View file

@ -210,7 +210,7 @@ describe("codex execute", () => {
"company-1", "company-1",
"codex-home", "codex-home",
); );
const workspaceSkill = path.join(workspace, ".agents", "skills", "paperclip"); const homeSkill = path.join(sharedCodexHome, "skills", "paperclip");
await fs.mkdir(workspace, { recursive: true }); await fs.mkdir(workspace, { recursive: true });
await fs.mkdir(sharedCodexHome, { 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, "auth.json"), '{"token":"shared"}\n', "utf8");
@ -284,7 +284,7 @@ describe("codex execute", () => {
expect(await fs.realpath(isolatedAuth)).toBe(await fs.realpath(path.join(sharedCodexHome, "auth.json"))); 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.lstat(isolatedConfig)).isFile()).toBe(true);
expect(await fs.readFile(isolatedConfig, "utf8")).toBe('model = "codex-mini-latest"\n'); expect(await fs.readFile(isolatedConfig, "utf8")).toBe('model = "codex-mini-latest"\n');
expect((await fs.lstat(workspaceSkill)).isSymbolicLink()).toBe(true); expect((await fs.lstat(homeSkill)).isSymbolicLink()).toBe(true);
expect(logs).toContainEqual( expect(logs).toContainEqual(
expect.objectContaining({ expect.objectContaining({
stream: "stdout", stream: "stdout",
@ -371,7 +371,7 @@ describe("codex execute", () => {
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload; const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
expect(capture.codexHome).toBe(explicitCodexHome); expect(capture.codexHome).toBe(explicitCodexHome);
expect((await fs.lstat(path.join(workspace, ".agents", "skills", "paperclip"))).isSymbolicLink()).toBe(true); expect((await fs.lstat(path.join(sharedCodexHome, "skills", "paperclip"))).isSymbolicLink()).toBe(true);
await expect(fs.lstat(path.join(paperclipHome, "instances", "worktree-1", "codex-home"))).rejects.toThrow(); await expect(fs.lstat(path.join(paperclipHome, "instances", "worktree-1", "codex-home"))).rejects.toThrow();
} finally { } finally {
if (previousHome === undefined) delete process.env.HOME; if (previousHome === undefined) delete process.env.HOME;

View file

@ -43,7 +43,7 @@ describe("codex local skill sync", () => {
expect(before.desiredSkills).toContain(paperclipKey); expect(before.desiredSkills).toContain(paperclipKey);
expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true); expect(before.entries.find((entry) => entry.key === paperclipKey)?.required).toBe(true);
expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured"); expect(before.entries.find((entry) => entry.key === paperclipKey)?.state).toBe("configured");
expect(before.entries.find((entry) => entry.key === paperclipKey)?.detail).toContain(".agents/skills"); expect(before.entries.find((entry) => entry.key === paperclipKey)?.detail).toContain("~/.codex/skills/");
}); });
it("does not persist Paperclip skills into CODEX_HOME during sync", async () => { it("does not persist Paperclip skills into CODEX_HOME during sync", async () => {