@yanglaofish/dsh-proxy-pro 1.1.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -135,6 +135,13 @@ dsh-proxy-pro
135
135
 
136
136
  ## 开发
137
137
 
138
+ 本地测试使用 Node.js 24(与发布 CI 一致)。多 profile 分别运行在独立进程中,
139
+ 可以使用不同监听端口并共用同一个上游代理;每个页面只能向自己的 `host:port`
140
+ 提交插件配置。此校验不影响代理端口、内网源或 `NO_PROXY`。
141
+
142
+ 启动会在插件初始化完成前应用已保存的代理配置;默认关闭时不改动已有传输。
143
+ 状态、系统提示和诊断提示会隐藏代理 URL 中的认证信息,连接参数及配置编辑框仍保留完整值。
144
+
138
145
  ```sh
139
146
  # 语法检查
140
147
  node --check lib/index.js lib/client.js lib/proxy-core.js
@@ -143,6 +150,15 @@ node --check lib/index.js lib/client.js lib/proxy-core.js
143
150
  npm test
144
151
  ```
145
152
 
153
+ 装有官方 Windows 桌面版时,可额外运行真实内核验证(替换为本机安装目录):
154
+
155
+ ```powershell
156
+ node scripts/verify-desktop-runtime.mjs 'E:\Agenttools\deepseek\app'
157
+ ```
158
+
159
+ 该验证在两个隔离进程内加载桌面版自带依赖,以本地测试代理检查认证、启动首请求、
160
+ 直连和关闭一方后的进程隔离,同时执行真实工具 schema 编译;不会修改已安装的 profile。
161
+
146
162
  - 纯逻辑层 `lib/proxy-core.js` 零 DSH 依赖,可独立单测。
147
163
  - **GitHub 安装模式**:改代码需 `git push` 后 `pnpm update @yanglaofish/dsh-proxy-pro` 再重启 `dsh web` 生效。
148
164
  - **npm 安装模式**:bump 版本 → `npm publish --access=public --registry=https://registry.npmjs.org --replace-registry-host=never` 后,profile 内 `pnpm add @yanglaofish/dsh-proxy-pro@最新版` 再重启 `dsh web` 生效。(scoped 包发布必须 `--access=public`;本机 npm 默认 registry 被公司镜像接管且 `replace-registry-host` 会重写 GET——发布与验证一律显式 `--registry` 官方源 + `--replace-registry-host=never`。)
@@ -150,4 +166,4 @@ npm test
150
166
 
151
167
  ## 许可
152
168
 
153
- MIT
169
+ MIT
package/docs/LESSONS.md CHANGED
@@ -991,4 +991,261 @@ PowerShell `env:` 枚举报「已添加了具有相同键的项」就是"证据"
991
991
 
992
992
  **排查手法(可复用)**:内核打包在 `…\DeepSeek Harness\resources\app.asar` 里,
993
993
  PowerShell 读不到;用 asar 头解析脚本(`_asar_probe.mjs` / `_asar_grep.mjs`)列文件、
