diff --git a/AGENTS.md b/AGENTS.md index 529a760..f140eee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,14 +12,21 @@ The non-negotiable details: stored key file. 2. Run `node src/cli.mjs install`, then `node src/cli.mjs start`, then `node src/cli.mjs doctor`. Doctor must report `ok` for config, catalog, router token, proxy, key, - and the app-server bridge. Install must refuse a user-owned `CODEX_CLI_PATH`; it may only install the - DSCodex wrapper when that login-session variable is absent or already DSCodex-owned. The - variable must point at the generated shim `~/.codex/dscodex/codex-cli-bridge.sh`, never - directly at `src/codex-wrapper.mjs`: GUI apps get a bare launchd PATH without Homebrew, so a - `#!/usr/bin/env node` shebang fails there and the shim embeds the absolute node path. The - bridge is macOS-only: Windows desktop apps spawn `CODEX_CLI_PATH` directly and cannot run a - script shim (CreateProcess requires an `.exe`), so on Windows `install` skips the bridge and - the `doctor` bridge check passes trivially. + and the app-server bridge state. The app-server bridge is opt-in (`node src/cli.mjs bridge enable`) + because a global `CODEX_CLI_PATH` demotes the app from its local daemon websocket (which + supports reconnect) to stdio and breaks Computer Use; `install` therefore never sets it and + actively removes DSCodex-owned copies left by older versions, including any `CODEX_CLI_PATH` + the Codex app snapshotted into `[mcp_servers.*.env]`. When enabled, `bridge enable` must refuse + a user-owned `CODEX_CLI_PATH`; the variable must point at the generated shim + `~/.codex/dscodex/codex-cli-bridge.sh`, never directly at `src/codex-wrapper.mjs`: GUI apps get + a bare launchd PATH without Homebrew, so a `#!/usr/bin/env node` shebang fails there. The shim + resolves node from PATH at runtime and only falls back to the absolute path baked at install + time. The wrapper must resolve the stock Codex binary through the shared fallback chain + (`src/real-codex.mjs`) instead of exiting when `DSCODEX_REAL_CODEX` is missing: launchctl login + variables do not survive reboots, and a stale `CODEX_CLI_PATH` must degrade to stock Codex, not + hard-fail every spawn. The bridge is macOS-only: Windows desktop apps spawn `CODEX_CLI_PATH` + directly and cannot run a script shim (CreateProcess requires an `.exe`), so on Windows the + bridge is unavailable and the `doctor` bridge check passes trivially. 3. Run `npm test`; all tests must pass. 4. The ChatGPT desktop app must be fully quit (`⌘Q`) and relaunched, and the user must start a NEW task to see `🐳 V4 Flash`. Existing tasks keep their old model state. diff --git a/README.en.md b/README.en.md index 351b9a0..9fc050e 100644 --- a/README.en.md +++ b/README.en.md @@ -66,10 +66,13 @@ http://127.0.0.1:10110//v1 ← authenticated DSCodex loopback ro ``` `openai_base_url` points at the router and `model_catalog_json` merges V4 Flash into the catalog; -the router rewrites only that model's provider fields and forwards everything else unchanged. The -desktop app starts a transparent bridge via `CODEX_CLI_PATH`, which rewrites only model-selection -JSONL RPC before handing it to the stock bundled Codex binary; the per-provider effort/speed slots -live in that bridge (`~/.codex/dscodex/model-selections.json`). +the router rewrites only that model's provider fields and forwards everything else unchanged. Model +switching works through the catalog out of the box. The desktop app additionally offers an +**opt-in** transparent bridge (`node src/cli.mjs bridge enable`, mounted via `CODEX_CLI_PATH`), +which rewrites only model-selection JSONL RPC before handing it to the stock bundled Codex binary; +the per-provider effort/speed slots live in that bridge (`~/.codex/dscodex/model-selections.json`). +The bridge is off by default: a global `CODEX_CLI_PATH` moves the app off its local daemon +websocket (which supports reconnect) onto stdio, breaking Computer Use. ## Requirements @@ -127,7 +130,7 @@ CLI note: `-m deepseek/deepseek-v4-flash` without the override may show `High`; - Real DeepSeek tool loop and GPT OAuth passthrough verified end-to-end (`DSCODEX_TOOL_OK`, `DSCODEX_GPT_OAUTH_OK`). -- The bridge covers default-picker changes, live-task switches, Fast restoration, and persistence +- The bridge (opt-in) covers default-picker changes, live-task switches, Fast restoration, and persistence across app restarts; `model/list` shows `🐳 V4 Flash`, default `max`, supported `["high","max"]`, with native GPT entries preserved. @@ -180,10 +183,16 @@ CLI note: `-m deepseek/deepseek-v4-flash` without the override may show `High`; unverified PID. Request and decompressed-body limits protect the local process from accidental or hostile memory spikes. - **Platform differences.** Routing, key storage, and catalog merging behave identically on every - platform; the app-server bridge (picker-state memory for the desktop app) is macOS-only. Windows - desktop apps spawn `CODEX_CLI_PATH` directly and CreateProcess cannot run a script shim (only an - `.exe`), so `install` skips the bridge on Windows and the matching `doctor` check passes - trivially. Windows uses the same `%USERPROFILE%\.codex` layout; the key file's 0600 mode is a + platform; the app-server bridge (picker-state memory for the desktop app) is macOS-only and + opt-in (`bridge enable`). It stays off by default because it demotes the app's app-server + connection from the local daemon websocket to stdio, which can break Computer Use; `bridge + disable` reverts at any time and also strips the `CODEX_CLI_PATH` copies the app snapshotted into + `[mcp_servers.*.env]`. The bridge shim resolves node from PATH at runtime and only falls back to + the absolute path baked at install time; when `DSCODEX_REAL_CODEX` is lost (launchctl login + variables do not survive reboots) the wrapper falls back to the app's bundled Codex binary + instead of exiting with an error. Windows desktop apps spawn `CODEX_CLI_PATH` directly and + CreateProcess cannot run a script shim (only an `.exe`), so the bridge is unavailable on Windows + and the matching `doctor` check passes trivially. Windows uses the same `%USERPROFILE%\.codex` layout; the key file's 0600 mode is a no-op there and protection falls back to the account ACL. The router does not auto-start by default; `node src/cli.mjs autostart enable` registers it at login (macOS launchd / Linux systemd user service / Windows Task Scheduler). Crashes are relaunched automatically, while a diff --git a/README.md b/README.md index f77e5a5..9f5624c 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ http://127.0.0.1:10110//v1 ← 带认证的 DSCodex 本地路由 https://chatgpt.com/backend-api/codex (OAuth 流量原样转发) ``` -`openai_base_url` 指向本地路由,`model_catalog_json` 把 V4 Flash 合入模型目录;路由只改写该模型的 provider 字段,其余请求原样转发。桌面端经 `CODEX_CLI_PATH` 挂一个透明 bridge,只改写模型选择的 JSONL RPC,再交给 App 自带的原版 Codex 二进制;双槽 effort/speed 状态就存在这个 bridge 里(`~/.codex/dscodex/model-selections.json`)。 +`openai_base_url` 指向本地路由,`model_catalog_json` 把 V4 Flash 合入模型目录;路由只改写该模型的 provider 字段,其余请求原样转发。模型切换只依赖目录,开箱即用。桌面端另有一个**可选**的透明 bridge(`node src/cli.mjs bridge enable` 后径 `CODEX_CLI_PATH` 挂载),只改写模型选择的 JSONL RPC,再交给 App 自带的原版 Codex 二进制;双槽 effort/speed 状态就存在这个 bridge 里(`~/.codex/dscodex/model-selections.json`)。bridge 默认不启用:全局 `CODEX_CLI_PATH` 会让 App 放弃本地 daemon websocket(支持重连)改走 stdio,破坏 Computer Use。 ## 环境要求 @@ -98,7 +98,7 @@ CLI 注意:`-m deepseek/deepseek-v4-flash` 不带覆盖参数时可能显示 ` ## 已验证行为 - 真实 DeepSeek 工具循环与 GPT OAuth 旁路均端到端实测通过(`DSCODEX_TOOL_OK`、`DSCODEX_GPT_OAUTH_OK`)。 -- bridge 覆盖默认 picker、已有任务切换、Fast 恢复与重启后的状态持久化;`model/list` 返回 `🐳 V4 Flash`、默认 `max`、可选 `["high","max"]`,原生 GPT 条目保留。 +- bridge(可选)覆盖默认 picker、已有任务切换、Fast 恢复与重启后的状态持久化;`model/list` 返回 `🐳 V4 Flash`、默认 `max`、可选 `["high","max"]`,原生 GPT 条目保留。 ## 兼容性速查 @@ -120,7 +120,7 @@ CLI 注意:`-m deepseek/deepseek-v4-flash` 不带覆盖参数时可能显示 ` - **Voice、Pets、插件、技能、MCP。** 都是客户端功能;语音由 GPT-Live 驱动,不会路由到 DeepSeek。 - **Key 存储。** 保存在 `~/.codex/dscodex/config.json`(权限 0600,目录 0700);Windows 使用当前用户 DPAPI 加密,POSIX 系统依靠仅所有者可读的文件权限。旧版 Windows 明文 key 会在下一次安装、启动或配置写入时自动迁移。介意持久化的话不要 `key set`,改用每次会话的 `DEEPSEEK_API_KEY`(或 macOS `launchctl setenv`)。运行时取值顺序:环境变量 → 存储文件 → macOS 登录会话;`key delete` 并重启路由即彻底清除。 - **本地边界。** `install` 会生成 256 位路由令牌并写入 `openai_base_url`;`start` / `serve` 会校准 CLI 自有的 URL、端口和令牌,`doctor` 会验证三者一致,不带令牌的请求返回 404。`stop` 使用同一令牌和一次性关闭令牌握手,并以实例身份原子认领 PID 状态,不会删除替代实例的状态或向未经验证的 PID 发信号;请求体和解压后请求体均有限制,避免本地进程因异常输入发生内存峰值。 -- **平台差异。** 路由、key 存储、目录合并全平台一致;app-server bridge(桌面端模型菜单的状态记忆)仅 macOS——Windows 桌面端直接 spawn `CODEX_CLI_PATH`,脚本 shim 起不来(CreateProcess 只认 `.exe`),因此 Windows 上 `install` 跳过 bridge、`doctor` 对应项自动 `ok`。Windows 的配置目录同样是 `%USERPROFILE%\.codex`;key 文件的 0600 权限位在 Windows 不生效,依赖账户 ACL 保护。路由默认不随机启动;`node src/cli.mjs autostart enable` 可注册登录自启(macOS launchd / Linux systemd user service / Windows 任务计划),崩溃会被自动拉起,而手动 `stop` 是优雅退出(退出码 0),不会被复活;`autostart disable` 和 `uninstall` 都会移除自启项。未开启自启时,重启后重新 `node src/cli.mjs start` 即可(key 已持久化,无需重配)。 +- **平台差异。** 路由、key 存储、目录合并全平台一致;app-server bridge(桌面端模型菜单的状态记忆)仅 macOS 且为可选(`bridge enable`)。默认不启用 bridge:它会把 App 的 app-server 连接从本地 daemon websocket 降级为 stdio,可能破坏 Computer Use;启用后 `bridge disable` 可随时回退,回退会同时清理 App 快照进 `[mcp_servers.*.env]` 的 `CODEX_CLI_PATH`。bridge shim 运行时优先用 PATH 里的 node,找不到才回退安装时焂入的绝对路径;`DSCODEX_REAL_CODEX` 丢失(重启后 launchctl 会话变量不保留)时 wrapper 会回退到 App 自带 Codex 二进制而不是报错退出。Windows 桌面端直接 spawn `CODEX_CLI_PATH`,脚本 shim 起不来(CreateProcess 只认 `.exe`),因此 Windows 上 bridge 不可用、`doctor` 对应项自动 `ok`。Windows 的配置目录同样是 `%USERPROFILE%\.codex`;key 文件的 0600 权限位在 Windows 不生效,依赖账户 ACL 保护。路由默认不随机启动;`node src/cli.mjs autostart enable` 可注册登录自启(macOS launchd / Linux systemd user service / Windows 任务计划),崩溃会被自动拉起,而手动 `stop` 是优雅退出(退出码 0),不会被复活;`autostart disable` 和 `uninstall` 都会移除自启项。未开启自启时,重启后重新 `node src/cli.mjs start` 即可(key 已持久化,无需重配)。 ## 任务中思考为什么反复折叠 diff --git a/package.json b/package.json index 779ab54..cb88a67 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dscodex", - "version": "0.5.1", + "version": "0.6.0", "private": true, "type": "module", "description": "Minimal DeepSeek V4 Flash Responses API integration for Codex", diff --git a/src/catalog.mjs b/src/catalog.mjs index 375725f..cf6d725 100644 --- a/src/catalog.mjs +++ b/src/catalog.mjs @@ -14,6 +14,23 @@ function clone(value) { return JSON.parse(JSON.stringify(value)); } +// Codex version bumps can turn previously optional catalog fields into +// required ones faster than the app rewrites models_cache.json, and a single +// unparseable entry breaks the whole model_catalog_json (app-server fails to +// start). Backfill known-required fields on native entries with safe +// defaults; the DeepSeek entry sets its own values explicitly. +const NATIVE_ENTRY_DEFAULTS = { + supports_reasoning_summaries: false, +}; + +function backfillNativeEntry(model) { + const entry = clone(model); + for (const [key, value] of Object.entries(NATIVE_ENTRY_DEFAULTS)) { + if (entry[key] === undefined) entry[key] = value; + } + return entry; +} + function replaceIdentity(value) { if (typeof value !== "string") return value; return value @@ -74,7 +91,7 @@ export function buildCatalog(cache) { const nativeModels = cache.models.filter((model) => model?.slug !== DEEPSEEK_PICKER_SLUG); const template = nativeModels.find((model) => model?.slug === "gpt-5.6-sol") ?? nativeModels[0]; return { - models: [buildDeepSeekCatalogEntry(template), ...clone(nativeModels)], + models: [buildDeepSeekCatalogEntry(template), ...nativeModels.map(backfillNativeEntry)], }; } diff --git a/src/cli.mjs b/src/cli.mjs index 5d077a5..2f034c1 100755 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -21,6 +21,7 @@ import { ensureManagedRouterBinding, install, managedRouterConfigMatches, + stripBridgeCliPathFromConfig, uninstall, } from "./config.mjs"; import { @@ -52,8 +53,7 @@ import { systemdUnitPath, } from "./autostart.mjs"; import { DEFAULT_PORT, HOST, VERSION, pathsFor, resolveCodexHome } from "./constants.mjs"; - -const APP_SERVER_WRAPPER = fileURLToPath(new URL("./codex-wrapper.mjs", import.meta.url)); +import { APP_SERVER_WRAPPER, resolveRealCodex } from "./real-codex.mjs"; function ts() { return new Date().toISOString(); @@ -71,23 +71,6 @@ function launchctlGet(name) { } } -function stockCodexPath() { - const candidates = [ - launchctlGet("DSCODEX_REAL_CODEX"), - process.env.DSCODEX_REAL_CODEX?.trim(), - "/Applications/ChatGPT.app/Contents/Resources/codex", - ]; - try { - candidates.push(execFileSync("/usr/bin/which", ["codex"], { - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }).trim()); - } catch { - // The bundled ChatGPT path above is the normal macOS install. - } - return candidates.find((candidate) => candidate && candidate !== APP_SERVER_WRAPPER && existsSync(candidate)) ?? ""; -} - function nodePath() { // Windows has no versioned-Cellar problem; the running interpreter is stable. if (process.platform === "win32") return process.execPath; @@ -107,8 +90,17 @@ function nodePath() { function writeBridgeShim(path) { // GUI apps get a bare launchd PATH (/usr/bin:/bin:...), so a `#!/usr/bin/env node` - // shebang fails there. Point CODEX_CLI_PATH at a shim with absolute paths instead. - const content = `#!/bin/sh\nexec ${JSON.stringify(nodePath())} ${JSON.stringify(APP_SERVER_WRAPPER)} "$@"\n`; + // shebang fails there. Resolve node at runtime first, and only fall back to the + // interpreter path baked at install time: a version-manager upgrade (fnm/nvm/ + // Homebrew) must not strand this shim on a deleted binary. + const content = [ + "#!/bin/sh", + "if command -v node >/dev/null 2>&1; then", + ` exec node ${JSON.stringify(APP_SERVER_WRAPPER)} "$@"`, + "fi", + `exec ${JSON.stringify(nodePath())} ${JSON.stringify(APP_SERVER_WRAPPER)} "$@"`, + "", + ].join("\n"); mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); writeFileSync(path, content, { mode: 0o755 }); } @@ -119,7 +111,7 @@ function bridgePlan(paths) { if (existing && existing !== paths.bridgeShim && existing !== APP_SERVER_WRAPPER) { throw new Error(`Refusing to replace user-owned CODEX_CLI_PATH: ${existing}`); } - const realCodex = stockCodexPath(); + const realCodex = resolveRealCodex(); if (!realCodex) throw new Error("Could not locate the stock Codex binary for the app-server bridge"); return { realCodex, shim: paths.bridgeShim }; } @@ -134,9 +126,49 @@ function activateBridge(plan) { function deactivateBridge(paths) { if (process.platform !== "darwin") return; const current = launchctlGet("CODEX_CLI_PATH"); - if (current !== paths.bridgeShim && current !== APP_SERVER_WRAPPER) return; - execFileSync("/bin/launchctl", ["unsetenv", "CODEX_CLI_PATH"]); - execFileSync("/bin/launchctl", ["unsetenv", "DSCODEX_REAL_CODEX"]); + if (current === paths.bridgeShim || current === APP_SERVER_WRAPPER) { + execFileSync("/bin/launchctl", ["unsetenv", "CODEX_CLI_PATH"]); + execFileSync("/bin/launchctl", ["unsetenv", "DSCODEX_REAL_CODEX"]); + } + // The Codex app snapshots CODEX_CLI_PATH into [mcp_servers.*.env]; clear + // DSCodex-owned copies so Computer Use stops spawning the shim as well. + stripBridgeCliPathFromConfig({ paths, ownedValues: [paths.bridgeShim, APP_SERVER_WRAPPER] }); +} + +function bridgeStateOk(paths) { + const current = launchctlGet("CODEX_CLI_PATH"); + if (current !== paths.bridgeShim && current !== APP_SERVER_WRAPPER) return true; + return existsSync(paths.bridgeShim) && Boolean(resolveRealCodex()); +} + +async function manageBridge(subcommand, paths) { + if (process.platform !== "darwin") { + console.log("The app-server bridge is macOS-only; model switching works without it elsewhere"); + return; + } + if (subcommand === "enable") { + activateBridge(bridgePlan(paths)); + console.log("App-server bridge enabled for this login session; fully quit and restart Codex"); + console.log("Note: the bridge moves the app onto a stdio transport and can degrade Computer Use; `bridge disable` reverts"); + return; + } + if (subcommand === "disable") { + deactivateBridge(paths); + console.log("App-server bridge disabled; fully quit and restart Codex"); + return; + } + if (subcommand === "status") { + const current = launchctlGet("CODEX_CLI_PATH"); + if (current === paths.bridgeShim || current === APP_SERVER_WRAPPER) { + console.log(`bridge: enabled (CODEX_CLI_PATH=${current})`); + } else if (current) { + console.log(`bridge: disabled; CODEX_CLI_PATH is user-owned (${current})`); + } else { + console.log("bridge: disabled"); + } + return; + } + throw new Error(`Unknown bridge subcommand: ${subcommand}`); } function parsePort(args, env = process.env) { @@ -722,11 +754,10 @@ async function doctor(port) { router_token_present: Boolean(routerToken), proxy_running: Boolean(ready), deepseek_key_in_proxy: Boolean(ready?.deepseek_key), - // The app-server bridge is macOS-only: Windows GUI apps cannot spawn a script - // shim (CreateProcess requires an .exe), so the check is skipped elsewhere. - app_server_bridge: process.platform !== "darwin" || ( - launchctlGet("CODEX_CLI_PATH") === paths.bridgeShim && Boolean(launchctlGet("DSCODEX_REAL_CODEX")) - ), + // The app-server bridge is macOS-only and opt-in. The check passes when + // the bridge is cleanly disabled (no DSCodex-owned CODEX_CLI_PATH) or + // fully functional (shim present and the stock Codex binary resolvable). + app_server_bridge: process.platform !== "darwin" || bridgeStateOk(paths), }; for (const [name, ok] of Object.entries(checks)) console.log(`${ok ? "ok" : "missing"} ${name}`); if (Object.values(checks).some((ok) => !ok)) process.exitCode = 1; @@ -748,6 +779,7 @@ Usage: dscodex [--port ${DEFAULT_PORT}] start run the loopback router in the background serve run the loopback router in the foreground autostart enable|disable|status run the router automatically at login + bridge enable|disable|status opt-in app-server bridge (provider effort memory; may degrade Computer Use) status show router state doctor verify catalog, routing, key, and app-server bridge state stop stop the background router @@ -769,13 +801,17 @@ async function main() { const { paths } = runtime(); switch (command) { case "install": { - const plan = bridgePlan(paths); const result = install({ paths, port }); - activateBridge(plan); + // The app-server bridge is opt-in: a global CODEX_CLI_PATH forces the + // Codex app off its local daemon websocket (which supports reconnect) + // onto stdio through our shim, breaking Computer Use. Undo any bridge + // a previous DSCodex version installed globally; model switching works + // through the catalog either way. + deactivateBridge(paths); console.log(`Installed ${result.catalog.models[0].display_name} with default Max reasoning`); - console.log("Installed provider-specific effort and speed memory for the ChatGPT app"); console.log(`Fully quit and restart Codex after starting DSCodex on ${HOST}:${port}`); console.log("Optional: `node src/cli.mjs autostart enable` starts the router at login"); + console.log("Optional: `node src/cli.mjs bridge enable` restores provider-specific effort memory in the ChatGPT app"); break; } case "sync": { @@ -788,6 +824,7 @@ async function main() { case "start": await start(port); break; case "serve": await serve(port); break; case "autostart": await manageAutostart(args[0] ?? "status", paths, port); break; + case "bridge": await manageBridge(args[0] ?? "status", paths); break; case "status": await status(port); break; case "doctor": await doctor(port); break; case "stop": await stop(); break; diff --git a/src/codex-wrapper.mjs b/src/codex-wrapper.mjs index c8355d3..9d36bd9 100755 --- a/src/codex-wrapper.mjs +++ b/src/codex-wrapper.mjs @@ -1,16 +1,20 @@ #!/usr/bin/env node -import { existsSync } from "node:fs"; import { homedir } from "node:os"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { spawn } from "node:child_process"; import readline from "node:readline"; import { createAppServerState } from "./app-server-state.mjs"; import { needsShellSpawn } from "./constants.mjs"; +import { resolveRealCodex } from "./real-codex.mjs"; -const self = resolve(process.argv[1]); -const realCodex = process.env.DSCODEX_REAL_CODEX?.trim(); -if (!realCodex || !existsSync(realCodex) || resolve(realCodex) === self) { - console.error("dscodex: DSCODEX_REAL_CODEX does not point to the stock Codex binary"); +// `launchctl setenv DSCODEX_REAL_CODEX` does not survive reboots, so the env +// override may be missing while a stale CODEX_CLI_PATH still points here. +// Fall back to the stock Codex locations instead of hard-failing: every +// spawn through this wrapper (app-server, Computer Use, MCP servers) must +// degrade to stock Codex rather than exit(1). +const realCodex = resolveRealCodex(); +if (!realCodex) { + console.error("dscodex: could not locate the stock Codex binary for the app-server bridge"); process.exit(1); } diff --git a/src/config.mjs b/src/config.mjs index a4ea4c9..44fd77f 100644 --- a/src/config.mjs +++ b/src/config.mjs @@ -218,6 +218,35 @@ export function buildInstalledConfig(content, options) { return injectDesktopReasoning(injectRoot(clean, options)); } +const MCP_ENV_TABLE = /^\s*\[mcp_servers\.[^\]]+\.env\]\s*$/; +const BRIDGE_ENV_KEY = "CODEX_CLI_PATH"; + +// The Codex app snapshots CODEX_CLI_PATH into [mcp_servers.*.env] while the +// bridge is active, persisting the hijack past `launchctl unsetenv`. Remove +// only DSCodex-owned values (the shim or the wrapper); never a user override. +export function stripBridgeCliPath(content, ownedValues) { + const owned = new Set(ownedValues.filter(Boolean)); + if (!owned.size) return content; + const lines = content.replaceAll("\r\n", "\n").split("\n"); + let inMcpEnv = false; + const kept = []; + for (const line of lines) { + if (/^\s*\[/.test(line)) inMcpEnv = MCP_ENV_TABLE.test(line); + if (inMcpEnv && keyOf(line) === BRIDGE_ENV_KEY && owned.has(assignedString(line))) continue; + kept.push(line); + } + return kept.join("\n").replace(/\n{3,}/g, "\n\n"); +} + +export function stripBridgeCliPathFromConfig({ paths, ownedValues }) { + if (!existsSync(paths.config)) return false; + const original = readFileSync(paths.config, "utf8"); + const repaired = stripBridgeCliPath(original, ownedValues); + if (repaired === original) return false; + atomicWrite(paths.config, repaired.endsWith("\n") ? repaired : `${repaired}\n`); + return true; +} + function atomicWrite(path, content, mode = 0o600) { mkdirSync(dirname(path), { recursive: true }); const temporary = `${path}.dscodex-tmp-${process.pid}`; diff --git a/src/constants.mjs b/src/constants.mjs index 0ce011f..6c1b586 100644 --- a/src/constants.mjs +++ b/src/constants.mjs @@ -1,7 +1,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; -export const VERSION = "0.5.1"; +export const VERSION = "0.6.0"; export const DEFAULT_PORT = 10110; export const HOST = "127.0.0.1"; export const DEEPSEEK_PICKER_SLUG = "deepseek/deepseek-v4-flash"; diff --git a/src/real-codex.mjs b/src/real-codex.mjs new file mode 100644 index 0000000..5638fea --- /dev/null +++ b/src/real-codex.mjs @@ -0,0 +1,56 @@ +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const APP_SERVER_WRAPPER = fileURLToPath(new URL("./codex-wrapper.mjs", import.meta.url)); +export const BUNDLED_CODEX = "/Applications/ChatGPT.app/Contents/Resources/codex"; + +function launchctlGetenv(name) { + if (process.platform !== "darwin") return ""; + try { + return execFileSync("/bin/launchctl", ["getenv", name], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return ""; + } +} + +function whichCodex() { + try { + return execFileSync("/usr/bin/which", ["codex"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return ""; + } +} + +// Locate the stock Codex binary the app-server bridge forwards to. Order: +// launchctl login session (macOS), process env, the ChatGPT app's bundled +// binary, then PATH. The wrapper itself is never a valid target. +// +// The fallback chain matters: `launchctl setenv` does not survive reboots, so +// a bridge reactivated from a stale CODEX_CLI_PATH must still find the stock +// binary instead of hard-failing every spawn (app-server, Computer Use, MCP). +export function resolveRealCodex({ + env = process.env, + self = APP_SERVER_WRAPPER, + exists = existsSync, + getenv = launchctlGetenv, + which = whichCodex, +} = {}) { + const selfResolved = self ? resolve(self) : ""; + const candidates = [ + getenv("DSCODEX_REAL_CODEX"), + env.DSCODEX_REAL_CODEX?.trim(), + BUNDLED_CODEX, + which(), + ]; + return candidates.find( + (candidate) => candidate && resolve(candidate) !== selfResolved && exists(candidate), + ) ?? ""; +} diff --git a/test/config.test.mjs b/test/config.test.mjs index 65f7653..0efb99c 100644 --- a/test/config.test.mjs +++ b/test/config.test.mjs @@ -10,6 +10,7 @@ import { install, managedRouterConfigMatches, readManagedRouterToken, + stripBridgeCliPath, stripManagedConfig, uninstall, } from "../src/config.mjs"; @@ -32,6 +33,14 @@ const TEMPLATE = { const ROUTER_TOKEN = "A".repeat(43); +test("catalog backfills newly required fields on stale native cache entries", () => { + const catalog = buildCatalog({ models: [TEMPLATE] }); + const native = catalog.models.find((model) => model.slug === "gpt-5.6-sol"); + assert.equal(native.supports_reasoning_summaries, false); + const deepseek = catalog.models.find((model) => model.slug === "deepseek/deepseek-v4-flash"); + assert.equal(deepseek.supports_reasoning_summaries, false); +}); + test("catalog adds one whale-labelled V4 Flash entry with honest reasoning levels", () => { const catalog = buildCatalog({ models: [TEMPLATE] }); const model = catalog.models[0]; @@ -224,3 +233,52 @@ test("install migrates a legacy Windows plaintext key before returning", () => { assert.equal(readStoredKey(paths.keyFile), "legacy-install-key"); assert.equal(readFileSync(paths.keyFile, "utf8").includes("legacy-install-key"), false); }); + +test("stripBridgeCliPath removes DSCodex-owned CODEX_CLI_PATH from MCP env tables", () => { + const shim = "/Users/x/.codex/dscodex/codex-cli-bridge.sh"; + const wrapper = "/repo/src/codex-wrapper.mjs"; + const content = [ + 'model = "gpt-5.6-sol"', + "", + "[mcp_servers.node_repl]", + 'command = "/Applications/ChatGPT.app/Contents/Resources/cua_node/bin/node_repl"', + "", + "[mcp_servers.node_repl.env]", + 'NODE_REPL_TRUSTED_CODE_PATHS = "/Users/x/.codex"', + `CODEX_CLI_PATH = "${shim}"`, + 'CODEX_HOME = "/Users/x/.codex"', + "", + "[mcp_servers.other.env]", + `CODEX_CLI_PATH = "${wrapper}"`, + "", + "[desktop]", + 'theme = "light"', + "", + ].join("\n"); + + const stripped = stripBridgeCliPath(content, [shim, wrapper]); + assert.equal(stripped.includes("CODEX_CLI_PATH"), false); + assert.match(stripped, /NODE_REPL_TRUSTED_CODE_PATHS/); + assert.match(stripped, /CODEX_HOME/); + assert.match(stripped, /\[mcp_servers\.node_repl\]/); + assert.match(stripped, /theme = "light"/); +}); + +test("stripBridgeCliPath keeps user-owned CODEX_CLI_PATH and non-MCP tables", () => { + const shim = "/Users/x/.codex/dscodex/codex-cli-bridge.sh"; + const content = [ + 'CODEX_CLI_PATH = "/usr/local/bin/codex"', + "", + "[mcp_servers.node_repl.env]", + 'CODEX_CLI_PATH = "/opt/user-owned/codex"', + "", + ].join("\n"); + + const stripped = stripBridgeCliPath(content, [shim]); + assert.equal(stripped, content); +}); + +test("stripBridgeCliPath with no owned values is a no-op", () => { + const content = '[mcp_servers.node_repl.env]\nCODEX_CLI_PATH = "/x"\n'; + assert.equal(stripBridgeCliPath(content, []), content); +}); diff --git a/test/real-codex.test.mjs b/test/real-codex.test.mjs new file mode 100644 index 0000000..be08d61 --- /dev/null +++ b/test/real-codex.test.mjs @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { resolveRealCodex } from "../src/real-codex.mjs"; + +const FAKE_WRAPPER = "/tmp/dscodex/src/codex-wrapper.mjs"; +const existsNone = () => false; +const existsAll = () => true; +const noLaunchctl = () => ""; +const noWhich = () => ""; + +test("resolveRealCodex prefers the launchctl login session value", () => { + const resolved = resolveRealCodex({ + env: { DSCODEX_REAL_CODEX: "/env/codex" }, + self: FAKE_WRAPPER, + exists: existsAll, + getenv: (name) => (name === "DSCODEX_REAL_CODEX" ? "/session/codex" : ""), + which: noWhich, + }); + assert.equal(resolved, "/session/codex"); +}); + +test("resolveRealCodex falls back to env, bundled app, then PATH", () => { + const fromEnv = resolveRealCodex({ + env: { DSCODEX_REAL_CODEX: "/env/codex" }, + self: FAKE_WRAPPER, + exists: existsAll, + getenv: noLaunchctl, + which: noWhich, + }); + assert.equal(fromEnv, "/env/codex"); + + const bundled = resolveRealCodex({ + env: {}, + self: FAKE_WRAPPER, + exists: (path) => path === "/Applications/ChatGPT.app/Contents/Resources/codex", + getenv: noLaunchctl, + which: noWhich, + }); + assert.equal(bundled, "/Applications/ChatGPT.app/Contents/Resources/codex"); + + const fromPath = resolveRealCodex({ + env: {}, + self: FAKE_WRAPPER, + exists: (path) => path === "/usr/local/bin/codex", + getenv: noLaunchctl, + which: () => "/usr/local/bin/codex", + }); + assert.equal(fromPath, "/usr/local/bin/codex"); +}); + +test("resolveRealCodex survives a rebooted session with no env overrides", () => { + // After a reboot launchctl/env are empty; only the bundled binary exists. + const resolved = resolveRealCodex({ + env: {}, + self: FAKE_WRAPPER, + exists: (path) => path === "/Applications/ChatGPT.app/Contents/Resources/codex", + getenv: noLaunchctl, + which: noWhich, + }); + assert.equal(resolved, "/Applications/ChatGPT.app/Contents/Resources/codex"); +}); + +test("resolveRealCodex never targets the wrapper itself", () => { + const resolved = resolveRealCodex({ + env: { DSCODEX_REAL_CODEX: FAKE_WRAPPER }, + self: FAKE_WRAPPER, + exists: existsAll, + getenv: noLaunchctl, + which: () => FAKE_WRAPPER, + }); + assert.equal(resolved, "/Applications/ChatGPT.app/Contents/Resources/codex"); +}); + +test("resolveRealCodex returns empty when no candidate exists", () => { + const resolved = resolveRealCodex({ + env: {}, + self: FAKE_WRAPPER, + exists: existsNone, + getenv: noLaunchctl, + which: noWhich, + }); + assert.equal(resolved, ""); +});