diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 029d0ff0d3..d4a96b45e6 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -655,6 +655,111 @@ process.stdin.on("data", (chunk) => { } } +async function withCustomCodexProviderHome( + run: (input: { + session: AgentSession; + readCaptured: () => CapturedFakeCodexRecord[]; + }) => Promise, +): Promise { + const tempDir = await mkdtemp(path.join(tmpdir(), "codex-provider-home-")); + const daemonCodexHome = path.join(tempDir, "daemon-codex-home"); + const providerCodexHome = path.join(tempDir, "provider-codex-home"); + const fakeAppServerPath = path.join(tempDir, "fake-codex-app-server.cjs"); + const capturedRequestsPath = path.join(tempDir, "requests.jsonl"); + mkdirSync(path.join(daemonCodexHome, "prompts"), { recursive: true }); + mkdirSync(path.join(providerCodexHome, "prompts"), { recursive: true }); + writeFileSync( + path.join(daemonCodexHome, "prompts", "probe-default.md"), + "---\ndescription: Daemon home prompt\n---\nfrom the daemon home\n", + ); + writeFileSync( + path.join(providerCodexHome, "prompts", "probe-profile.md"), + "---\ndescription: Provider home prompt\n---\nfrom the provider home\n", + ); + writeFileSync( + fakeAppServerPath, + ` +const fs = require("node:fs"); + +const capturePath = process.env.PASEO_FAKE_CODEX_CAPTURE; +let buffer = ""; + +fs.appendFileSync(capturePath, JSON.stringify({ kind: "env", CODEX_HOME: process.env.CODEX_HOME }) + "\\n"); + +function resultFor(method) { + if (method === "collaborationMode/list") return { data: [] }; + if (method === "skills/list") return { data: [] }; + if (method === "model/list") return { data: [{ id: "profile-model", isDefault: true }] }; + if (method === "thread/start") return { thread: { id: "thread-1" } }; + return {}; +} + +process.stdin.on("data", (chunk) => { + buffer += chunk.toString(); + for (;;) { + const newlineIndex = buffer.indexOf("\\n"); + if (newlineIndex === -1) break; + const line = buffer.slice(0, newlineIndex).trim(); + buffer = buffer.slice(newlineIndex + 1); + if (!line) continue; + const message = JSON.parse(line); + fs.appendFileSync(capturePath, JSON.stringify({ kind: "request", method: message.method, params: message.params }) + "\\n"); + process.stdout.write(JSON.stringify({ id: message.id, result: resultFor(message.method) }) + "\\n"); + } +}); +`, + ); + + vi.stubEnv("CODEX_HOME", daemonCodexHome); + const registry = buildProviderRegistry(createTestLogger(), { + providerOverrides: { + "profile-codex": { + extends: "codex", + label: "Profile Codex", + command: [process.execPath, fakeAppServerPath], + env: { + CODEX_HOME: providerCodexHome, + PASEO_FAKE_CODEX_CAPTURE: capturedRequestsPath, + }, + }, + }, + }); + const session = await registry["profile-codex"].createClient(createTestLogger()).createSession({ + provider: "profile-codex", + cwd: tempDir, + modeId: "auto", + }); + + try { + return await run({ + session, + readCaptured: () => + readFileSync(capturedRequestsPath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as CapturedFakeCodexRecord), + }); + } finally { + await session.close(); + vi.unstubAllEnvs(); + rmSync(tempDir, { recursive: true, force: true }); + } +} + +async function listPromptCommandsFromCustomCodexHome(): Promise { + return withCustomCodexProviderHome(async ({ session }) => { + const commands = await session.listCommands!(); + return commands.map((command) => command.name).filter((name) => name.startsWith("prompts:")); + }); +} + +async function runPromptFromCustomCodexHome(prompt: string): Promise { + return withCustomCodexProviderHome(async ({ session, readCaptured }) => { + await session.startTurn(prompt); + return readCaptured(); + }); +} + function capturedThreadStartConfig(records: CapturedFakeCodexRecord[]): unknown { const threadStart = records.find((record) => record.method === "thread/start"); const params = threadStart?.params as Record | undefined; @@ -2100,7 +2205,9 @@ describe("Codex app-server provider", () => { }; try { - await expect(listCodexSkills(cwd, workspaceGitService)).resolves.toContainEqual({ + await expect( + listCodexSkills(cwd, path.join(tempDir, "codex-home"), workspaceGitService), + ).resolves.toContainEqual({ name: "shipper", description: "Ship changes carefully.", argumentHint: "", @@ -2361,6 +2468,22 @@ describe("Codex app-server provider", () => { }); }); + test("lists custom prompts from the CODEX_HOME a custom provider runs Codex with", async () => { + await expect(listPromptCommandsFromCustomCodexHome()).resolves.toEqual([ + "prompts:probe-profile", + ]); + }); + + test("runs a custom prompt from the CODEX_HOME a custom provider runs Codex with", async () => { + const records = await runPromptFromCustomCodexHome("/prompts:probe-profile"); + + expect(records.find((record) => record.kind === "env")?.CODEX_HOME).toMatch( + /provider-codex-home$/, + ); + const turnStart = records.find((record) => record.method === "turn/start"); + expect(JSON.stringify(turnStart?.params)).toContain("from the provider home"); + }); + test("deduplicates Codex skill slash commands returned from multiple skill roots", async () => { const commands = await listCommandsFromFakeCodex([ { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 015a9aeaf9..55d7886df8 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -260,6 +260,8 @@ interface CodexAppServerAgentDeps { extends: string; }; customCodexConfig?: CodexCustomProviderConfig | null; + // The CODEX_HOME the session's app-server runs with; prompts and skills are read from it. + codexHome?: string; _createCodexClient?: ( child: ChildProcessWithoutNullStreams, logger: Logger, @@ -545,8 +547,8 @@ async function checkCodexLaunchAvailable(launch: ResolvedProviderLaunch) { }); } -function resolveCodexHomeDir(): string { - return process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex"); +function resolveCodexHomeDir(env: NodeJS.ProcessEnv): string { + return env.CODEX_HOME ?? path.join(os.homedir(), ".codex"); } function decodeEscapedChar(next: string): string { @@ -656,8 +658,7 @@ function parseFrontMatter(markdown: string): { return { frontMatter, body }; } -async function listCodexCustomPrompts(): Promise { - const codexHome = resolveCodexHomeDir(); +async function listCodexCustomPrompts(codexHome: string): Promise { const promptsDir = path.join(codexHome, "prompts"); let entries: Dirent[]; try { @@ -699,6 +700,7 @@ async function listCodexCustomPrompts(): Promise { export async function listCodexSkills( cwd: string, + codexHome: string, workspaceGitService?: Pick, ): Promise { const candidates: string[] = []; @@ -712,7 +714,7 @@ export async function listCodexSkills( candidates.push(path.join(repoRoot, ".codex", "skills")); } - candidates.push(path.join(resolveCodexHomeDir(), "skills")); + candidates.push(path.join(codexHome, "skills")); const candidateReads = await Promise.all( candidates.map(async (dir) => { @@ -3354,6 +3356,7 @@ export class CodexAppServerAgentSession implements AgentSession { private readonly logger: Logger; private readonly config: AgentSessionConfig; private readonly asyncQuestions: CodexAsyncQuestions; + private readonly codexHome: string; private currentMode: string; private hasWorkflowModeOverride: boolean; private readonly providerOptions: CodexProviderOptions; @@ -3468,6 +3471,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.providerOptions = CodexProviderOptionsSchema.parse(config.providerOptions ?? {}); this.config = config; this.asyncQuestions = new CodexAsyncQuestions(resumeHandle?.metadata?.asyncQuestions); + this.codexHome = deps.codexHome ?? resolveCodexHomeDir(process.env); this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId); if (this.config.featureValues?.fast_mode && codexModelSupportsFastMode(this.config.model)) { this.serviceTier = "fast"; @@ -4049,8 +4053,7 @@ export class CodexAppServerAgentSession implements AgentSession { ): Promise { if (commandName.startsWith("prompts:")) { const promptName = commandName.slice("prompts:".length); - const codexHome = resolveCodexHomeDir(); - const promptPath = path.join(codexHome, "prompts", `${promptName}.md`); + const promptPath = path.join(this.codexHome, "prompts", `${promptName}.md`); const raw = await fs.readFile(promptPath, "utf8"); const parsed = parseFrontMatter(raw); return expandCodexCustomPrompt(parsed.body, args); @@ -4954,7 +4957,7 @@ export class CodexAppServerAgentSession implements AgentSession { } async listCommands(): Promise { - const prompts = await listCodexCustomPrompts(); + const prompts = await listCodexCustomPrompts(this.codexHome); if (this.connectionState === "disconnected") { await this.connect(); } else { @@ -4968,7 +4971,7 @@ export class CodexAppServerAgentSession implements AgentSession { })); const fallbackSkills = this.cachedSkills === null - ? await listCodexSkills(this.config.cwd, this.deps.workspaceGitService) + ? await listCodexSkills(this.config.cwd, this.codexHome, this.deps.workspaceGitService) : []; const builtin: AgentSlashCommand[] = [ { @@ -7037,9 +7040,10 @@ export class CodexAppServerAgentClient implements AgentClient { private readonly deps: CodexAppServerAgentDeps = {}, ) {} - private sessionDeps(): CodexAppServerAgentDeps { + private sessionDeps(launchEnv: Record | undefined): CodexAppServerAgentDeps { return { ...this.deps, + codexHome: resolveCodexHomeDir(buildCodexAppServerEnv(this.runtimeSettings, launchEnv)), customCodexConfig: this.customProviderConfig(), }; } @@ -7151,7 +7155,7 @@ export class CodexAppServerAgentClient implements AgentClient { this.logger, () => this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }), - this.sessionDeps(), + this.sessionDeps(launchContext?.env), options?.persistSession === false, goalsEnabled, autoReviewEnabled, @@ -7184,7 +7188,7 @@ export class CodexAppServerAgentClient implements AgentClient { this.logger, () => this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }), - this.sessionDeps(), + this.sessionDeps(launchContext?.env), false, goalsEnabled, autoReviewEnabled,