994
- 按路径取文件、全库 grep 字符串,再顺着 `dsh-app-boot` → 兼容判定看真相。
994
+ 按路径取文件、全库 grep 字符串,再顺着 `dsh-app-boot` → 兼容判定看真相。
995
+
996
+ ## 34. 发布后运维:淘宝同步端点 + profile 装包通道(2026-10-08)
997
+
998
+ **淘宝主动同步(铁律 §28 的正确端点)**:
999
+
1000
+ - ✅ 可用:`PUT https://registry.npmmirror.com/-/sync?name=%40scope%2Fname` → `201 {"ok":true,…}`
1001
+ - ❌ 旧写法失效:`PUT https://registry.npmmirror.com/-/sync/@scope%2fname` → **404**;
1002
+ `https://npmmirror.com/sync/@scope/name` → 405。workflow 里的同步步骤已按此改正
1003
+ (此前 `|| true` 吞掉了 404,实际靠 npmmirror 懒同步兜底,属于"看起来同步了"的假象)。
1004
+
1005
+ **profile 装包通道(升级 web/test/desktop 时)**:
1006
+
1007
+ - profile 的 pnpm registry 是**华为内网镜像** `http://mirrors.tools.huawei.com/npm/`,
1008
+ 它对新版本有滞后:publish 完立刻 `pnpm add pkg@新版本` 会报
1009
+ "The latest release of … is <旧版本>",甚至 `--force` 也无法绕过(元数据是镜像给的)。
1010
+ - pnpm **直连** npmmirror / npmjs 报 `EACCES`(不走系统代理,被网络策略挡住)。
1011
+ - ✅ 解法:给 pnpm 挂上公司代理 + 显式淘宝 registry——
1012
+ `$env:HTTP_PROXY=$env:HTTPS_PROXY='http://proxyhk.huawei.com:8080'` 然后
1013
+ `pnpm add 'pkg@版本' --registry=https://registry.npmmirror.com`。
1014
+ - `pnpm cache delete <pkg>` 会列出/删除三份元数据缓存(huawei / npmjs / npmmirror),
1015
+ 可先用它排除缓存干扰。
1016
+
1017
+ ## 35. dsh 0.2 的 `webServer` 是晚挂载:不要在 apply 里 `ctx.get` 一次就当真(2026-10-08)
1018
+
1019
+ **现象**:test profile 里客户端面板显示「宿主未提供配置接口(/dsh-proxy-pro/api/config)」,
1020
+ 直接 `Invoke-WebRequest http://127.0.0.1:<port>/dsh-proxy-pro/api/config` → **404**
1021
+ (服务器活着,是回退处理器答的),而插件其它部分看起来正常。
1022
+
1023
+ **根因**:dsh 0.2 里 `webServer`(`@deepseek-ai/dsh-host-webserver`)**在插件行 apply 之后才挂载**。
1024
+ 我们原来的写法是 apply 里 `const webServer = ctx.get('webServer')`,拿到 `undefined` 就
1025
+ `if (webServer !== undefined)` 整块跳过——**静默、无日志**,路由永远没注册。
1026
+
1027
+ **解法(0.2 的规范写法)**:用**作用域注入**在服务出现时再注册:
1028
+
1029
+ ```js
1030
+ if (typeof ctx.inject === 'function') {
1031
+ ctx.inject(['webServer'], (childCtx) => { registerApi(childCtx.webServer) })
1032
+ }
1033
+ registerApi(ctx.get('webServer')) // 旧内核:apply 时服务已在
1034
+ ```
1035
+
1036
+ - `ctx.inject([name], cb)` 在内核里到处都在用(`ctx.inject(['sessionPersistence'], …)`、
1037
+ `ctx.inject(['settings'], …)`);服务已存在时立即执行,后到时在执行。
1038
+ - 注册成功要打一行 info(`status API registered on …`)——这次就是靠"没有任何日志"才绕了弯路。
1039
+ - 用 `apiRegistered` 标志防止两条路径重复注册(重复 `register` 会抛)。
1040
+ - **不要**把 `webServer` 写进插件的 `inject` 顶层数组:那会让整个插件在无 web 服务的
1041
+ composition(如纯 TUI)里永久 pending(§6 的坑)。
1042
+
1043
+ **可复用教训**:0.2 里凡"我 apply 时它还没挂"的服务,都改用 `ctx.inject([...], cb)`;
1044
+ 以及**静默跳过是最贵的失败模式**——拿不到依赖时要留日志。
1045
+
1046
+ ## 36. 设置字段必须 `.volatile()`,否则"点一下开启马上弹回"(2026-10-08)
1047
+
1048
+ **现象**:面板能读(GET /config → 200,`enabled:false`),但点开关立刻弹回关闭。
1049
+ 直接打接口拿到真话:
1050
+
1051
+ ```json
1052
+ POST /dsh-proxy-pro/api/config {"enabled":true}
1053
+ 503 {"ok":false,"error":"Plugin entry \"dsh-proxy-pro\" has no volatile fields"}
1054
+ ```
1055
+
1056
+ **根因**:dsh 0.2 的 `dsh-settings` 只允许写**声明为 volatile 的字段**——
1057
+ `volatileForm(schema)` 要求节点带 `meta.volatile`,`isVolatilePath(schema, path)` 逐级校验;
1058
+ 我们原来的 `Config` 一个字段都没标 → 任何写入都被拒。前端拿到失败后回滚乐观值,
1059
+ 看起来就像"UI 坏了"。
1060
+
1061
+ **解法**(文档与内核一致):
1062
+
1063
+ ```js
1064
+ const volatile = (field) => (typeof field?.volatile === 'function' ? field.volatile() : field)
1065
+ const Config = z.object({
1066
+ enabled: volatile(z.boolean().default(false)),
1067
+ mode: volatile(z.union([...]).default('system')),
1068
+ customUrl: volatile(z.string().default('http://127.0.0.1:7890')),
1069
+ noProxy: volatile(z.string().default('localhost,127.0.0.1,::1')),
1070
+ systemPollMs: z.number().default(30000).min(0), // 只在 apply 时读,故意不标
1071
+ })
1072
+ ```
1073
+
1074
+ 配套(文档给的契约,`cordis` README「Volatile configuration」+ `dsh-settings` README):
1075
+
1076
+ - 解析后的 volatile 字段是 **`Volatile<T>` 引用**,live 值用 **`.get()`** 读
1077
+ (内核实例:`this.config.enabled.get()`);旧内核/未标注时是普通值,两种都要能吃。
1078
+ - 仅 volatile 的改动由 Loader **就地提交**并用 **`loader/volatile-update`** 通知本 fiber
1079
+ (`ctx.on('loader/volatile-update', …)`)——不需要重启、也不会重新 apply。
1080
+ - entry id(= 设置命名空间)直接读 **`ctx.fiber.entry?.options.id`**
1081
+ (内核实例:dsh-experimental-speech-to-text),不必去 configEditor 里扫。
1082
+ - **自带设置页**的插件要在 `ctx.inject(['settings'], child => …)` 里
1083
+ `configure({ auto: false }, ctx.fiber)`,否则会多出一个自动生成的表单。
1084
+ - **前端不许静默回滚**:把宿主的错误原文显示出来(本次就是靠它一句话定位)。
1085
+
1086
+ **验证手法(不重启内核也能验)**:把 asar 里的 `schemastery`/`cosmokit` 抽到
1087
+ 工作区 node_modules,给 `dsh-tools`/`dsh-http-proxy` 建桩,然后 import 插件真实模块,
1088
+ 用内核同款 `volatileForm`/`isVolatilePath` 逻辑断言(`_core_probe/verify-config.mjs`),
1089
+ 结果必须 PASS——比"重启了看看"快得多。
1090
+
1091
+ ## 37. 流程教训:先读包内 README,再逆向代码(2026-10-08)
1092
+
1093
+ 这一轮我一直在 asar 里逆向 lib 代码推规则,绕了远路。其实**每个 `@deepseek-ai` 包都带
1094
+ README.md / README.zh.md**,而且关键契约都写在里面:
1095
+
1096
+ - `dsh-app-boot/README.md`:peer 兼容判定("Before a profile imports a plugin, DSH checks
1097
+ its peerDependencies on @deepseek-ai/dsh and @deepseek-ai/dsh-* against the single runtime
1098
+ version returned by getDshRuntimeVersion()… missing DSH peers impose no constraint;
1099
+ invalid ranges are incompatible")、denied row/bundle 行为、`compatibility.json` 豁免。
1100
+ - `cordis/README.md`:volatile 引用契约(`.get()` + `loader/volatile-update`)。
1101
+ - `dsh-settings/README.md`:「字段必须 `.volatile()`」「表单按 profile entry id 识别」
1102
+ 「业务插件直接读自己的 Config 引用」「自带页面 `configure({auto:false})`」。
1103
+
1104
+ **做法**:`node _asar_probe.mjs <app.asar> list 'README\.md$'` 找文档 →
1105
+ `cat` 抽出来读;`_asar_grep.mjs` 按字符串定位到具体包再读那一段。
1106
+ 本会话早先可用的 `cordis-plugin-development` 技能当时已下线(调用报 unknown),
1107
+ 所以**包内文档是这版内核唯一可靠的作者指南**。
1108
+
1109
+ ## 38. 写/移植插件的第一步:`cordis_inspect_*`(2026-10-08)
1110
+
1111
+ **这是内核内置的权威契约查询能力**,比读 README 更精确、比逆向 lib 代码快一个数量级。
1112
+ `cordis_inspect_list` 列出全部 Provider,`cordis_inspect_query` 按 platform+provider+method 查:
1113
+
1114
+ | platform | provider | method | 拿到什么 |
1115
+ | --- | --- | --- | --- |
1116
+ | host | Service | `listService` | 省略 `service` → 紧凑目录;给定 → **精确方法签名 + 引用类型声明** |
1117
+ | host | Event | `listEvents` | 省略 `event` → 事件目录(名称/mode/签名);给定 → 单个事件契约 |
1118
+ | host | Config | `listConfigs` | 按 `name`/`entry` 查活条目目录或某条的**投影 JSON Schema**(volatile 一目了然) |
1119
+ | host | Tool | `listTools` | 当前 Agent 可调用的全部工具 schema |
1120
+ | client | Service / Event / Builtin / Slots / Theme | `listService` / `listEvents` / `listBuiltins` / `listSubTree` / `listTokens` | 客户端可注入服务、事件、可用内建符号、**实时槽位拓扑与注册契约**、主题 token |
1121
+
1122
+ **这一轮用它验到的事实**(全部与本插件的改法对得上):
1123
+
1124
+ - `settings`:`configure(presentation, owner = this.ctx.fiber)`、`update(ns, patch, expectedRevision?)`、
1125
+ `describe()`、`replace`、`mutate`;`ns` 明确是 **Profile entry id**。
1126
+ - `webServer.register({kind:'exact'|'prefix', path, handler})`;服务描述里写明
1127
+ 「启动期间未被认领的请求由 fallback 回 **404**,直到其 owner 注册」——正是我们那次 404 的机制。
1128
+ - **`loader/volatile-update` 不在 Event 目录里**(它是 cordis loader 的内部事件,只在本 fiber 上 emit);
1129
+ 目录在册的是 **`settings/document-updated(ns, revision)`**("One profile entry's form values,
1130
+ availability, or page policy changed")。两个都监听最稳,但**备案写法是后者**。
1131
+ - client 可注入服务目录(本组合):`layout / locale / sessions / slots / theme / timer /
1132
+ uiWorkspace / workspaces` —— **`settingsScope` 根本不存在**,`configForms` 也不在第三方可注入目录里。
1133
+ 所以"客户端只 inject `slots`、配置读写走插件自己的 HTTP API"是正确选择。
1134
+ - `Config` 目录里第三方条目形如 `{id: "include:dsh-mcp-manager", patchId: "dsh-mcp-manager"}`:
1135
+ **entry id 是加载器条目 id(可能带 `include:` 前缀)**,所以要用 `ctx.fiber.entry?.options.id`
1136
+ 取,而不是自己拼包名。
1137
+
1138
+ **顺序**:写/移植插件前 → `cordis_inspect_list` → 需要什么查什么(Service 签名 / Event 签名 /
1139
+ Config schema / Slots 契约)→ 再动手。**先查再写,不要先写再猜。**
1140
+
1141
+ ## 39. 客户端的两个贡献点必须在同一份状态上(2026-10-08)
1142
+
1143
+ **现象**:写入已经成功(POST /config → 200,profile patch 文件也被改写),但**头部胶囊显示
1144
+ 「代理 · ON」而设置面板显示「代理未启用」**——同一时刻、同一页面,两处结论相反。
1145
+
1146
+ **两个独立原因,都要治**:
1147
+
1148
+ 1. **状态被复制**:胶囊注册在 `conversation.session.header.utilities`,面板注册在
1149
+ `settings.section`——**设置外壳自己渲染一个 React root**,两棵树。原先 `apply()` 里
1150
+ `createProxyStore()` 每次 apply 建一份 store,于是两棵树各看各的快照。
1151
+ → 改为**模块级单例**(`proxyStore()` 惰性创建一份),所有贡献点共用同一状态。
1152
+ **通用规则**:客户端半边的状态 store 一律做成模块级单例,不要放在 `apply()` 里。
1153
+ 2. **乐观值卡死**:胶囊点击后置 `optimistic=next`,原逻辑只在
1154
+ `settings.value.enabled === optimistic` 时才清除——**宿主拒写时这个条件永远不成立,
1155
+ 胶囊就永久停在 ON**。
1156
+ → 改为**写完就结算**:`store.setEnabled(next).then(清乐观, 清乐观)`,
1157
+ 成功与否都以宿主答复为准(失败回滚 + 面板显示错误原文)。
1158
+
1159
+ **顺带记住的排查手法**:页面自相矛盾时,先打宿主接口拿真值
1160
+ (`GET /config`)+ 看落盘文件(profile `cordis.patch.yml` 的 mtime 与内容),
1161
+ 就能立刻区分"写没成功"和"前端状态不一致"——这次一查就知道**写入早就成功了**。
1162
+
1163
+ ## 40. 自查(code-review)抓到的问题 + 编码红线(2026-10-08)
1164
+
1165
+ **🔴 渲染风暴(真 bug,会一直打接口)**:`useProxySettings` 把
1166
+ `store.refresh()` 写在 `subscribe` 回调里,而 subscribe 是**每次渲染新建的闭包**:
1167
+ 渲染 → React 因 identity 变化重订阅 → subscribe 里 fetch `/config` → 响应 emit 新快照对象
1168
+ → 再渲染 → 无限循环(每轮一次 HTTP 请求)。
1169
+
1170
+ 修法三条一起上:
1171
+ 1. `subscribe`/`getSnapshot` 用 **`useCallback`** 固定 identity(依赖 `[store]`);
1172
+ 2. **首次加载挪到 `useEffect(() => { store.refresh() }, [store])`**,subscribe 只负责订阅;
1173
+ 3. store 的 `emit()` 加**同值短路**(比较 status/writable/error/JSON.stringify(value)),
1174
+ 值没变就不通知——订阅者不该因为"又读了一遍同样的数据"而重渲染。
1175
+
1176
+ **🟢 语义修正**:`configPayload(resolved, writable)` 增加可写标志,由宿主按
1177
+ 「有 settings 服务且能 update/register」计算;只读 composition 不该在面板上给出可点的开关
1178
+ (点了必然 503)。
1179
+
1180
+ **🔴 编码红线(我自己犯的)**:用 PowerShell `Get-Content -Raw | Set-Content` 回写
1181
+ `lib/index.js` 时,中文与破折号被读成系统代码页再以 UTF-8 写回,**全文变乱码**(`鈥?`)——
1182
+ 与 §29 的 `Out-File` BOM 坑同源。
1183
+
1184
+ - **规则:源码文件只用 `edit`/`write` 工具改;绝不用 shell 命令做文本往返。**
1185
+ - 需要批量替换时,先 `git status` 记住改动,出事后 `git checkout -- <file>` 回滚再用 edit 重做
1186
+ (本次就是这么恢复的,回滚前先确认坏文件没有未提交的独有改动)。
1187
+ - 事后用乱码特征串(`鈥|锛|鐨|涓|鏄`)全仓扫一遍确认干净。
1188
+
1189
+ **护栏**:`test/manifest-compat.test.mjs` 新增
1190
+ 「the client store never refreshes from inside subscribe」——静态断言 subscribe 体内无
1191
+ `refresh()`、首刷在 `useEffect`、`useCallback` 存在、`emit` 有同值短路。
1192
+
1193
+ ## 41. 版本语义与 npm 管理操作(用户定案,2026-10-08)
1194
+
1195
+ **版本约定:插件大版本 = dsh 的代际。**
1196
+
1197
+ | 插件 | 服务的 dsh 代际 | 说明 |
1198
+ | --- | --- | --- |
1199
+ | 1.0.x | dsh 0.1.x(`0.1.5-rc.2` 时代) | 1.0.10 是那一代的稳定版 |
1200
+ | 2.0.x | dsh 0.2.x(`0.2.0-rc.2`) | 设置模型/客户端传输全变,属于破坏性适配 |
1201
+
1202
+ - 因此 2.x 的 `peerDependencies` **收窄**为 `">=0.2.0-rc.2 <0.3.0"`——只声明真正验证过的代际,
1203
+ 不再假装支持 0.1(代码里的 legacy `settings.register` 分支作为防御性兜底保留,但不再承诺)。
1204
+ - 1.1.0 是一次失败的中间版本(只有 peer 放宽、缺 volatile/webServer/客户端修复):
1205
+ **不要把它当成 2.x 的前身**,直接以 2.0.1 作为 0.2 代的首发。
1206
+
1207
+ **npm 管理操作(deprecate / unpublish)必须在 CI 里做**:
1208
+
1209
+ - deprecate / unpublish 都是**对 registry 的认证写操作**,本地被 SWG 挡(§32)——
1210
+ 所以用 `.github/workflows/npm-admin.yml`(`workflow_dispatch`,输入 operation/target/message/otp),
1211
+ 在云端 runner 上执行。
1212
+ - **优先 deprecate**(npm 官方建议):版本仍可被已有 lockfile 安装,但任何人解析它都会看到警告。
1213
+ 命令:`npm deprecate '<pkg>@<ver>' '<原因>'`。
1214
+ - **unpublish 有时间窗**:npm 只允许在发布后 **72 小时内**、且**没有依赖者**时删除,且删除后
1215
+ 该版本号不可复用。仅用于"根本没人可能装过"的事故版本。
1216
+ - 两者都可能要求 2FA:granular token 通常免 OTP;若被要求,把一次性码填进 `otp` 输入,
1217
+ 或直接在 npmjs 网页(版本页 → Delete)操作。
1218
+
1219
+ ## 42. 脱离公司网时的发布/升级路径(2026-10-08 实测)
1220
+
1221
+ **环境特征**(判断当前处在哪种网络,决定走哪条路):
1222
+
1223
+ | 探测 | 在公司网 | 脱离公司网(本轮实测) |
1224
+ | --- | --- | --- |
1225
+ | 系统代理 | 开(proxyhk 等) | **关**(`ProxyEnable=0`) |
1226
+ | 华为内网镜像 `mirrors.tools.huawei.com` | 通 | **ENOTFOUND** |
1227
+ | registry.npmjs.org / registry.npmmirror.com | 直连被 SWG 拦(§32) | **直连可达** |
1228
+ | github.com | 经代理可推 | 直连不稳(IWR 超时,但 `git push` 常能过) |
1229
+
1230
+ **本轮实际路径(都能用,按可用性选)**:
1231
+
1232
+ 1. **发包**:本地直连 `npm publish --registry=https://registry.npmjs.org` → 返回
1233
+ "Your package is being processed"(staged),**约 2 分钟后**才在 registry 可见——别以为失败,
1234
+ 要轮询 `dist-tags`/`versions` 确认。
1235
+ 2. **淘宝同步**:手动 `PUT https://registry.npmmirror.com/-/sync?name=%40scope%2Fname` → 201。
1236
+ 3. **deprecate**:本地可做;**unpublish 不行**——免 2FA 的 granular token 被 npm 拒绝
1237
+ (`Granular access tokens that bypass two-factor authentication may not perform this action`),
1238
+ 删除只能走网页 2FA 或带 OTP 的会话。
1239
+ 4. **profile 升级**:内网镜像不可达时必须显式换源——
1240
+ `pnpm add '<pkg>@<ver>' --registry=https://registry.npmmirror.com`;
1241
+ `plugin_manager install_bundle` 同样支持 `registry` 入参(用它还能顺手把 bundles 组合改对)。
1242
+ 5. **CI 仍可用**:tag 推上去即可;发布步骤已做成幂等(版本已存在就跳过),
1243
+ 所以"先本地发、后补 tag"不会报红。
1244
+
1245
+ **验证工具(本轮新增,可复用)**:
1246
+
1247
+ - `_core_probe/scan-mojibake.mjs`——ASCII 源码(`\u` 转义)+ 按**相对路径**跳过 node_modules,
1248
+ 扫真实乱码;注意 PowerShell 里传 CJK 正则本身可能被编码破坏,**别用它做乱码探测**。
1249
+ - `_core_probe/scan-workflows.mjs`——现成捕获"未加引号的 `: ` 值"这类会让 GitHub
1250
+ **在零 job 层面拒掉整个 workflow 文件**的错误(现象:出现以文件名为名的失败 run)。
1251
+ - 读文件一律用 `read` 工具或 Node `readFileSync(..., 'utf8')`;`Get-Content` 的显示不等于文件内容。
package/lib/client.js CHANGED
@@ -35,6 +35,7 @@ window.__ModuleLoader__.load({
35
35
  var react = require("react");
36
36
  var useState = react.useState;
37
37
  var useEffect = react.useEffect;
38
+ var useCallback = react.useCallback;
38
39
  var useRef = react.useRef;
39
40
  var useSyncExternalStore = react.useSyncExternalStore;
40
41
 
@@ -115,22 +116,51 @@ window.__ModuleLoader__.load({
115
116
  // scope (`{ status, value, writable }`), so the render code below is
116
117
  // unchanged; an older host simply has no /config route and the panel
117
118
  // reports itself unavailable instead of crashing.
119
+ //
120
+ // ONE store for the whole client half: the header capsule and the settings
121
+ // page live in different React trees (the settings shell renders its own
122
+ // root), and per-apply stores left them showing different snapshots —
123
+ // "header ON but panel OFF" (LESSONS §39). A module-level singleton makes
124
+ // every contributor observe exactly one state.
125
+ var sharedProxyStore = null
126
+
127
+ function proxyStore() {
128
+ if (sharedProxyStore === null) sharedProxyStore = createProxyStore()
129
+ return sharedProxyStore
130
+ }
131
+
118
132
  function createProxyStore() {
119
133
  var listeners = [];
120
- var snapshot = { status: "loading", value: undefined, writable: false };
134
+ var snapshot = { status: "loading", value: undefined, writable: false, error: "" };
121
135
  var inflight = null;
136
+ /** Cheap equality so an unchanged payload does not notify: every emit
137
+ * re-renders its subscribers, and a needless emit inside a render path
138
+ * is how a store turns into a request storm (review fix, LESSONS §40). */
139
+ function unchanged(a, b) {
140
+ return a.status === b.status
141
+ && a.writable === b.writable
142
+ && a.error === b.error
143
+ && JSON.stringify(a.value) === JSON.stringify(b.value);
144
+ }
122
145
  function emit(next) {
146
+ if (unchanged(snapshot, next)) return;
123
147
  snapshot = next;
124
148
  listeners.slice().forEach(function (fn) {
125
149
  try { fn(); } catch (error) { console.error("[dsh-proxy-pro] listener failed:", error); }
126
150
  });
127
151
  }
152
+ /** Keep the last known state but surface why a write was refused: a
153
+ * silent rollback is how a rejected settings write looks like a UI bug
154
+ * (LESSONS §36 — "点一下马上弹回"). */
155
+ function fail(message) {
156
+ emit({ status: snapshot.status, value: snapshot.value, writable: snapshot.writable, error: message });
157
+ }
128
158
  function accept(data) {
129
159
  if (data === null || data.ok !== true || data.config === undefined || data.config === null) {
130
- emit({ status: "unavailable", value: undefined, writable: false });
160
+ emit({ status: "unavailable", value: undefined, writable: false, error: (data && data.error) || "" });
131
161
  return false;
132
162
  }
133
- emit({ status: "ready", value: data.config, writable: data.writable !== false });
163
+ emit({ status: "ready", value: data.config, writable: data.writable !== false, error: "" });
134
164
  return true;
135
165
  }
136
166
  function refresh() {
@@ -151,9 +181,14 @@ window.__ModuleLoader__.load({
151
181
  body: JSON.stringify(body),
152
182
  }).then(function (data) {
153
183
  if (data === null || data.ok !== true) {
154
- throw new Error((data && data.error) || "config write failed");
184
+ var message = (data && data.error) || "配置写入失败(宿主未返回原因)";
185
+ fail(message);
186
+ throw new Error(message);
155
187
  }
156
188
  accept(data);
189
+ }, function (error) {
190
+ fail("无法连接宿主配置接口:" + String(error && error.message ? error.message : error));
191
+ throw error;
157
192
  });
158
193
  }
159
194
  return {
@@ -171,22 +206,23 @@ window.__ModuleLoader__.load({
171
206
  };
172
207
  }
173
208
 
174
- /** Subscribe to the host-backed config store → `{ value, writable, ready }`. */
209
+ /** Subscribe to the host-backed config store → `{ value, writable, ready }`.
210
+ * The subscribe/getSnapshot callbacks are identity-stable (`useCallback`)
211
+ * and the first load lives in an effect: calling `refresh()` from inside
212
+ * subscribe re-subscribed on every render and looped GET /config forever
213
+ * (review fix, LESSONS §40). */
175
214
  function useProxySettings(store) {
176
- var snapshot = useSyncExternalStore(
177
- function (onChange) {
178
- var unsubscribe = store.subscribe(onChange);
179
- store.refresh();
180
- return unsubscribe;
181
- },
182
- function () { return store.getSnapshot(); }
183
- );
215
+ var subscribe = useCallback(function (onChange) { return store.subscribe(onChange); }, [store]);
216
+ var getSnapshot = useCallback(function () { return store.getSnapshot(); }, [store]);
217
+ var snapshot = useSyncExternalStore(subscribe, getSnapshot);
218
+ useEffect(function () { store.refresh(); }, [store]);
184
219
  var ready = snapshot.status === "ready" && snapshot.value !== undefined;
185
220
  return {
186
221
  value: ready ? snapshot.value : undefined,
187
222
  writable: snapshot.writable === true && ready,
188
223
  ready: snapshot.status === "unavailable" ? false : snapshot.status === "ready",
189
224
  unavailable: snapshot.status === "unavailable",
225
+ error: snapshot.error || "",
190
226
  };
191
227
  }
192
228
 
@@ -269,9 +305,14 @@ window.__ModuleLoader__.load({
269
305
  if (!settings.writable) return;
270
306
  var next = !shownEnabled;
271
307
  setOptimistic(next);
272
- store.setEnabled(next).catch(function (error) {
308
+ // The shared store receives the host's answer for this write, so the
309
+ // optimistic echo ends as soon as the round trip settles — success or
310
+ // failure. Waiting for "value === optimistic" left the capsule stuck ON
311
+ // whenever the host refused the write (LESSONS §39).
312
+ store.setEnabled(next).then(function () {
313
+ setOptimistic(null);
314
+ }, function (error) {
273
315
  console.error("[dsh-proxy-pro] toggle failed:", error);
274
- // Roll back to the committed value on failure.
275
316
  setOptimistic(null);
276
317
  });
277
318
  // Toggle doubles as a manual "recheck now" even when the settings
@@ -285,6 +326,7 @@ window.__ModuleLoader__.load({
285
326
  var title = active
286
327
  ? "代理已开启(界面开关)· 点击关闭"
287
328
  : "代理已关闭 · 点击开启";
329
+ if (settings.error) title = title + "\n最近一次写入失败:" + settings.error;
288
330
  return react.createElement(
289
331
  "button",
290
332
  {
@@ -346,8 +388,10 @@ window.__ModuleLoader__.load({
346
388
  // Address is configuration-driven: custom mode shows the custom URL
347
389
  // immediately (no stale host snapshot), system mode shows the URL the
348
390
  // host read from Windows.
391
+ // 配置输入框保留原始认证信息;状态文字不展示用户名或密码。
392
+ var publicCustomUrl = (value.customUrl || "").replace(/^(https?:\/\/)[^/?#]*@/i, "$1");
349
393
  var shownUrl = modeNow === "custom"
350
- ? (value.customUrl || "—")
394
+ ? (publicCustomUrl || "—")
351
395
  : ((status && status.url) || "—");
352
396
  var statusLine = cfgActive
353
397
  ? "代理生效中 · " + shownUrl
@@ -355,7 +399,7 @@ window.__ModuleLoader__.load({
355
399
  var subLine = "";
356
400
  if (cfgActive) {
357
401
  if (modeNow === "custom") {
358
- subLine = "自定义地址(立即生效)" + (status && status.url && status.url !== value.customUrl ? " · 已应用: " + status.url : "");
402
+ subLine = "自定义地址(立即生效)" + (status && status.url && status.url !== publicCustomUrl ? " · 已应用: " + status.url : "");
359
403
  } else if (status !== null && status !== undefined) {
360
404
  subLine = status.active === true
361
405
  ? "跟随 Windows 系统代理(自动刷新)"
@@ -429,6 +473,15 @@ window.__ModuleLoader__.load({
429
473
  }))
430
474
  ),
431
475
 
476
+ // --- last write failure (never fail silently: LESSONS §36) ---
477
+ settings.error
478
+ ? react.createElement("div", { className: "dsxprx-status", "data-active": "false", "data-proxy-error": true },
479
+ react.createElement("span", { className: "dsxprx-dot" }),
480
+ react.createElement("div", { className: "dsxprx-statusbody" },
481
+ react.createElement("span", { className: "dsxprx-statusline" }, "配置写入失败"),
482
+ react.createElement("span", { className: "dsxprx-subline" }, settings.error)))
483
+ : null,
484
+
432
485
  // --- mode ---
433
486
  react.createElement("div", { className: "dsxprx-field" },
434
487
  react.createElement("label", { htmlFor: "dsxprx-mode" }, "代理地址来源"),
@@ -533,7 +586,7 @@ window.__ModuleLoader__.load({
533
586
  * @param ctx - client cordis context.
534
587
  */
535
588
  function apply(ctx) {
536
- var store = createProxyStore();
589
+ var store = proxyStore();
537
590
  ctx.effect(function () { return function () { /* no timers to clear */ }; }, "dsh-proxy-pro: config store");
538
591
  ctx.slots.inject("settings.section", function () {
539
592
  return ctx.slots.register({
@@ -570,4 +623,4 @@ window.__ModuleLoader__.load({
570
623
  ];
571
624
  return module.exports;
572
625
  },
573
- });
626
+ });
package/lib/index.js CHANGED
@@ -34,13 +34,13 @@ import {
34
34
  classifyTargetFailure,
35
35
  combineDualProbe,
36
36
  configPayload,
37
- DEFAULT_ENTRY_ID,
38
- discoverEntryId,
39
37
  headNeedsGetRetry,
40
38
  keepaliveHint,
41
39
  makeSystemProxyReader,
42
40
  normalizeProxyUrl,
43
- readEntryConfig,
41
+ readProxyConfig,
42
+ redactProxyUrl,
43
+ resolveEntryId,
44
44
  resolveProxyState,
45
45
  summarize,
46
46
  systemFactsEqual,
@@ -73,14 +73,30 @@ const SECTION_ORDER = 55
73
73
  /** Base path for the status HTTP API (client polls this). */
74
74
  const ROUTE_BASE = '/dsh-proxy-pro/api'
75
75
 
76
+ /**
77
+ * Mark one Config field live-editable.
78
+ *
79
+ * dsh >= 0.2 only accepts a settings write whose paths sit under a field whose
80
+ * schema node carries `meta.volatile` (`volatileForm`/`isVolatilePath` in
81
+ * dsh-settings); an unmarked schema answers `Plugin entry "…" has no volatile
82
+ * fields` and every toggle bounces straight back (LESSONS §36). The guard keeps
83
+ * the definition loadable on older schemastery builds without `.volatile()`.
84
+ *
85
+ * @param field - a schemastery field.
86
+ * @returns the same field, marked volatile when the API exists.
87
+ */
88
+ const volatile = (field) => (typeof field?.volatile === 'function' ? field.volatile() : field)
89
+
76
90
  /** Composition-row configuration (also the settings schema). */
77
91
  const Config = z.object({
78
- enabled: z.boolean().default(false),
92
+ enabled: volatile(z.boolean().default(false)),
79
93
  // 'none' kept only for parsing pre-removal settings.yaml values; the UI and
80
94
  // proxy_config never produce it anymore (LESSONS §17.3e).
81
- mode: z.union([z.const('system'), z.const('custom'), z.const('none')]).default('system'),
82
- customUrl: z.string().default('http://127.0.0.1:7890'),
83
- noProxy: z.string().default('localhost,127.0.0.1,::1'),
95
+ mode: volatile(z.union([z.const('system'), z.const('custom'), z.const('none')]).default('system')),
96
+ customUrl: volatile(z.string().default('http://127.0.0.1:7890')),
97
+ noProxy: volatile(z.string().default('localhost,127.0.0.1,::1')),
98
+ // Deliberately NOT volatile: the poll interval is read once at apply time,
99
+ // so promising a live edit here would be a lie. It stays a composition knob.
84
100
  systemPollMs: z.number().default(30000).min(0),
85
101
  })
86
102
 
@@ -118,7 +134,8 @@ function makeEnvLike(effective) {
118
134
  }
119
135
  }
120
136
 
121
- /** Build the agent-facing status text from the latest sync snapshot. */function renderStatusText(snapshot) {
137
+ /** Build the agent-facing status text from the latest sync snapshot. */
138
+ function renderStatusText(snapshot) {
122
139
  const effective = snapshot?.effective
123
140
  const config = snapshot?.config
124
141
  if (effective === undefined || config === undefined) {
@@ -145,7 +162,7 @@ function makeEnvLike(effective) {
145
162
  return [
146
163
  '## Proxy status: ON',
147
164
  '',
148
- `The whole runtime (model requests, web_search/web_fetch, and all spawned tools) is routed through the proxy: ${effective.url} (${source}).`,
165
+ `The whole runtime (model requests, web_search/web_fetch, and all spawned tools) is routed through the proxy: ${redactProxyUrl(effective.url)} (${source}).`,
149
166
  '',
150
167
  `Bypass list (NO_PROXY, traffic goes direct): ${effective.noProxy || '(none)'}.`,
151
168
  '',
@@ -294,7 +311,7 @@ async function probeTarget(url, snapshot, channel = 'dual') {
294
311
  let routeName = 'DIRECT'
295
312
  try {
296
313
  const routeProbe = proxyRouteFor(parsed)
297
- routeName = routeProbe.proxied ? `PROXIED ${routeProbe.proxy}` : 'DIRECT'
314
+ routeName = routeProbe.proxied ? `PROXIED ${redactProxyUrl(routeProbe.proxy)}` : 'DIRECT'
298
315
  } catch {
299
316
  // proxyRouteFor unavailable in this composition → rely on state snapshot
300
317
  }
@@ -346,11 +363,11 @@ async function probeTarget(url, snapshot, channel = 'dual') {
346
363
  // dsh-skill-manager ships (its lib/index.js L131-172), matching the loopback
347
364
  // verdict of the official fence with zero extra dependencies.
348
365
 
349
- /** Normalized URL of a Host-header authority (hostname lowercased), or undefined. */
366
+ /** 解析请求所访问的 profile 地址,保留端口以区分并行运行的 profile。 */
350
367
  function parseHostHeader(host) {
351
368
  if (typeof host !== 'string' || !host) return undefined
352
369
  try {
353
- return new URL(`http://${host}`).hostname.toLowerCase()
370
+ return new URL(`http://${host}`)
354
371
  } catch {
355
372
  return undefined
356
373
  }
@@ -367,14 +384,16 @@ function isLoopbackHostname(hostname) {
367
384
  /** True when the Host is loopback and any attached browser markers are same-origin. */
368
385
  function isTrustedPanelRequest(headers) {
369
386
  const host = typeof headers?.get === 'function' ? headers.get('host') : headers?.host
370
- const hostname = parseHostHeader(host)
371
- if (!hostname || !isLoopbackHostname(hostname)) return false
387
+ const hostUrl = parseHostHeader(host)
388
+ if (!hostUrl || !isLoopbackHostname(hostUrl.hostname)) return false
372
389
  if (headers?.get && headers.get('sec-fetch-site') === 'cross-site') return false
373
390
  if (!headers?.get && headers?.['sec-fetch-site'] === 'cross-site') return false
374
391
  const originRaw = typeof headers?.get === 'function' ? headers.get('origin') : headers?.origin
375
392
  if (originRaw === undefined || originRaw === null || originRaw === '') return true
376
393
  try {
377
- return new URL(originRaw).hostname.toLowerCase() === hostname
394
+ // 与 DSH Connection 的校验保持一致:各 profile 只接受自己的 host:port。
395
+ // Desktop 会把 dsh-app://app 改写为目标 Host 的 origin;无 Origin 的本地 CLI 仍兼容。
396
+ return new URL(originRaw).host === hostUrl.host
378
397
  } catch {
379
398
  return false
380
399
  }
@@ -397,7 +416,7 @@ function denyIfUntrusted(req, res) {
397
416
  * @param ctx - registrant context.
398
417
  * @param config - validated composition configuration.
399
418
  */
400
- function apply(ctx, config) {
419
+ async function apply(ctx, config) {
401
420
  const systemProxyReader = makeSystemProxyReader(execFileAsync)
402
421
  let source = () => config
403
422
  let snapshot = undefined
@@ -486,12 +505,13 @@ function apply(ctx, config) {
486
505
  // service (register(ns, schema, {base}) → scope.get()/watch()).
487
506
  // - dsh >= 0.2: register()/watch() are gone. The plugin's OWN composition
488
507
  // entry IS the settings form (namespace = entry id, schema = this Config),
489
- // writes go through settings.update(entryId, patch) and every commit
490
- // emits `settings/document-updated`, which is what re-syncs us live.
508
+ // writes go through settings.update(entryId, patch), the live values are
509
+ // the Config's volatile references, and Loader announces a volatile-only
510
+ // commit with `loader/volatile-update`.
491
511
  const settingsService = ctx.get('settings')
492
512
  const configEditor = ctx.get('configEditor')
493
513
  const legacyRegister = typeof settingsService?.register === 'function'
494
- const entryId = discoverEntryId(configEditor, ctx) ?? DEFAULT_ENTRY_ID
514
+ const entryId = resolveEntryId(ctx, configEditor)
495
515
  if (legacyRegister) {
496
516
  const proxyScope = settingsService.register(PROXY_NS, Config, { base: config })
497
517
  source = () => proxyScope.get()
@@ -499,17 +519,47 @@ function apply(ctx, config) {
499
519
  void requestSync().catch(() => {})
500
520
  })
501
521
  } else if (typeof settingsService?.update === 'function') {
502
- source = () => readEntryConfig(configEditor, entryId, config)
522
+ // Documented volatile contract (cordis README, dsh-settings README, and the
523
+ // host Service/Event Inspect providers — LESSONS §36/§38): resolved
524
+ // `.volatile()` fields are `Volatile<T>` references read through `.get()`,
525
+ // and a volatile-only edit is committed into that same reference. The
526
+ // catalogued notification is `settings/document-updated(ns, revision)`; the
527
+ // loader also emits its internal `loader/volatile-update` on this fiber, so
528
+ // both are observed (sync is idempotent).
529
+ source = () => readProxyConfig(config)
503
530
  ctx.on('settings/document-updated', (ns) => {
504
531
  if (ns === entryId) void requestSync().catch(() => {})
505
532
  })
533
+ ctx.on('loader/volatile-update', () => {
534
+ void requestSync().catch(() => {})
535
+ })
536
+ }
537
+ // We ship our own settings page (代理配置), so opt out of the auto-generated
538
+ // form — the documented pattern is an effect inside an injected child, so a
539
+ // late-loading Settings service still picks the policy up.
540
+ if (typeof ctx.inject === 'function') {
541
+ ctx.inject(['settings'], (child) => {
542
+ child.effect(() => {
543
+ try {
544
+ return child.settings.configure({ auto: false }, ctx.fiber)
545
+ } catch (error) {
546
+ ctx.logger.warn('dsh-proxy-pro: settings page policy not applied: %s', String(error))
547
+ return () => {}
548
+ }
549
+ }, 'dsh-proxy-pro: settings page policy')
550
+ })
506
551
  }
507
552
  // Without either generation the plugin still runs read-only on the
508
553
  // composition config (source keeps returning it).
509
554
 
555
+ /** Whether this composition can persist a configuration write at all: a
556
+ * read-only composition must not offer enabled controls in the panel. */
557
+ const settingsWritable = settingsService !== undefined
558
+ && (legacyRegister || typeof settingsService.update === 'function')
559
+
510
560
  /** Persist one config patch through whichever settings generation is present. */
511
561
  const writeSettings = async (patch) => {
512
- if (settingsService === undefined) {
562
+ if (!settingsWritable) {
513
563
  throw new Error('the settings service is unavailable in this composition — change the proxy configuration in the composition instead')
514
564
  }
515
565
  await settingsService.update(legacyRegister ? PROXY_NS : entryId, patch)
@@ -633,7 +683,7 @@ function apply(ctx, config) {
633
683
  } else {
634
684
  const normalized = normalizeProxyUrl(raw)
635
685
  if (normalized === null) {
636
- throw new Error(`proxy_config: "${args.customUrl}" is not a usable proxy address — expected http(s)://host:port (a bare host:port is also accepted)`)
686
+ throw new Error('proxy_config: invalid proxy address — expected http(s)://host:port (a bare host:port is also accepted)')
637
687
  }
638
688
  patch.customUrl = normalized
639
689
  }
@@ -684,8 +734,20 @@ function apply(ctx, config) {
684
734
  }
685
735
  }
686
736
 
687
- const webServer = ctx.get('webServer')
688
- if (webServer !== undefined) {
737
+ /**
738
+ * Register the status/config HTTP API on the web server.
739
+ *
740
+ * dsh 0.2 mounts `webServer` AFTER plugin rows have applied, so reading
741
+ * `ctx.get('webServer')` once inside apply() returned undefined and this
742
+ * whole block was skipped silently — the panel then reported "宿主未提供
743
+ * 配置接口" while everything else looked healthy (LESSONS §35). The 0.2
744
+ * idiom is `ctx.inject([...], (child) => …)`: it runs as soon as the service
745
+ * exists (immediately when it already does). The direct read stays as the
746
+ * fallback for older cores.
747
+ */
748
+ let apiRegistered = false
749
+ const registerApi = (webServer) => {
750
+ if (apiRegistered || webServer === undefined || typeof webServer.register !== 'function') return false
689
751
  try {
690
752
  const unregisterRoute = webServer.register({
691
753
  kind: 'prefix',
@@ -738,7 +800,7 @@ function apply(ctx, config) {
738
800
  // the browser settings transport (`ctx.settingsScope`), so the panel
739
801
  // reads and writes through this API instead (LESSONS §33).
740
802
  if (is('/config', 'GET')) {
741
- sendJson(res, 200, { ok: true, ...configPayload(source()) })
803
+ sendJson(res, 200, { ok: true, ...configPayload(source(), settingsWritable) })
742
804
  return
743
805
  }
744
806
  if (is('/config', 'POST')) {
@@ -759,7 +821,7 @@ function apply(ctx, config) {
759
821
  } else {
760
822
  const normalized = normalizeProxyUrl(raw)
761
823
  if (normalized === null) {
762
- sendJson(res, 400, { ok: false, error: `"${body.customUrl}" is not a usable proxy address — expected http(s)://host:port` })
824
+ sendJson(res, 400, { ok: false, error: 'invalid proxy address — expected http(s)://host:port' })
763
825
  return
764
826
  }
765
827
  patch.customUrl = normalized
@@ -773,7 +835,7 @@ function apply(ctx, config) {
773
835
  return
774
836
  }
775
837
  await requestSync()
776
- sendJson(res, 200, { ok: true, ...configPayload(source()) })
838
+ sendJson(res, 200, { ok: true, ...configPayload(source(), settingsWritable) })
777
839
  return
778
840
  }
779
841
  sendJson(res, 404, { ok: false, error: 'not found' })
@@ -783,11 +845,22 @@ function apply(ctx, config) {
783
845
  },
784
846
  })
785
847
  ctx.effect(() => () => { try { unregisterRoute?.() } catch { /* best effort */ } }, 'dsh-proxy-pro: route cleanup')
848
+ apiRegistered = true
849
+ ctx.logger.info('dsh-proxy-pro: status API registered on %s', ROUTE_BASE)
850
+ return true
786
851
  } catch (error) {
787
852
  ctx.logger.warn('dsh-proxy-pro: webServer unavailable — status API disabled: %s', String(error))
853
+ return false
788
854
  }
789
855
  }
790
856
 
857
+ // Late-bound registration (dsh 0.2 mounts webServer after plugin rows).
858
+ if (typeof ctx.inject === 'function') {
859
+ ctx.inject(['webServer'], (childCtx) => { registerApi(childCtx.webServer) })
860
+ }
861
+ // Older cores where the service is already present at apply time.
862
+ registerApi(ctx.get('webServer'))
863
+
791
864
  // Follow the Windows system proxy at runtime (change-only re-apply).
792
865
  if (config.systemPollMs > 0) {
793
866
  pollTimer = setInterval(async () => {
@@ -827,6 +900,10 @@ function apply(ctx, config) {
827
900
  }
828
901
  }
829
902
  }, 'dsh-proxy-pro: cleanup')
903
+
904
+ // Loader 等待 apply 完成后再开放能力;首次模型请求前应用保存的配置。
905
+ // 先登记清理逻辑,以覆盖初始化期间卸载的情况。默认关闭时仍不碰现有传输。
906
+ await requestSync()
830
907
  }
831
908
 
832
- export { Config, apply, inject, name }
909
+ export { Config, apply, inject, name }
package/lib/proxy-core.js CHANGED
@@ -21,42 +21,67 @@ export const DEFAULT_ENTRY_ID = 'dsh-proxy-pro'
21
21
 
22
22
  /**
23
23
  * Resolve this plugin's composition entry id — the settings namespace on
24
- * dsh >= 0.2 (LESSONS §33). Prefers the row whose fiber IS this context, then
25
- * the known bundle id; undefined when the editor cannot be read.
24
+ * dsh >= 0.2 (LESSONS §33). Core plugins read it straight off the fiber
25
+ * (`ctx.fiber.entry?.options.id`, e.g. dsh-experimental-speech-to-text); the
26
+ * config-editor row match is the fallback, then the known bundle id.
26
27
  */
27
- export function discoverEntryId(configEditor, ctx) {
28
+ export function resolveEntryId(ctx, configEditor, fallback = DEFAULT_ENTRY_ID) {
29
+ const direct = ctx?.fiber?.entry?.options?.id
30
+ if (typeof direct === 'string' && direct.length > 0) return direct
28
31
  try {
29
32
  const rows = configEditor?.configuration?.() ?? []
30
33
  const own = rows.find((row) => row?.entry?.fiber === ctx?.fiber)
31
- ?? rows.find((row) => row?.entry?.options?.id === DEFAULT_ENTRY_ID)
34
+ ?? rows.find((row) => row?.entry?.options?.id === fallback)
32
35
  const id = own?.entry?.options?.id
33
- return typeof id === 'string' && id.length > 0 ? id : undefined
36
+ return typeof id === 'string' && id.length > 0 ? id : fallback
34
37
  } catch {
35
- return undefined
38
+ return fallback
36
39
  }
37
40
  }
38
41
 
39
42
  /**
40
- * Effective config of one entry: the composed layers, then the profile patch.
41
- * Every Config field is a scalar, so a shallow merge is exact.
43
+ * Read one resolved Config field.
44
+ *
45
+ * Cordis' documented volatile contract (vendor loader README, LESSONS §36):
46
+ * a field declared `.volatile()` resolves to a `Volatile<T>` **reference**
47
+ * whose live value is read with `.get()`; Loader commits volatile-only edits
48
+ * into that same reference. Older cores (and our `.volatile()` guard on old
49
+ * schemastery) hand back a plain value, so both shapes are accepted.
42
50
  */
43
- export function readEntryConfig(configEditor, entryId, fallback) {
44
- try {
45
- const rows = configEditor?.configuration?.() ?? []
46
- const own = rows.find((row) => row?.entry?.options?.id === entryId)
47
- if (own !== undefined) return { ...fallback, ...(own.inherited ?? {}), ...(own.override ?? {}) }
48
- } catch {
49
- /* fall through to the apply-time config */
51
+ export function readConfigField(config, field, fallback) {
52
+ const value = config?.[field]
53
+ if (value !== null && typeof value === 'object' && typeof value.get === 'function') {
54
+ try {
55
+ const live = value.get()
56
+ return live === undefined ? fallback : live
57
+ } catch {
58
+ return fallback
59
+ }
60
+ }
61
+ return value === undefined ? fallback : value
62
+ }
63
+
64
+ /** The plain, serializable configuration the tools/client/API consume. */
65
+ export function readProxyConfig(config) {
66
+ return {
67
+ enabled: readConfigField(config, 'enabled', false) === true,
68
+ mode: readConfigField(config, 'mode', 'none'),
69
+ customUrl: readConfigField(config, 'customUrl', ''),
70
+ noProxy: readConfigField(config, 'noProxy', ''),
71
+ systemPollMs: readConfigField(config, 'systemPollMs', 0),
50
72
  }
51
- return fallback
52
73
  }
53
74
 
54
75
  /**
55
76
  * Editable configuration payload for the client panel (`/config` API). The
56
77
  * browser cannot reach the settings transport on dsh >= 0.2, so the host hands
57
78
  * the panel exactly the fields it edits (LESSONS §33).
79
+ *
80
+ * @param resolved - the live configuration (see {@link readProxyConfig}).
81
+ * @param writable - whether this composition can persist a write at all; a
82
+ * read-only composition must not offer enabled controls (review fix).
58
83
  */
59
- export function configPayload(resolved) {
84
+ export function configPayload(resolved, writable = true) {
60
85
  return {
61
86
  config: {
62
87
  enabled: resolved?.enabled === true,
@@ -65,7 +90,7 @@ export function configPayload(resolved) {
65
90
  noProxy: resolved?.noProxy ?? '',
66
91
  systemPollMs: resolved?.systemPollMs ?? 0,
67
92
  },
68
- writable: true,
93
+ writable: writable === true,
69
94
  }
70
95
  }
71
96
 
@@ -85,6 +110,21 @@ export function normalizeProxyUrl(value) {
85
110
  return url.toString().replace(/\/+$/, '')
86
111
  }
87
112
 
113
+ /** 仅用于状态展示;传输层和可编辑配置继续使用完整的原始地址。 */
114
+ export function redactProxyUrl(value) {
115
+ if (typeof value !== 'string' || value === '') return ''
116
+ try {
117
+ const url = new URL(value)
118
+ if (!url.username && !url.password) return value
119
+ url.username = ''
120
+ url.password = ''
121
+ return url.toString().replace(/\/+$/, '')
122
+ } catch {
123
+ // 无效地址也不能把 user:password 原样带入错误提示。
124
+ return value.replace(/(https?:\/\/)[^\s/?#]*@/gi, '$1[redacted]@')
125
+ }
126
+ }
127
+
88
128
  /**
89
129
  * Parse a Windows `ProxyServer` string: single `host:port` (both protocols)
90
130
  * or `http=...;https=...` form; `socks=` ignored (undici speaks HTTP CONNECT).
@@ -299,7 +339,7 @@ export function summarize(snapshot) {
299
339
  active: effective?.active === true,
300
340
  enabled: config?.enabled === true,
301
341
  mode: config?.mode ?? 'none',
302
- url: effective?.url ?? '',
342
+ url: redactProxyUrl(effective?.url ?? ''),
303
343
  noProxy: effective?.noProxy ?? composeNoProxy(config?.noProxy),
304
344
  reason: effective?.reason ?? 'unknown',
305
345
  source: config?.mode === 'system' ? 'system' : config?.mode === 'custom' ? 'custom' : 'none',
@@ -317,7 +357,7 @@ export function summarize(snapshot) {
317
357
  export function keepaliveHint(message = '', proxyUrl = '') {
318
358
  const text = typeof message === 'string' ? message : ''
319
359
  if (!/407|NTLM|proxy authentication|authentication required/i.test(text)) return ''
320
- const example = typeof proxyUrl === 'string' && proxyUrl !== '' ? proxyUrl : 'http://localhost:7890'
360
+ const example = typeof proxyUrl === 'string' && proxyUrl !== '' ? redactProxyUrl(proxyUrl) : 'http://localhost:7890'
321
361
  return `该代理要求 NTLM 认证,undici 无法自行协商。最近一次 curl/浏览器到同一代理的连接会保持一段已认证窗口——先执行一次 \`curl -x ${example} -I https://github.com\`,然后重试。`
322
362
  }
323
363
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yanglaofish/dsh-proxy-pro",
3
- "version": "1.1.0",
3
+ "version": "2.0.2",
4
4
  "description": "DSH proxy manager: route model requests, web fetches and spawned tools through the Windows system proxy or a custom address; per-URL route diagnosis, one-click toggle from the conversation header, a dedicated Settings page, and keepalive-aware 407 hints.",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -40,10 +40,10 @@
40
40
  },
41
41
  "peerDependencies": {
42
42
  "@deepseek-ai/cordis": "^4.0.0",
43
- "@deepseek-ai/dsh-http-proxy": ">=0.1.5-rc.2 <0.3.0",
44
- "@deepseek-ai/dsh-settings": ">=0.1.5-rc.2 <0.3.0",
45
- "@deepseek-ai/dsh-system-prompt": ">=0.1.5-rc.2 <0.3.0",
46
- "@deepseek-ai/dsh-tools": ">=0.1.5-rc.2 <0.3.0",
43
+ "@deepseek-ai/dsh-http-proxy": ">=0.2.0-rc.2 <0.3.0",
44
+ "@deepseek-ai/dsh-settings": ">=0.2.0-rc.2 <0.3.0",
45
+ "@deepseek-ai/dsh-system-prompt": ">=0.2.0-rc.2 <0.3.0",
46
+ "@deepseek-ai/dsh-tools": ">=0.2.0-rc.2 <0.3.0",
47
47
  "@deepseek-ai/schemastery": "^3.18.0"
48
48
  },
49
49
  "dsh": {
@@ -53,10 +53,10 @@
53
53
  "client": {
54
54
  "platform": "web",
55
55
  "inject": [
56
- "@deepseek-ai/dsh-client-runtime",
56
+ "@deepseek-ai/dsh-client-ui-slots",
57
57
  "@deepseek-ai/dsh-client-ui-settings",
58
58
  "@deepseek-ai/dsh-client-ui-conversation"
59
59
  ]
60
60
  }
61
61
  }
62
- }
62
+ }