Scope Codex local skills home by company

This commit is contained in:
dotta 2026-03-18 14:38:39 -05:00
parent 6ba5758d30
commit 3b03ac1734
5 changed files with 95 additions and 27 deletions

View file

@ -15,25 +15,35 @@ export async function pathExists(candidate: string): Promise<boolean> {
return fs.access(candidate).then(() => true).catch(() => false); 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); const fromEnv = nonEmpty(env.CODEX_HOME);
if (fromEnv) return path.resolve(fromEnv); const baseHome = fromEnv ? path.resolve(fromEnv) : path.join(os.homedir(), ".codex");
return path.join(os.homedir(), ".codex"); return companyId ? path.join(baseHome, "companies", companyId) : baseHome;
} }
function isWorktreeMode(env: NodeJS.ProcessEnv): boolean { function isWorktreeMode(env: NodeJS.ProcessEnv): boolean {
return TRUTHY_ENV_RE.test(env.PAPERCLIP_IN_WORKTREE ?? ""); 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; if (!isWorktreeMode(env)) return null;
const paperclipHome = nonEmpty(env.PAPERCLIP_HOME); const paperclipHome = nonEmpty(env.PAPERCLIP_HOME);
if (!paperclipHome) return null; if (!paperclipHome) return null;
const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID); const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID);
if (instanceId) { 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<void> { async function ensureParentDir(target: string): Promise<void> {
@ -72,8 +82,9 @@ async function ensureCopiedFile(target: string, source: string): Promise<void> {
export async function prepareWorktreeCodexHome( export async function prepareWorktreeCodexHome(
env: NodeJS.ProcessEnv, env: NodeJS.ProcessEnv,
onLog: AdapterExecutionContext["onLog"], onLog: AdapterExecutionContext["onLog"],
companyId?: string,
): Promise<string | null> { ): Promise<string | null> {
const targetHome = resolveWorktreeCodexHomeDir(env); const targetHome = resolveWorktreeCodexHomeDir(env, companyId);
if (!targetHome) return null; if (!targetHome) return null;
const sourceHome = resolveCodexHomeDir(env); const sourceHome = resolveCodexHomeDir(env);

View file

@ -276,24 +276,21 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const desiredSkillNames = resolveCodexDesiredSkillNames(config, codexSkillEntries); const desiredSkillNames = resolveCodexDesiredSkillNames(config, codexSkillEntries);
await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
const preparedWorktreeCodexHome = const preparedWorktreeCodexHome =
configuredCodexHome ? null : await prepareWorktreeCodexHome(process.env, onLog); configuredCodexHome ? null : await prepareWorktreeCodexHome(process.env, onLog, agent.companyId);
const effectiveCodexHome = configuredCodexHome ?? preparedWorktreeCodexHome; const defaultCodexHome = resolveCodexHomeDir(process.env, agent.companyId);
const effectiveCodexHome = configuredCodexHome ?? preparedWorktreeCodexHome ?? defaultCodexHome;
await ensureCodexSkillsInjected( await ensureCodexSkillsInjected(
onLog, onLog,
effectiveCodexHome {
? { skillsHome: path.join(effectiveCodexHome, "skills"),
skillsHome: path.join(effectiveCodexHome, "skills"), skillsEntries: codexSkillEntries,
skillsEntries: codexSkillEntries, desiredSkillNames,
desiredSkillNames, },
}
: { skillsEntries: codexSkillEntries, desiredSkillNames },
); );
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.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()) ||

View file

@ -20,20 +20,25 @@ function asString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
} }
function resolveCodexSkillsHome(config: Record<string, unknown>) { function resolveCodexSkillsHome(config: Record<string, unknown>, companyId?: string) {
const env = const env =
typeof config.env === "object" && config.env !== null && !Array.isArray(config.env) typeof config.env === "object" && config.env !== null && !Array.isArray(config.env)
? (config.env as Record<string, unknown>) ? (config.env as Record<string, unknown>)
: {}; : {};
const configuredCodexHome = asString(env.CODEX_HOME); 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"); return path.join(home, "skills");
} }
async function buildCodexSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> { async function buildCodexSkillSnapshot(
config: Record<string, unknown>,
companyId?: string,
): Promise<AdapterSkillSnapshot> {
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
const skillsHome = resolveCodexSkillsHome(config); const skillsHome = resolveCodexSkillsHome(config, companyId);
const installed = await readInstalledSkillTargets(skillsHome); const installed = await readInstalledSkillTargets(skillsHome);
return buildPersistentSkillSnapshot({ return buildPersistentSkillSnapshot({
adapterType: "codex_local", adapterType: "codex_local",
@ -49,7 +54,7 @@ async function buildCodexSkillSnapshot(config: Record<string, unknown>): Promise
} }
export async function listCodexSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> { export async function listCodexSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
return buildCodexSkillSnapshot(ctx.config); return buildCodexSkillSnapshot(ctx.config, ctx.companyId);
} }
export async function syncCodexSkills( export async function syncCodexSkills(
@ -61,7 +66,7 @@ export async function syncCodexSkills(
...desiredSkills, ...desiredSkills,
...availableEntries.filter((entry) => entry.required).map((entry) => entry.key), ...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 }); await fs.mkdir(skillsHome, { recursive: true });
const installed = await readInstalledSkillTargets(skillsHome); const installed = await readInstalledSkillTargets(skillsHome);
const availableByRuntimeName = new Map(availableEntries.map((entry) => [entry.runtimeName, entry])); 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(() => {}); await fs.unlink(path.join(skillsHome, name)).catch(() => {});
} }
return buildCodexSkillSnapshot(ctx.config); return buildCodexSkillSnapshot(ctx.config, ctx.companyId);
} }
export function resolveCodexDesiredSkillNames( export function resolveCodexDesiredSkillNames(

View file

@ -48,7 +48,14 @@ describe("codex execute", () => {
const capturePath = path.join(root, "capture.json"); const capturePath = path.join(root, "capture.json");
const sharedCodexHome = path.join(root, "shared-codex-home"); const sharedCodexHome = path.join(root, "shared-codex-home");
const paperclipHome = path.join(root, "paperclip-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(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");

View file

@ -56,6 +56,54 @@ describe("codex local skill sync", () => {
expect((await fs.lstat(path.join(codexHome, "skills", "paperclip"))).isSymbolicLink()).toBe(true); 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 () => { it("keeps required bundled Paperclip skills installed even when the desired set is emptied", async () => {
const codexHome = await makeTempDir("paperclip-codex-skill-prune-"); const codexHome = await makeTempDir("paperclip-codex-skill-prune-");
cleanupDirs.add(codexHome); cleanupDirs.add(codexHome);