From 95e14e12eafd992fbd4ce6a6011a0ddc14fa2737 Mon Sep 17 00:00:00 2001 From: skychentian Date: Wed, 5 Aug 2026 00:29:46 +0800 Subject: [PATCH] fix: make the app-server bridge opt-in so Computer Use keeps working Installing DSCodex used to set CODEX_CLI_PATH globally via launchctl. That forces the Codex desktop app off its local app-server daemon websocket (the transport with reconnect support) onto stdio through the DSCodex shim for every spawn - app-server, MCP servers, and Computer Use included. The bridge was also fragile: launchctl login variables do not survive reboots, so a missing DSCODEX_REAL_CODEX made the wrapper exit(1), and the shim embedded a version-manager node path that disappears on upgrade. Any of these left the app unable to spawn Codex at all, which users saw as Computer Use being blocked by DSCodex. Model switching never needed the bridge - it works through the merged model catalog. The bridge only adds per-provider effort/speed memory, so it is now opt-in: - install no longer sets CODEX_CLI_PATH and actively removes DSCodex-owned copies left by older versions, including the ones the Codex app snapshots into [mcp_servers.*.env]; new `dscodex bridge enable|disable|status` command manages the opt-in - the wrapper resolves the stock Codex binary through a shared fallback chain (launchctl -> env -> bundled app binary -> PATH) instead of exiting when DSCODEX_REAL_CODEX is missing, so a stale CODEX_CLI_PATH degrades to stock Codex rather than hard-failing every spawn - the shim resolves node from PATH at runtime and only falls back to the absolute path baked at install time, so fnm/nvm/Homebrew upgrades no longer strand it - doctor now accepts a cleanly disabled bridge and only fails when an active DSCodex bridge is broken Also backfill newly required catalog fields (supports_reasoning_summaries) on native entries: Codex bumps can make a stale models_cache.json entry unparseable, which takes down the whole model_catalog_json and the app-server with it (picker smoke failed before this). Bumps version to 0.6.0. --- AGENTS.md | 23 ++++++--- README.en.md | 27 ++++++---- README.md | 6 +-- package.json | 2 +- src/catalog.mjs | 19 +++++++- src/cli.mjs | 103 ++++++++++++++++++++++++++------------- src/codex-wrapper.mjs | 16 +++--- src/config.mjs | 29 +++++++++++ src/constants.mjs | 2 +- src/real-codex.mjs | 56 +++++++++++++++++++++ test/config.test.mjs | 58 ++++++++++++++++++++++ test/real-codex.test.mjs | 83 +++++++++++++++++++++++++++++++ 12 files changed, 362 insertions(+), 62 deletions(-) create mode 100644 src/real-codex.mjs create mode 100644 test/real-codex.test.mjs 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, ""); +});