Skip to content

Commit b7db1d5

Browse files
authored
feat(chat): [MOC-323] 新版 Codex(26.707) Chat 接入 transfer 自定义模型 (#598)
* feat(chat): [MOC-323] 加 Chat auth 守卫补丁脚本(NODE_OPTIONS 主进程注入) Codex 26.707 Chat(经典 ChatGPT 对话)的主进程 auth 守卫 isDesktopAuthAllowedUrl 只对白名单 host 附加鉴权;transfer 把 CODEX_API_BASE_URL 指向本地 proxy 时会被 拒发。本脚本经 NODE_OPTIONS=--require 在主进程启动早期注入,hook Module._compile 给守卫白名单补上 CAS_CHAT_GUARD_HOST(localhost:<port>),不改磁盘 app.asar。 补丁逻辑已真机实测(patch 命中 + guarded 请求流过非白名单端口)。 Refs MOC-323 * feat(chat): [MOC-323] proxy 拦截 /f/conversation 转自定义 provider + 启动注入 - crates/proxy/chat_conversation.rs: 拦 POST /backend-api/f/conversation,把 classic ChatGPT 封套转 /responses body 内部重派 forward_handler(复用全套 provider resolve/adapter/鉴权),收集 Responses SSE 文本回一条 ChatGPT 整条-message SSE (免 delta)。prepare/models/account 等仍走既有 passthrough 真 chatgpt.com。 gate CAS_CHAT_CUSTOM_MODEL 默认开。接进 forward.rs is_chatgpt_backend 分支。 - process.rs: chat_launch_env(macOS) 写守卫补丁到 ~/.codex-app-transfer + 经 open --env 注 NODE_OPTIONS=--require + CODEX_API_BASE_URL=localhost:<port>/backend-api + CAS_CHAT_GUARD_HOST;设置项 chatCustomModelEnabled 默认开。host 用 localhost 与守卫补丁白名单一致。open_command 加 extra_env 参数。 - 单测:proxy 6 + process 21(含新 chat env / 路径判定 / 封套转换 / SSE 解析)全绿。 Refs MOC-323 * feat(chat): [MOC-323] SettingsPage 加「Chat 使用自定义模型」开关(默认开) chatCustomModelEnabled toggle(默认 true)+ zh/en i18n。位于 Codex 集成分组。 翻动需重启 Codex(启动经 open --env 注入生效)。 Refs MOC-323 * feat(chat): [MOC-323] Chat 多轮上下文连续(per-conv 历史存储) 实测 Chat 每轮只发新消息(messages.len=1,classic ChatGPT 靠 conversation_id 服务端存历史),transfer 拦截后无状态会丢上下文。补 ConvStore(LazyLock<Mutex< HashMap>>,LRU 上限 64 对话/40 条每对话):按 conv_id 载历史→append 新用户消息 →发全历史给 provider→append assistant 回复→回填同一 conv_id(app 下轮回传续接)。 build_responses_from_history 全历史转 /responses input(user=input_text/ assistant=output_text)。删 #4 调研临时日志。单测 8 个(历史续接/上限淘汰/角色映射)。 Refs MOC-323 * feat(chat): [MOC-323] Chat 模型选择器显示自定义模型(复用 coding slot 映射) 拦 GET /backend-api/models,透传真 chatgpt.com 拿真实 picker schema,再用**与 coding 共享的** registry::model_alias slot 映射(openai_model_slot + normalize_model_mappings)把 picker 条目(models[].slug / categories[]. default_model)relabel 成 active provider 的实际模型名(如 gpt-5.5→grok-4.5)。 不新建映射逻辑、不碰高层 catalog crate、mismatch/无 provider 时原样返回零副作用。 active provider 经合成 /responses 请求走 resolver 拿(复用 HTTP 路由)。 passthrough_chatgpt_backend 提 pub(crate) 供本模块复用。单测 3 个(路径判定/ slot relabel/无映射零副作用)。 Refs MOC-323 * Revert "feat(chat): [MOC-323] Chat 模型选择器显示自定义模型(复用 coding slot 映射)" This reverts commit 62bc916. * feat(chat): [MOC-323] Quick Chat 模型名显示自定义模型(CDP renderer relabel 注入器) 新版 Codex 的 Quick Chat 模型选择器是 renderer 硬编码数组(GPT-5.6 Sol/5.5/…), 不向上游拉模型列表 → transfer 把 Chat 路由到自定义 provider 后,picker 仍显示 GPT 名。 改用与 codex_quota_injector 同构的 CDP 注入 daemon:每 tick 经 Runtime.evaluate 装一个 幂等 + 版本化的 MutationObserver,按文本匹配把所有 GPT-5.x 标签 relabel 成活动 provider 的映射目标模型(provider.models.default,回落 provider.name);不碰 reasoning 档位 (Instant/Medium/High/Pro)。注入所有 index.html target(含 Quick Chat 的 ?initialRoute=/chatgpt/quick-chat,排除 avatar-overlay)。 - 开关 settings.chatCustomModelEnabled(默认开,与 chat 功能 gate 一致);关闭推 remove 脚本断 observer + 从 data-cas-orig 还原原始 GPT 标签 - 文本经 serde_json 转义走 textContent sink,无 XSS;relabel 后新文本不再匹配 → 不重入循环 - 取代已 revert 的 proxy /models 拦截方案(62bc916→1a2f224):显示是 renderer 本地渲染, 与上游无关,只能 renderer 侧改 * fix(chat): [MOC-323] Quick Chat 模型注入改「按 coding 映射逐条 relabel」+ 去掉危险的隐藏逻辑 - 每个 picker 条目按它的 gpt model id 查 coding 映射(provider.models 的 gpt_5_X 槽)relabel 成对应目标,与 coding 侧同一套映射 → 显示与路由(chat_conversation 发原始 gpt id 走同一 resolver)自动一致,不会「显示 A 实际调 B」。非 coding 槽条目(Auto 等)relabel 成默认目标。 - **修 UI 崩溃**:v2 曾用 display:none 隐藏「多余项」的行祖先,在 React app 里连带吞掉整个 chat 面板(rowOf 选的 button/容器太大)。v3 起纯文本 relabel,零 DOM 结构改动;加回归护栏 断言 install 脚本不含任何 display/隐藏/remove 操作。 - payload 由 Rust 侧读 MODEL_SLOTS + provider.models 构建 {gpt-5.5→grok-4.5, ...},喂给注入脚本; label→gpt id 从显示文本反推(GPT-5.5→gpt-5.5 / Auto→auto),免读 React fiber。 * fix(chat): [MOC-323] 修多 agent 审查 + bot 全部发现(CRITICAL→LOW) CRITICAL - C1 should_attach_debug_port 纳入 chatCustomModelEnabled(且需有 proxy 路由的活动 provider) → 只开 chat 时也带 CDP 端口,relabel daemon 不再因 CDP_PORT=0 静默跳过、生产环境失效。 - C2 guard patch:regex 捕获 minified 变量名(不写死 `n`,容忍 Codex 更新漂移)+ 写落地校验 breadcrumb chat_guard_status.json;daemon 首次注入成功后读它,applied=false 即 warn(不再无声)。 - C3 relabel sweep:looksLikeModel 改**锚定完整标签形 + 长度封顶**,避免误伤描述性文案 (如 "GPT-5.5 is our strongest…")→ 不再 clobber 无关 UI(v4)。 HIGH - H1 chat_launch_env:无可解析 proxy 目标(全新安装/无 provider)时不注入 CODEX_API_BASE_URL, 回落真 ChatGPT(否则 Quick Chat 被导向死本地端点)。 - H2 上游真错不再吞成「未返回文本」:extract_error_text 从 SSE/body 抠 error.message 带给用户 + 五个错误分支全加 tracing::warn。 - H3 CAS_CHAT_CUSTOM_MODEL 死 gate 改真读 registry settings.chatCustomModelEnabled(env 为覆盖), 运行时关开关即刻停止拦截;注释纠正。 - H4 chat_launch_env I/O 失败/HOME 缺失加 tracing::warn(不再静默空 env)。 - H5 eval_all 部分 target 失败也 warn(不用 .is_ok() 计数掩盖 Quick Chat 独立窗口失败)。 - H6 会话历史 Vec<(String,String)> → Vec<Turn{role:Role,text}>;content_type 走 exhaustive match, 消除非精确 "assistant" 静默当 user 的隐患。 MEDIUM - M1 切到无映射 provider(payload=None)时推 remove 清残留,不再让旧标签挂着。 - M2 chat_conversation 不再把 model=auto 强制改写 gpt-5.5;auto 原样透传 → resolver 降级 default, 与注入器把 Auto 显示成 default 一致(显示=实际调用)。 - M4 daemon 区分「Codex 未就绪」(debug)与「在跑但 eval 失败」(warn),错误变化时重报,不一次性静默。 - M5 JS sweep catch 记 window.__casChatSweepErr(不静默吞,供 CDP 排查)。 - M6 清理大面积 v2「隐藏」注释残留;module doc「只拦2条」→「只接管1条」。 LOW / 质量 - DRY:抽 codex_theme_injector::page_target_ws_urls 共用,删 chat 侧重复 CDP 枚举。 - chat_relabel_payload 抽纯函数 build_payload_from_models + 补 3 测(映射/gpt_5_5 填充/全空 None)。 - ConvStore::put 加 debug_assert map/order 一致;补 40 轮裁剪 + LRU touch 存活测试。 - label_to_id 测试改折叠空白串(对齐 JS \s+),不再逐空格分叉误当覆盖。 - CORS:就地回 OPTIONS 预检 Allow-*(防落 passthrough 阻塞)。 - M3 Chat 开关 gate 到 macOS(非 macOS chat_launch_env 空转)。 * fix(chat): [MOC-323] 修 bot 对 aeda9bc 的 4 条二次反馈 - 移除本地 CORS 处理(bot [1]+[2]):Chat 端到端本就跑通;加的 `*` 预检既不完整(POST 响应 缺 ACAO)又开安全洞(任意网页能预检打本地 proxy 烧额度)。移除后 OPTIONS 落回 passthrough, 攻击者跨源预检拿不到 localhost ACAO 被浏览器挡在请求前(比 `*` 更安全)。 - should_attach_debug_port 的 chat 端口需求 gate 到 macOS(bot [3]):非 macOS chat_launch_env 不注入、chat 不工作,不再白暴露 --remote-debugging-port/--remote-allow-origins=*。 - chat_guard_patch.js 装 hook 前先写初始 pending 失败态(bot [4]):守卫函数被 Codex 更新改名/ 移走时 hook 永不触发,避免旧 applied:true 残留骗过 daemon;真触发再覆盖。
1 parent 8826a2a commit b7db1d5

11 files changed

Lines changed: 1308 additions & 7 deletions

File tree

crates/proxy/src/chat_conversation.rs

Lines changed: 621 additions & 0 deletions
Large diffs are not rendered by default.

crates/proxy/src/forward.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,20 @@ pub async fn forward_handler(
488488
// 复用 state.http(reqwest 默认读系统代理设置 /`scutil --proxy`,跟随系统、非写死端口;
489489
// chatgpt.com 必须经代理才可达,故绝不能 no_proxy)。
490490
if is_chatgpt_backend_path(&client_path) {
491+
// [MOC-323] Chat(经典 ChatGPT 对话)接入自定义模型:只拦 `…/f/conversation`(转
492+
// /responses 内部重派 provider,回 ChatGPT 整条-message SSE),其余(prepare/account/
493+
// 会话列表/plugins)交回下面 passthrough 走真 chatgpt.com。gate 默认开,见模块注释。
494+
if let Some(resp) = crate::chat_conversation::try_handle(
495+
&state,
496+
&parts.method,
497+
&parts.headers,
498+
&client_path,
499+
&body_bytes,
500+
)
501+
.await
502+
{
503+
return Ok(resp);
504+
}
491505
// [MOC-257] 模拟(伪造)账号模式:活动 auth.json 是合成伪造账号 → **截断**这些
492506
// 账号/插件请求、逐条下发伪造 200,而非透传真 chatgpt.com(伪造 token 会被上游 401)。
493507
// 关 / 真实账号 relay 时走原透传。

crates/proxy/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
//! 未实现(下阶段):provider 协议转换(`crates/adapters`,Stage 3)、
1212
//! OS 集成(`crates/codex_integration`,Stage 2.5)、WebSocket 透传。
1313
14+
pub mod chat_conversation;
1415
pub mod diagnostics;
1516
pub mod fake_account;
1617
pub mod fixture;

frontend/src/i18n/en.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,8 @@ export default {
586586
"Logged in, but saving the account failed (check ~/.codex-app-transfer permissions and retry); not switching to real account",
587587
"settings.autoUnlockCodexPlugins": "Auto-unlock Codex plugins",
588588
"settings.autoUnlockCodexPluginsHint": "Unlocks Codex plugins. Requires launching Codex from this app.",
589+
"settings.chatCustomModelEnabled": "Custom models in Chat",
590+
"settings.chatCustomModelEnabledHint": "Lets Codex's Chat (classic conversation) use your configured custom-provider models. Only when Codex is launched from this app; relaunch Codex after toggling.",
589591
"settings.codexQuotaEnabled": "Show usage in Codex",
590592
"settings.codexQuotaEnabledHint": "Shows a usage panel in Codex (context, token rate/total, quota). Only when Codex is launched from this app.",
591593
"settings.workbuddyQuotaGuardThreshold": "WorkBuddy account-switch threshold",

frontend/src/i18n/zh.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -668,6 +668,9 @@ export default {
668668
"settings.realAccountLoginFailed": "登录失败",
669669
"settings.realAccountPinFailed":
670670
"登录成功,但账号持久化失败(请检查 ~/.codex-app-transfer 目录权限后重试),暂不切换真实账号",
671+
"settings.chatCustomModelEnabled": "Chat 使用自定义模型",
672+
"settings.chatCustomModelEnabledHint":
673+
"让 Codex 的 Chat(经典对话)也用已配置的自定义供应商模型。仅通过本应用启动 Codex 时生效,翻动后需重启 Codex。",
671674
"settings.codexQuotaEnabled": "Codex 内显示用量信息",
672675
"settings.codexQuotaEnabledHint":
673676
"仅通过本应用启动 Codex 时生效,在 Codex 內显示用量面板:上下文占用、Tokens 速率与累计以及额度信息。",

frontend/src/pages/SettingsPage.vue

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,9 @@ const restoreCodexOnExit = toggle('restoreCodexOnExit', true)
247247
const autoWakeCodexPet = toggle('autoWakeCodexPet', true)
248248
const codexQuotaEnabled = toggle('codexQuotaEnabled', false)
249249
const codexStashEnabled = toggle('codexStashEnabled', false)
250+
// [MOC-323] Chat(经典 ChatGPT 对话)接入自定义模型:默认开。翻动需重启 Codex 生效
251+
// (启动时经 open --env 注入守卫补丁 + CODEX_API_BASE_URL,把 Chat 对话路由进本地 proxy)。
252+
const chatCustomModelEnabled = toggle('chatCustomModelEnabled', true)
250253
const codexNetworkAccess = toggle('codexNetworkAccess', false)
251254
const exposeAllProviderModels = toggle('exposeAllProviderModels', false)
252255
const showGrayProviders = toggle('showGrayProviders', false)
@@ -646,6 +649,15 @@ const UPDATE_REPO_URL = 'https://github.com/Cmochance/codex-app-transfer'
646649
</SettingsGroup>
647650

648651
<SettingsGroup :title="t('settings.groupCodexIntegration')">
652+
<!-- [code-review M3] Chat 自定义模型仅 macOS 实现(chat_launch_env 只对 macos 注入
653+
NODE_OPTIONS/CODEX_API_BASE_URL);非 macOS 开了也空转 → gate 到 macOS,避免误导。 -->
654+
<SettingsRow
655+
v-if="isMac"
656+
:title="t('settings.chatCustomModelEnabled')"
657+
:description="t('settings.chatCustomModelEnabledHint')"
658+
>
659+
<AppSwitch v-model="chatCustomModelEnabled" />
660+
</SettingsRow>
649661
<SettingsRow :title="t('settings.codexQuotaEnabled')" :description="t('settings.codexQuotaEnabledHint')">
650662
<AppSwitch v-model="codexQuotaEnabled" />
651663
</SettingsRow>
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// [MOC-323] Codex/ChatGPT 桌面 app 的 Chat(经典 ChatGPT 对话)auth 守卫补丁。
2+
//
3+
// 背景:app 主进程(.vite/build/main-*.js)有 `isDesktopAuthAllowedUrl(url)`,只对白名单
4+
// host(localhost / localhost:8000 / *.openai.com / *.chatgpt.com)附加 ChatGPT 鉴权;
5+
// transfer 把 `CODEX_API_BASE_URL` 指向本地 proxy(非白名单端口)时,`/f/conversation` 会被
6+
// 守卫以 `Refusing to attach authentication to non-OpenAI URL` 拒发。守卫在**主进程** JS,
7+
// renderer 的 `--remote-debugging-port` 够不到,故经 `NODE_OPTIONS=--require <本文件>` 在主
8+
// 进程启动早期注入,hook CommonJS 编译,给守卫白名单补上 transfer 的 host(不改磁盘 app.asar、
9+
// 不重打包、不动其它逻辑)。
10+
//
11+
// 端口经 env `CAS_CHAT_GUARD_HOST` 传入(形如 `localhost:18080`),缺省不打补丁(安全)。
12+
//
13+
// **落地校验(code-review C2)**:补丁靠字符串匹配 minified 第三方代码,Codex 更新可能让守卫
14+
// 形态漂移导致**静默失效**。故:① 用 regex 捕获守卫里的变量名(不写死 `n`,容忍 minifier 改名);
15+
// ② 把结果(applied / reason)写 `~/.codex-app-transfer/chat_guard_status.json` breadcrumb,让
16+
// 上层(transfer daemon)能发现「守卫没打上」并告警,而非无声。
17+
'use strict';
18+
19+
function writeStatus(obj) {
20+
try {
21+
const fs = require('fs');
22+
const path = require('path');
23+
const os = require('os');
24+
const dir = path.join(os.homedir(), '.codex-app-transfer');
25+
fs.mkdirSync(dir, { recursive: true });
26+
fs.writeFileSync(
27+
path.join(dir, 'chat_guard_status.json'),
28+
JSON.stringify(Object.assign({ ts: Date.now() }, obj))
29+
);
30+
} catch (_e) {
31+
// 连 breadcrumb 都写不了也不能拖垮启动。
32+
}
33+
}
34+
35+
try {
36+
const Module = require('module');
37+
const extraHost = (process.env.CAS_CHAT_GUARD_HOST || '').trim().toLowerCase();
38+
// 只接受 `localhost:<port>` 形态,避免误放宽成任意 host。
39+
if (!/^localhost:\d{2,5}$/.test(extraHost)) {
40+
writeStatus({ applied: false, reason: 'no-guard-host-env' });
41+
} else {
42+
// **先写初始 pending 失败态**(code-review [4]):若未来 Codex 更新把守卫函数改名/移走,
43+
// 下面的 hook 永远进不了替换块、不会写新 status,旧 `applied:true` 会残留、骗过 daemon 判「健康」。
44+
// 先落 pending,hook 真触发(命中/未命中)再覆盖;永不触发则保持失败态 → 可被发现。
45+
writeStatus({ applied: false, reason: 'pending-guard-module-not-compiled' });
46+
// 容忍 minified 变量名漂移:捕获 `<var>===`localhost:8000`` 的变量名,不写死 `n`。
47+
const GUARD_RE = /([A-Za-z_$][\w$]*)===`localhost:8000`/g;
48+
const origCompile = Module.prototype._compile;
49+
let done = false;
50+
Module.prototype._compile = function (content, filename) {
51+
if (!done && typeof content === 'string' && content.indexOf('isDesktopAuthAllowedUrl') !== -1) {
52+
done = true;
53+
Module.prototype._compile = origCompile; // 处理完守卫模块即卸载 hook,零后续开销。
54+
const replaced = content.replace(GUARD_RE, function (_m, v) {
55+
return '(' + v + '===`localhost:8000`||' + v + '===`' + extraHost + '`)';
56+
});
57+
if (replaced !== content) {
58+
content = replaced; // 全量替换(该字面量在守卫函数内的多处都放宽)。
59+
writeStatus({ applied: true, host: extraHost });
60+
} else {
61+
// 守卫函数在、但目标字面量不在 → 很可能 Codex 更新改了守卫形态,补丁需跟进。
62+
writeStatus({ applied: false, reason: 'needle-not-found' });
63+
}
64+
}
65+
return origCompile.call(this, content, filename);
66+
};
67+
}
68+
} catch (e) {
69+
// 补丁失败不能拖垮 app 启动 —— 降级,但把原因写 breadcrumb 供发现(不再无声)。
70+
writeStatus({ applied: false, reason: 'exception:' + String((e && e.message) || e) });
71+
}

src-tauri/src/admin/services/desktop/process.rs

Lines changed: 128 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -189,10 +189,72 @@ fn quit_command(platform: &str, force: bool) -> Vec<String> {
189189
/// `extra_args`: 附加给 Codex Desktop 本身的参数(如 `--remote-debugging-port=9222`)。
190190
/// macOS 通过 `open` 的 `--args` 传递;Linux 直接追加到命令;Windows Store
191191
/// 应用暂不支持命令行参数(忽略)。
192+
/// [MOC-323] Chat 接入自定义模型:守卫补丁脚本(经 `NODE_OPTIONS=--require` 注入 app 主进程,
193+
/// hook `isDesktopAuthAllowedUrl` 白名单;详见 `resources/chat_guard_patch.js`)。
194+
const CHAT_GUARD_PATCH_JS: &str = include_str!("../../../../resources/chat_guard_patch.js");
195+
196+
/// [MOC-323] 启动 Codex app 时为「Chat 接入自定义模型」注入的环境变量(仅 macOS)。
197+
/// 设置项 `chatCustomModelEnabled` **默认开**;把守卫补丁写到 `~/.codex-app-transfer/`,令
198+
/// app 的 Chat 对话经 `CODEX_API_BASE_URL` 流进本地 proxy。**host 必须用 `localhost`**:守卫
199+
/// 判 `new URL(base).host`,补丁加的是 `localhost:<port>`,base 用 `127.0.0.1` 则 host 不匹配。
200+
fn chat_launch_env(platform: &str) -> Vec<(String, String)> {
201+
if platform != "macos" {
202+
return Vec::new();
203+
}
204+
let cfg = crate::admin::registry_io::load().ok();
205+
let enabled = cfg
206+
.as_ref()
207+
.and_then(|c| c.get("settings"))
208+
.and_then(|s| s.get("chatCustomModelEnabled"))
209+
.and_then(|v| v.as_bool())
210+
.unwrap_or(true); // 默认开
211+
if !enabled {
212+
return Vec::new();
213+
}
214+
// [code-review H1] 无可解析 proxy 目标(全新安装 / activeProvider null / provider 不走
215+
// proxy)时**不注入** base_url。否则 Quick Chat 被 CODEX_API_BASE_URL 导向死的本地
216+
// `/backend-api`,连真 ChatGPT 都用不了 —— 新装用户直接丢失 Chat。此时回落真 ChatGPT。
217+
if !crate::admin::services::desktop::snapshot::active_provider_supports_relay() {
218+
return Vec::new();
219+
}
220+
let port = cfg
221+
.as_ref()
222+
.map(crate::admin::handlers::proxy::read_proxy_port)
223+
.unwrap_or(18080);
224+
let Some(home) = std::env::var_os("HOME") else {
225+
tracing::warn!("[Chat] HOME 未设置,Chat 自定义模型本次启动不生效");
226+
return Vec::new();
227+
};
228+
let dir = PathBuf::from(home).join(".codex-app-transfer");
229+
let patch_path = dir.join("chat_guard_patch.js");
230+
// [code-review H4] I/O 失败不能静默吞成空 env(开关显示开、实际走真 ChatGPT);记 warn 便于诊断。
231+
if let Err(e) = fs::create_dir_all(&dir) {
232+
tracing::warn!(error = %e, dir = %dir.display(), "[Chat] 建目录失败,Chat 自定义模型本次不生效");
233+
return Vec::new();
234+
}
235+
if let Err(e) = fs::write(&patch_path, CHAT_GUARD_PATCH_JS) {
236+
tracing::warn!(error = %e, path = %patch_path.display(), "[Chat] 写守卫补丁失败,Chat 自定义模型本次不生效");
237+
return Vec::new();
238+
}
239+
let host = format!("localhost:{port}");
240+
vec![
241+
(
242+
"NODE_OPTIONS".into(),
243+
format!("--require {}", patch_path.to_string_lossy()),
244+
),
245+
(
246+
"CODEX_API_BASE_URL".into(),
247+
format!("http://{host}/backend-api"),
248+
),
249+
("CAS_CHAT_GUARD_HOST".into(), host),
250+
]
251+
}
252+
192253
fn open_command(
193254
platform: &str,
194255
resolved_macos_app: Option<&str>,
195256
extra_args: &[String],
257+
extra_env: &[(String, String)],
196258
) -> Vec<String> {
197259
match platform {
198260
// [MOC-100 E] 去掉 `-n`(原来强制开新实例以绕过「刚杀完进程、launchd 还没
@@ -203,6 +265,12 @@ fn open_command(
203265
// 走到这里 + POST_QUIT_LAUNCHD_GRACE,那条 race 已不存在 → 用 `open -a` 启**单**实例。
204266
"macos" => {
205267
let mut cmd = vec!["open".into()];
268+
// [MOC-323] `open --env K=V` 把 env 传给被启动的 GUI app(NODE_OPTIONS 守卫补丁 +
269+
// CODEX_API_BASE_URL 路由 Chat 进本地 proxy)。必须在 `-a`/`--args` 之前。
270+
for (k, v) in extra_env {
271+
cmd.push("--env".into());
272+
cmd.push(format!("{k}={v}"));
273+
}
206274
match resolved_macos_app {
207275
Some(path) => {
208276
cmd.push("-a".into());
@@ -703,7 +771,27 @@ fn should_attach_debug_port() -> Vec<String> {
703771
.and_then(|s| s.get("codexStashEnabled"))
704772
.and_then(|v| v.as_bool())
705773
.unwrap_or(false);
706-
if !plugin_unlock_needs_port && !theme_enabled && !quota_enabled && !stash_enabled {
774+
// [MOC-323 / code-review C1] Chat 模型 relabel daemon 也走 CDP。chatCustomModelEnabled
775+
// 默认开、其它 CDP 功能默认关 → 不含它则「只开 chat」时 CDP_PORT=0、daemon 静默跳过、
776+
// picker 永远 GPT 名。只在**确有 proxy 路由的活动 provider**(全新安装/无 provider → false,
777+
// 与 chat_launch_env 的 H1 守卫一致,避免无谓开端口)时才为 chat 要端口。
778+
let chat_enabled = cfg
779+
.as_ref()
780+
.and_then(|c| c.get("settings"))
781+
.and_then(|s| s.get("chatCustomModelEnabled"))
782+
.and_then(|v| v.as_bool())
783+
.unwrap_or(true);
784+
// 仅 macOS 实现 Chat(chat_launch_env 只对 macos 注入);非 macos 开了也不工作,
785+
// 故不为它带 CDP 端口(否则白暴露 --remote-debugging-port/--remote-allow-origins=* — code-review [3])。
786+
let chat_needs_port = chat_enabled
787+
&& cfg!(target_os = "macos")
788+
&& crate::admin::services::desktop::snapshot::active_provider_supports_relay();
789+
if !plugin_unlock_needs_port
790+
&& !theme_enabled
791+
&& !quota_enabled
792+
&& !stash_enabled
793+
&& !chat_needs_port
794+
{
707795
return vec![];
708796
}
709797

@@ -899,7 +987,8 @@ fn open_codex_app(platform: &str) -> Result<(), String> {
899987
None
900988
};
901989
let extra_args = should_attach_debug_port();
902-
let cmd = open_command(platform, resolved.as_deref(), &extra_args);
990+
let chat_env = chat_launch_env(platform);
991+
let cmd = open_command(platform, resolved.as_deref(), &extra_args, &chat_env);
903992
let Some((program, args)) = cmd.split_first() else {
904993
return Err("open command is empty".to_owned());
905994
};
@@ -1266,17 +1355,17 @@ mod tests {
12661355
fn open_command_uses_resolved_path_when_available() {
12671356
// [MOC-100 E] 去掉 `-n`,改单实例 `open -a`
12681357
assert_eq!(
1269-
open_command("macos", Some("/Applications/Codex.app"), &[]),
1358+
open_command("macos", Some("/Applications/Codex.app"), &[], &[]),
12701359
vec!["open", "-a", "/Applications/Codex.app"]
12711360
);
12721361
// [改名适配] 路径落空按 bundle id 兜底(新旧两代同 id;`-a Codex` 在
12731362
// 26.707-only 安装上启不动,`-a ChatGPT` 可能解析到消费级客户端)
12741363
assert_eq!(
1275-
open_command("macos", None, &[]),
1364+
open_command("macos", None, &[], &[]),
12761365
vec!["open", "-b", "com.openai.codex"]
12771366
);
12781367
assert_eq!(
1279-
open_command("macos", None, &["--remote-debugging-port=9222".into()]),
1368+
open_command("macos", None, &["--remote-debugging-port=9222".into()], &[]),
12801369
vec![
12811370
"open",
12821371
"-b",
@@ -1285,13 +1374,45 @@ mod tests {
12851374
"--remote-debugging-port=9222"
12861375
]
12871376
);
1288-
let windows = open_command("windows", None, &[]);
1377+
let windows = open_command("windows", None, &[], &[]);
12891378
assert_eq!(windows[0], "explorer.exe");
12901379
assert!(windows[1].starts_with("shell:AppsFolder\\"));
12911380
assert!(windows[1].contains("OpenAI.Codex"));
1292-
let linux = open_command("linux", None, &[]);
1381+
let linux = open_command("linux", None, &[], &[]);
12931382
assert_eq!(linux[0], "sh");
12941383
assert_eq!(linux[1], "-c");
12951384
assert!(linux[2].contains("codex"));
12961385
}
1386+
1387+
// [MOC-323] Chat env 经 `open --env` 注入,排在 `-a`/`--args` 之前
1388+
#[test]
1389+
fn open_command_injects_chat_env_before_app() {
1390+
let env = vec![
1391+
("NODE_OPTIONS".to_string(), "--require /x/p.js".to_string()),
1392+
(
1393+
"CODEX_API_BASE_URL".to_string(),
1394+
"http://localhost:18080/backend-api".to_string(),
1395+
),
1396+
];
1397+
let cmd = open_command(
1398+
"macos",
1399+
Some("/Applications/ChatGPT.app"),
1400+
&["--remote-debugging-port=0".into()],
1401+
&env,
1402+
);
1403+
assert_eq!(
1404+
cmd,
1405+
vec![
1406+
"open",
1407+
"--env",
1408+
"NODE_OPTIONS=--require /x/p.js",
1409+
"--env",
1410+
"CODEX_API_BASE_URL=http://localhost:18080/backend-api",
1411+
"-a",
1412+
"/Applications/ChatGPT.app",
1413+
"--args",
1414+
"--remote-debugging-port=0",
1415+
]
1416+
);
1417+
}
12971418
}

0 commit comments

Comments
 (0)