diff --git a/src/client-config.ts b/src/client-config.ts index 67417006..47eb1a5b 100644 --- a/src/client-config.ts +++ b/src/client-config.ts @@ -150,6 +150,15 @@ export function resolveDshHome(env: NodeJS.ProcessEnv): string { : path.join(h, ".dsh"); } +/** codex keeps everything under CODEX_HOME (default ~/.codex): config.toml, + * auth.json, sessions. Same resolution the discovery + plugin-install paths + * already use (client-config.ts / plugin-install.ts `codexToml`). */ +export function resolveCodexHome(env: NodeJS.ProcessEnv): string { + const h = os.homedir(); + return nonEmpty(env.CODEX_HOME) ? env.CODEX_HOME! + : path.join(h, ".codex"); +} + /** Line-based scanner for dsh settings.yaml: collects every http(s) URL that * appears as a baseURL/baseUrl/base_url value (llm-pi-ai provider profiles, * llm-deepseek baseURL, model-level overrides). Route discovery only needs diff --git a/src/launcher.ts b/src/launcher.ts index 73d0f90e..2251942c 100644 --- a/src/launcher.ts +++ b/src/launcher.ts @@ -43,7 +43,7 @@ import { selfPackageRoot, isBiliPiEntry, ompPluginLoadedFrom } from "./plugin-in function selfDistFile(name: string): string { return path.join(selfPackageRoot(), "dist", name); } -import { nonEmpty, resolvePiHome, resolveOmpHome, resolveDshHome, loadClientConfig, collectModelWindows, type ClientConfig, type CodexConfig, resolveOpencodeConfigFile, type OpencodeConfig, type OpencodeProvider, type HermesConfig, type HermesProvider } from "./client-config.js"; +import { nonEmpty, resolvePiHome, resolveOmpHome, resolveDshHome, resolveCodexHome, loadClientConfig, collectModelWindows, type ClientConfig, type CodexConfig, resolveOpencodeConfigFile, type OpencodeConfig, type OpencodeProvider, type HermesConfig, type HermesProvider } from "./client-config.js"; import { loadRoutes, resolveConfiguredContextLimit, lookupContextLimit, type ProviderRoutes } from "./config.js"; import { contextFromRegistry } from "./registry.js"; @@ -75,6 +75,7 @@ export { readDshConfig, parseDshSettingsYaml, resolveDshHome, + resolveCodexHome, resolveOpencodeConfigFile, readOpencodeConfig, type OpencodeConfig, @@ -674,6 +675,36 @@ export function buildCodexMcpArgs(origin: string, conversationId: string): strin ]; } +/** #681: how the bili MCP server reaches the spawned codex. On POSIX the + * inline `-c mcp_servers.bili.*` values are safe (no shell re-parses argv), + * so buildCodexMcpArgs stands. On Windows every codex launch rides a .cmd + * shim through cmd.exe, and a `-c` value embedding an absolute path carries + * both quotes and spaces — cmd.exe strips the TOML-required quotes (it has no + * literal-quote escape), leaving malformed TOML. There the definition is + * delivered via a file instead: a persistent -bili overlay whose + * merged config.toml holds [mcp_servers.bili], pointed at by CODEX_HOME. + * When the overlay cannot be built the injection degrades to nothing (wire + * mode still compresses server-side) with a warning. */ +export function prepareCodexMcpInjection(opts: { + platform: NodeJS.Platform; + codexHome: string; + origin: string; + conversationId: string; +}): { clientArgs: string[]; envPatch: Record; warning?: string } { + if (opts.platform !== "win32") { + return { clientArgs: buildCodexMcpArgs(opts.origin, opts.conversationId), envPatch: {} }; + } + const overlay = prepareCodexHome(opts.codexHome, opts.origin, opts.conversationId); + if (!overlay) { + return { + clientArgs: [], + envPatch: {}, + warning: "could not prepare the codex MCP overlay (-bili) — launching without native bili MCP tools; wire-injected compression is still active.", + }; + } + return { clientArgs: [], envPatch: { CODEX_HOME: overlay } }; +} + /** * Shared persistent-overlay machinery for the remaining home-dir launcher * (dsh; pi/omp/hermes went file-free in #535 — env routing + extension, no @@ -1246,6 +1277,56 @@ export function prepareDshHome( return overlay; } +/** Strip any existing [mcp_servers.bili] block from codex config text so the + * launcher can append a fresh one without duplicating the table. Table + * boundaries follow plugin-install.ts `codexRemove`. */ +function stripCodexBiliBlock(text: string): string { + const m = /^[ \t]*\[mcp_servers\.bili\][ \t]*$/m.exec(text); + if (m === null) return text; + const start = m.index; + const lineStart = text.lastIndexOf("\n", start - 1) + 1; + const after = text.slice(start); + const firstNewline = after.indexOf("\n"); + const nextTable = firstNewline < 0 ? -1 : after.slice(firstNewline + 1).search(/^[ \t]*\[/m); + const end = nextTable >= 0 ? start + firstNewline + 1 + nextTable : text.length; + return (text.slice(0, lineStart).replace(/\n+$/, "\n") + text.slice(end)).replace(/^\n+/, ""); +} + +/** Real config.toml text with the launcher's [mcp_servers.bili] merged in: a + * pre-existing block (e.g. from `bili plugin install codex`) is replaced by + * the current launch's command/args/env — adding the per-spawn + * BILI_CONVERSATION_ID the persistent install lacks. Values are + * JSON.stringify'd exactly like plugin-install.ts `codexBlock`, which yields + * valid TOML basic strings (both escape backslashes as \\). */ +function mergeCodexBiliBlock(text: string, origin: string, conversationId: string): string { + const script = selfDistFile("mcp.js"); + const block = + "\n[mcp_servers.bili]\n" + + `command = ${JSON.stringify(process.execPath)}\n` + + `args = [${JSON.stringify(script)}]\n` + + `env = { BILI_MCP_PROXY = ${JSON.stringify(origin)}, BILI_CONVERSATION_ID = ${JSON.stringify(conversationId)} }\n`; + const base = stripCodexBiliBlock(text); + return base + (base.endsWith("\n") || base.length === 0 ? "" : "\n") + block; +} + +/** #681: persistent -bili overlay carrying the bili MCP server in + * config.toml instead of inline `-c` args (which cmd.exe cannot transmit when + * they embed a spaced/quoted Windows path). Every real-home entry except + * config.toml is shared (auth.json, sessions, model settings survive); the + * generated config.toml is the real contents plus [mcp_servers.bili]. Returns + * the overlay dir to point CODEX_HOME at, or undefined when it cannot be + * built (caller then skips native MCP injection). */ +export function prepareCodexHome(codexHome: string, origin: string, conversationId: string): string | undefined { + let txt = ""; + try { + txt = fs.readFileSync(path.join(codexHome, "config.toml"), "utf8"); + } catch {} + const overlay = `${codexHome}-bili`; + if (!refreshOverlayHome(codexHome, overlay, "config.toml")) return undefined; + writeOverlayFileAtomic(overlay, "config.toml", mergeCodexBiliBlock(txt, origin, conversationId)); + return overlay; +} + /** Write the `--patch` overlay file that inserts the bili /acp command * plugin into whatever profile dsh boots. Lives in the persistent * `-bili` dir, INDEPENDENT of the settings.yaml rewrite — the @@ -1892,7 +1973,6 @@ export async function runLaunch(params: RunLaunchParams, deps: LauncherDeps = {} const codexConversationId = injectMcp ? randomUUID() : undefined; if (directUrl) { env = { ...process.env, BILLION_CONTEXT_PROXY: origin }; - if (codexConversationId) clientArgs = [...buildCodexMcpArgs(origin, codexConversationId), ...clientArgs]; } else { env = buildCodexEnv(origin, resolveCombinedCaPath(process.env), process.env); clientArgs = buildCodexArgs(origin, routes.httpRewrites, routes.httpsRewrites, clientArgs); @@ -1907,7 +1987,17 @@ export async function runLaunch(params: RunLaunchParams, deps: LauncherDeps = {} clientArgs = [...budgetArgs, ...clientArgs]; console.error(`bili: codex budget aligned — ${budgetArgs.slice(2).join(", ")} (model: ${config.codex?.model})`); } - if (injectMcp && codexConversationId) clientArgs = [...buildCodexMcpArgs(origin, codexConversationId), ...clientArgs]; + } + if (injectMcp && codexConversationId) { + const inj = prepareCodexMcpInjection({ + platform: process.platform, + codexHome: resolveCodexHome(process.env), + origin, + conversationId: codexConversationId, + }); + if (inj.clientArgs.length > 0) clientArgs = [...inj.clientArgs, ...clientArgs]; + Object.assign(env, inj.envPatch); + if (inj.warning) console.error(`bili: ${inj.warning}`); } } else { env = directUrl diff --git a/tests/launcher.test.ts b/tests/launcher.test.ts index 478c767f..defbc142 100644 --- a/tests/launcher.test.ts +++ b/tests/launcher.test.ts @@ -43,6 +43,10 @@ import { prepareDshHome, writeDshAcpPatch, dshArgsWithPatch, + buildCodexMcpArgs, + prepareCodexHome, + prepareCodexMcpInjection, + resolveCodexHome, readOpencodeConfig, resolveOpencodeConfigFile, findFreePort, @@ -1715,6 +1719,128 @@ test("prepareDshHome: returns undefined for unreadable settings even with rewrit } }); +test("resolveCodexHome: honours CODEX_HOME, defaults to ~/.codex", () => { + assert.equal(resolveCodexHome({ CODEX_HOME: "/tmp/cx" }), "/tmp/cx"); + assert.ok(resolveCodexHome({}).endsWith(".codex")); +}); + +test("prepareCodexHome: no real config → overlay holds only the bili MCP block, siblings shared, real home untouched (#681)", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cx-home-")); + const origin = "http://127.0.0.1:8787"; + const cid = "conv-1"; + try { + fs.writeFileSync(path.join(dir, "auth.json"), '{"id_token":"x"}'); + fs.mkdirSync(path.join(dir, "sessions")); + const authOriginal = fs.readFileSync(path.join(dir, "auth.json"), "utf8"); + + const overlay = prepareCodexHome(dir, origin, cid); + assert.ok(overlay); + assert.equal(overlay, `${dir}-bili`); + const txt = fs.readFileSync(path.join(overlay, "config.toml"), "utf8"); + assert.equal((txt.match(/\[mcp_servers\.bili\]/g) ?? []).length, 1); + assert.ok(txt.includes(`command = ${JSON.stringify(process.execPath)}`)); + assert.match(txt, /args = \[.*mcp\.js.*\]/); + assert.ok(txt.includes(`BILI_MCP_PROXY = ${JSON.stringify(origin)}`)); + assert.ok(txt.includes(`BILI_CONVERSATION_ID = ${JSON.stringify(cid)}`)); + // the command value must be a quoted TOML basic string — only then does a spaced/quoted Windows path survive being read from the file + assert.match(txt, /^command = ".+"$/m); + assert.ok(fs.lstatSync(path.join(overlay, "auth.json")).isSymbolicLink()); + assert.ok(fs.lstatSync(path.join(overlay, "sessions")).isSymbolicLink()); + assert.equal(fs.readFileSync(path.join(dir, "auth.json"), "utf8"), authOriginal); + assert.ok(!fs.existsSync(path.join(dir, "config.toml"))); + fs.rmSync(overlay, { recursive: true, force: true }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("prepareCodexHome: real config without bili → original preserved, block appended once (#681)", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cx-home-")); + try { + fs.writeFileSync( + path.join(dir, "config.toml"), + ['model = "gpt-5"', "", '[model_providers.openai]', 'name = "OpenAI"', ''].join("\n"), + ); + const original = fs.readFileSync(path.join(dir, "config.toml"), "utf8"); + const overlay = prepareCodexHome(dir, "http://127.0.0.1:8787", "conv-2"); + assert.ok(overlay); + const txt = fs.readFileSync(path.join(overlay, "config.toml"), "utf8"); + assert.ok(txt.includes('model = "gpt-5"')); + assert.ok(txt.includes('[model_providers.openai]')); + assert.equal((txt.match(/\[mcp_servers\.bili\]/g) ?? []).length, 1); + assert.equal(fs.readFileSync(path.join(dir, "config.toml"), "utf8"), original); + fs.rmSync(overlay, { recursive: true, force: true }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("prepareCodexHome: pre-existing [mcp_servers.bili] is replaced, never duplicated (#681)", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cx-home-")); + try { + fs.writeFileSync( + path.join(dir, "config.toml"), + [ + "model = \"gpt-5\"", + "", + "[mcp_servers.bili]", + "command = \"/old/path/node\"", + "args = [\"/old/mcp.js\"]", + "env = { BILI_MCP_PROXY = \"http://old:1\" }", + "", + "[other_table]", + "keep = \"me\"", + "", + ].join("\n"), + ); + const overlay = prepareCodexHome(dir, "http://127.0.0.1:8787", "conv-3"); + assert.ok(overlay); + const txt = fs.readFileSync(path.join(overlay, "config.toml"), "utf8"); + assert.equal((txt.match(/\[mcp_servers\.bili\]/g) ?? []).length, 1, "exactly one bili block"); + assert.ok(!txt.includes("/old/path/node"), "stale install block removed"); + assert.ok(!txt.includes("http://old:1"), "stale proxy origin removed"); + assert.ok(txt.includes(`BILI_CONVERSATION_ID = ${JSON.stringify("conv-3")}`), "per-spawn conversation id added"); + assert.ok(txt.includes('model = "gpt-5"'), "unrelated top-level key kept"); + assert.ok(txt.includes('[other_table]') && txt.includes('keep = "me"'), "unrelated table kept"); + fs.rmSync(overlay, { recursive: true, force: true }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("prepareCodexMcpInjection: POSIX keeps inline -c args, no CODEX_HOME redirect (#681)", () => { + const r = prepareCodexMcpInjection({ + platform: "linux", + codexHome: "/nonexistent-codex-home", + origin: "http://127.0.0.1:8787", + conversationId: "conv-x", + }); + assert.deepEqual(r.clientArgs, buildCodexMcpArgs("http://127.0.0.1:8787", "conv-x")); + assert.deepEqual(r.envPatch, {}); + assert.equal(r.warning, undefined); +}); + +test("prepareCodexMcpInjection: win32 redirects CODEX_HOME to the overlay, drops inline args (#681)", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cx-home-")); + try { + fs.writeFileSync(path.join(dir, "auth.json"), "{}"); + const r = prepareCodexMcpInjection({ + platform: "win32", + codexHome: dir, + origin: "http://127.0.0.1:8787", + conversationId: "conv-w", + }); + assert.deepEqual(r.clientArgs, [], "no inline -c args on Windows"); + assert.equal(r.envPatch.CODEX_HOME, `${dir}-bili`); + assert.ok(fs.existsSync(path.join(`${dir}-bili`, "config.toml"))); + const txt = fs.readFileSync(path.join(`${dir}-bili`, "config.toml"), "utf8"); + assert.equal((txt.match(/\[mcp_servers\.bili\]/g) ?? []).length, 1); + fs.rmSync(`${dir}-bili`, { recursive: true, force: true }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + test("runLaunch dsh: non-loopback upstreams ride proxy envs, loopback keeps the overlay (#535 phase 4)", async () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "bili-dsh-launch-")); const prevBin = process.env.BILI_CLIENT_BIN;