From 3b03ac173452c277aaf661a987bb5545a2a13247 Mon Sep 17 00:00:00 2001 From: dotta Date: Wed, 18 Mar 2026 14:38:39 -0500 Subject: [PATCH] Scope Codex local skills home by company --- .../codex-local/src/server/codex-home.ts | 25 +++++++--- .../codex-local/src/server/execute.ts | 21 ++++---- .../adapters/codex-local/src/server/skills.ts | 19 +++++--- .../src/__tests__/codex-local-execute.test.ts | 9 +++- .../__tests__/codex-local-skill-sync.test.ts | 48 +++++++++++++++++++ 5 files changed, 95 insertions(+), 27 deletions(-) diff --git a/packages/adapters/codex-local/src/server/codex-home.ts b/packages/adapters/codex-local/src/server/codex-home.ts index 08c851e7..774a0b35 100644 --- a/packages/adapters/codex-local/src/server/codex-home.ts +++ b/packages/adapters/codex-local/src/server/codex-home.ts @@ -15,25 +15,35 @@ export async function pathExists(candidate: string): Promise { return fs.access(candidate).then(() => true).catch(() => false); } -export function resolveCodexHomeDir(env: NodeJS.ProcessEnv = process.env): string { +export function resolveCodexHomeDir( + env: NodeJS.ProcessEnv = process.env, + companyId?: string, +): string { const fromEnv = nonEmpty(env.CODEX_HOME); - if (fromEnv) return path.resolve(fromEnv); - return path.join(os.homedir(), ".codex"); + const baseHome = fromEnv ? path.resolve(fromEnv) : path.join(os.homedir(), ".codex"); + return companyId ? path.join(baseHome, "companies", companyId) : baseHome; } function isWorktreeMode(env: NodeJS.ProcessEnv): boolean { return TRUTHY_ENV_RE.test(env.PAPERCLIP_IN_WORKTREE ?? ""); } -function resolveWorktreeCodexHomeDir(env: NodeJS.ProcessEnv): string | null { +function resolveWorktreeCodexHomeDir( + env: NodeJS.ProcessEnv, + companyId?: string, +): 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 companyId + ? path.resolve(paperclipHome, "instances", instanceId, "companies", companyId, "codex-home") + : path.resolve(paperclipHome, "instances", instanceId, "codex-home"); } - return path.resolve(paperclipHome, "codex-home"); + return companyId + ? path.resolve(paperclipHome, "companies", companyId, "codex-home") + : path.resolve(paperclipHome, "codex-home"); } async function ensureParentDir(target: string): Promise { @@ -72,8 +82,9 @@ async function ensureCopiedFile(target: string, source: string): Promise { export async function prepareWorktreeCodexHome( env: NodeJS.ProcessEnv, onLog: AdapterExecutionContext["onLog"], + companyId?: string, ): Promise { - const targetHome = resolveWorktreeCodexHomeDir(env); + const targetHome = resolveWorktreeCodexHomeDir(env, companyId); if (!targetHome) return null; const sourceHome = resolveCodexHomeDir(env); diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 6fe475dd..2a5c1c55 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -276,24 +276,21 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0; const env: Record = { ...buildPaperclipEnv(agent) }; - if (effectiveCodexHome) { - env.CODEX_HOME = effectiveCodexHome; - } + env.CODEX_HOME = effectiveCodexHome; env.PAPERCLIP_RUN_ID = runId; const wakeTaskId = (typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) || diff --git a/packages/adapters/codex-local/src/server/skills.ts b/packages/adapters/codex-local/src/server/skills.ts index 4fde6a70..c4c38fcb 100644 --- a/packages/adapters/codex-local/src/server/skills.ts +++ b/packages/adapters/codex-local/src/server/skills.ts @@ -20,20 +20,25 @@ function asString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } -function resolveCodexSkillsHome(config: Record) { +function resolveCodexSkillsHome(config: Record, companyId?: string) { const env = typeof config.env === "object" && config.env !== null && !Array.isArray(config.env) ? (config.env as Record) : {}; const configuredCodexHome = asString(env.CODEX_HOME); - const home = configuredCodexHome ? path.resolve(configuredCodexHome) : resolveCodexHomeDir(process.env); + const home = configuredCodexHome + ? path.resolve(configuredCodexHome) + : resolveCodexHomeDir(process.env, companyId); return path.join(home, "skills"); } -async function buildCodexSkillSnapshot(config: Record): Promise { +async function buildCodexSkillSnapshot( + config: Record, + companyId?: string, +): Promise { const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); - const skillsHome = resolveCodexSkillsHome(config); + const skillsHome = resolveCodexSkillsHome(config, companyId); const installed = await readInstalledSkillTargets(skillsHome); return buildPersistentSkillSnapshot({ adapterType: "codex_local", @@ -49,7 +54,7 @@ async function buildCodexSkillSnapshot(config: Record): Promise } export async function listCodexSkills(ctx: AdapterSkillContext): Promise { - return buildCodexSkillSnapshot(ctx.config); + return buildCodexSkillSnapshot(ctx.config, ctx.companyId); } export async function syncCodexSkills( @@ -61,7 +66,7 @@ export async function syncCodexSkills( ...desiredSkills, ...availableEntries.filter((entry) => entry.required).map((entry) => entry.key), ]); - const skillsHome = resolveCodexSkillsHome(ctx.config); + const skillsHome = resolveCodexSkillsHome(ctx.config, ctx.companyId); await fs.mkdir(skillsHome, { recursive: true }); const installed = await readInstalledSkillTargets(skillsHome); const availableByRuntimeName = new Map(availableEntries.map((entry) => [entry.runtimeName, entry])); @@ -80,7 +85,7 @@ export async function syncCodexSkills( await fs.unlink(path.join(skillsHome, name)).catch(() => {}); } - return buildCodexSkillSnapshot(ctx.config); + return buildCodexSkillSnapshot(ctx.config, ctx.companyId); } export function resolveCodexDesiredSkillNames( diff --git a/server/src/__tests__/codex-local-execute.test.ts b/server/src/__tests__/codex-local-execute.test.ts index c6e19919..9291791c 100644 --- a/server/src/__tests__/codex-local-execute.test.ts +++ b/server/src/__tests__/codex-local-execute.test.ts @@ -48,7 +48,14 @@ describe("codex execute", () => { 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"); + const isolatedCodexHome = path.join( + paperclipHome, + "instances", + "worktree-1", + "companies", + "company-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"); diff --git a/server/src/__tests__/codex-local-skill-sync.test.ts b/server/src/__tests__/codex-local-skill-sync.test.ts index 0ea2b50c..93d9cc88 100644 --- a/server/src/__tests__/codex-local-skill-sync.test.ts +++ b/server/src/__tests__/codex-local-skill-sync.test.ts @@ -56,6 +56,54 @@ describe("codex local skill sync", () => { expect((await fs.lstat(path.join(codexHome, "skills", "paperclip"))).isSymbolicLink()).toBe(true); }); + it("isolates default Codex skills by company when CODEX_HOME comes from process env", async () => { + const sharedCodexHome = await makeTempDir("paperclip-codex-skill-scope-"); + cleanupDirs.add(sharedCodexHome); + const previousCodexHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = sharedCodexHome; + + try { + const companyAContext = { + agentId: "agent-a", + companyId: "company-a", + adapterType: "codex_local", + config: { + env: {}, + paperclipSkillSync: { + desiredSkills: [paperclipKey], + }, + }, + } as const; + + const companyBContext = { + agentId: "agent-b", + companyId: "company-b", + adapterType: "codex_local", + config: { + env: {}, + paperclipSkillSync: { + desiredSkills: [paperclipKey], + }, + }, + } as const; + + await syncCodexSkills(companyAContext, [paperclipKey]); + await syncCodexSkills(companyBContext, [paperclipKey]); + + expect((await fs.lstat(path.join(sharedCodexHome, "companies", "company-a", "skills", "paperclip"))).isSymbolicLink()).toBe(true); + expect((await fs.lstat(path.join(sharedCodexHome, "companies", "company-b", "skills", "paperclip"))).isSymbolicLink()).toBe(true); + await expect(fs.lstat(path.join(sharedCodexHome, "skills", "paperclip"))).rejects.toMatchObject({ + code: "ENOENT", + }); + } finally { + if (previousCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = previousCodexHome; + } + } + }); + it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => { const codexHome = await makeTempDir("paperclip-codex-skill-prune-"); cleanupDirs.add(codexHome);