@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 +17 -1
- package/docs/LESSONS.md +258 -1
- package/lib/client.js +72 -19
- package/lib/index.js +105 -28
- package/lib/proxy-core.js +60 -20
- package/package.json +7 -7
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
|
-
|
|
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
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
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
|
-
? (
|
|
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 !==
|
|
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 =
|
|
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
|
-
|
|
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. */
|
|
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
|
-
/**
|
|
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}`)
|
|
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
|
|
371
|
-
if (!
|
|
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
|
-
|
|
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)
|
|
490
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
688
|
-
|
|
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:
|
|
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).
|
|
25
|
-
*
|
|
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
|
|
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 ===
|
|
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 :
|
|
36
|
+
return typeof id === 'string' && id.length > 0 ? id : fallback
|
|
34
37
|
} catch {
|
|
35
|
-
return
|
|
38
|
+
return fallback
|
|
36
39
|
}
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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": "
|
|
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.
|
|
44
|
-
"@deepseek-ai/dsh-settings": ">=0.
|
|
45
|
-
"@deepseek-ai/dsh-system-prompt": ">=0.
|
|
46
|
-
"@deepseek-ai/dsh-tools": ">=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-
|
|
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
|
+
}
|