Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/client-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
96 changes: 93 additions & 3 deletions src/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -75,6 +75,7 @@ export {
readDshConfig,
parseDshSettingsYaml,
resolveDshHome,
resolveCodexHome,
resolveOpencodeConfigFile,
readOpencodeConfig,
type OpencodeConfig,
Expand Down Expand Up @@ -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 <CODEX_HOME>-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<string, string>; 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 (<CODEX_HOME>-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
Expand Down Expand Up @@ -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 <CODEX_HOME>-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
* `<dshHome>-bili` dir, INDEPENDENT of the settings.yaml rewrite — the
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down
126 changes: 126 additions & 0 deletions tests/launcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ import {
prepareDshHome,
writeDshAcpPatch,
dshArgsWithPatch,
buildCodexMcpArgs,
prepareCodexHome,
prepareCodexMcpInjection,
resolveCodexHome,
readOpencodeConfig,
resolveOpencodeConfigFile,
findFreePort,
Expand Down Expand Up @@ -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;
Expand Down
Loading