fix(codex): read custom prompts from the provider's own CODEX_HOME (#5450)

A provider that extends codex and sets CODEX_HOME in its env runs Codex
against that home, but the daemon listed, expanded, and fell back to
skills from its own CODEX_HOME or ~/.codex. The / menu offered another
home's prompts and never the provider's own, and invoking one of the
provider's prompts sent the raw slash text to Codex.

Resolve the Codex home from the same environment the app-server is
launched with, and give it to the session.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-09-28 16:49:02 +01:00
committed by GitHub
parent a43c8d888f
commit dffde6ae8b
2 changed files with 140 additions and 13 deletions
@@ -655,6 +655,111 @@ process.stdin.on("data", (chunk) => {
}
}
async function withCustomCodexProviderHome<T>(
run: (input: {
session: AgentSession;
readCaptured: () => CapturedFakeCodexRecord[];
}) => Promise<T>,
): Promise<T> {
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<string[]> {
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<CapturedFakeCodexRecord[]> {
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<string, unknown> | 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([
{
@@ -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<AgentSlashCommand[]> {
const codexHome = resolveCodexHomeDir();
async function listCodexCustomPrompts(codexHome: string): Promise<AgentSlashCommand[]> {
const promptsDir = path.join(codexHome, "prompts");
let entries: Dirent[];
try {
@@ -699,6 +700,7 @@ async function listCodexCustomPrompts(): Promise<AgentSlashCommand[]> {
export async function listCodexSkills(
cwd: string,
codexHome: string,
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">,
): Promise<AgentSlashCommand[]> {
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<CodexPromptInput> {
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<AgentSlashCommand[]> {
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<string, string> | 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,