Scope Codex local skills home by company
This commit is contained in:
parent
6ba5758d30
commit
3b03ac1734
5 changed files with 95 additions and 27 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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()) ||
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue