@yanglaofish/dsh-proxy-pro 1.0.10 → 1.1.0

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/docs/LESSONS.md CHANGED
@@ -898,8 +898,97 @@ PowerShell `env:` 枚举报「已添加了具有相同键的项」就是"证据"
898
898
  用户实测功能/回归通过才算过。
899
899
  3. **验证通过 → 提交远程**:先 commit 当前工作区(消除 git 与 npm 的漂移——1.0.9 曾
900
900
  出现发布树与 HEAD 不一致:单拼写被混进 commit 但从未发布),再 push。
901
- 4. **发包**:显式 `--registry=https://registry.npmjs.org`,scoped `--access=public`,
902
- `--replace-registry-host=never`;403/staged publishing 绕行见 §29。
903
- 5. **淘宝强制同步**:publish 后**不等**,循环 PUT npmmirror sync + 轮询
904
- dist-tags.latest 直到更新(铁律 §28)。
905
- 6. **升级 web**:web profile `pnpm install` 装新版本,验收后结束。
901
+ 4. **发包(2026-09-23 起改走 CI)**:本地不再发包(SWG 按 URL 拦 proxy 包上传,见 §32)。
902
+ `git tag vX.Y.Z && git push origin vX.Y.Z` → GitHub Actions
903
+ (`.github/workflows/publish.yml`)在云端 runner 自动 `npm publish`;
904
+ 认证 = repo secret `NPM`(npmjs granular token,secret 名必须与 workflow
905
+ `secrets.NPM` 引用一致——曾因命名不匹配多失败三次,§32)。
906
+ 5. **淘宝强制同步**:workflow 内置「不等」——PUT npmmirror sync + 轮询
907
+ dist-tags.latest 直到更新(铁律 §28 已固化进流水线)。
908
+ 6. **升级 web**:web profile `pnpm install` 装新版本,验收后结束。
909
+
910
+ ## 32. SWG 上传限制 → 发布改走 GitHub Actions(2026-09-23)
911
+
912
+ **现象**:发布 1.0.10 时本地**任何通道都发不出去**:
913
+ - 公司出口被 SWG(HIS Proxy Notification)按 **URL 关键词**拦上传:到
914
+ registry.npmjs.org 的 **POST/PUT 且路径含 `proxy`**(`@yanglaofish/dsh-proxy-pro`)
915
+ 一律 403「该网站上载文件受限制」;GET 全放行。
916
+ - 试遍:npm CLI 11.19.1、npx npm@11、curl(HTTP/1.1 + npm UA)、node fetch、
917
+ staged 端点(`/-/stage/package/...`)、proxyhk / 直连 / proxyza(后者 407 无 NTLM)。
918
+ **另一会话同时用同版本 npm CLI 发 dsh-skill-manager(URL 无 proxy 词)直接成功**
919
+ → 实锤按 URL 词拦截,与工具/通道无关。
920
+ - 拦截页提供「申请上传权限」(按 URL、Upload_365 一年有效)入口,也可找 12345 客服。
921
+
922
+ **解法(已固化)**:**发布不再本地做**——`.github/workflows/publish.yml`:
923
+ `git tag vX.Y.Z && git push origin vX.Y.Z` 即触发 GitHub Actions
924
+ (runner 在 GitHub 云,不在公司网,SWG 管不到)→ `npm publish` → 自动淘宝同步 + 轮询。
925
+
926
+ **坑**:
927
+ - GitHub secret 名必须与 workflow `secrets.<名>` 完全一致:配了 `NPM` 而 workflow 引用
928
+ `NPM_TOKEN` → 读不到 token,run 失败在 Publish 步骤。
929
+ - step 的 `if:` 里直接引用未定义的 secret 可致 workflow 校验失败(快速失败、job 无 steps)——
930
+ 改经 `env:` 传值(env 引用空 secret 安全)。
931
+ - tag 触发时 workflow 文件取 **tag 指向 commit 的版本**:改了 workflow 必须删旧 tag 重打
932
+ (`git tag -d && git push origin --delete <tag> && git tag && git push`),否则 run 用旧代码。
933
+ - npm publish 若撞 staged publishing(202)需在 npmjs 网页/`npm stage approve` 做 2FA 批准;
934
+ granular/automation token 通常直接发布。
935
+
936
+ ## 33. dsh 0.2.0-rc.2 非兼容升级:插件被静默跳过(2026-10-08)
937
+
938
+ **背景**:DSH 桌面端换成新的 DeepSeek Harness(内核 `@deepseek-ai/*` 从 `0.1.5-rc.2`
939
+ 升到 `0.2.0-rc.2`)。升级后本插件**在插件清单里彻底消失**(连禁用项都没有),
940
+ `proxy_*` 工具、头部胶囊、设置页全没了——但**没有任何构建/加载报错**。
941
+
942
+ **三个非兼容点(按危害排序)**:
943
+
944
+ 1. **peer 范围把整个 bundle 判死(静默跳过)**。
945
+ `@deepseek-ai/dsh-app-boot` 的 `evaluatePluginCompatibility()` 会把 manifest 里每个
946
+ `@deepseek-ai/dsh` / `@deepseek-ai/dsh-*` peerDependency **与运行版本**(= 该
947
+ `dsh-app-boot` 包自己的 version)做 `semver.satisfies(..., {includePrerelease:true})`;
948
+ 只要一个不满足,**该 bundle 在启动时被跳过**(`Unreadable bundles, and bundles whose own
949
+ dsh peers the profile does not exempt, are skipped`),只留一条 warning 文案。
950
+ 我们写的是 `^0.1.5-rc.2`(caret 在 0.x 只允许同 minor)→ 不满足 0.2.0-rc.2 → 插件被丢。
951
+ - 只有 **`@deepseek-ai/dsh*`** 名字会被检查(`@deepseek-ai/cordis`、`schemastery` 不查)。
952
+ - `workspace:^`/`~`/`*` 视为"当前运行时";空串/非法范围同样判不兼容。
953
+ - 处置:宽范围 `">=0.1.5-rc.2 <0.3.0"`(代码两代 API 都兼容)。
954
+ - 参考:`dsh-skill-manager` 干脆**不声明任何 peerDependencies**,所以在同一内核下照常 active。
955
+
956
+ 2. **宿主设置服务换模型**:`dsh-settings` 的 `SettingsForms` 服务(name = `settings`)
957
+ **不再有 `register()` / `watch()`**。0.2 里**插件自己的 composition entry 就是设置表单**:
958
+ namespace = **entry id**,schema = 插件自己的 `Config`,写入走
959
+ `settings.update(entryId, patch)`(还有 `replace`/`mutate`,都带 `expectedRevision`),
960
+ 每次提交 emit `settings/document-updated`(参数 = entry id)。entry id 可从
961
+ `configEditor.configuration()` 的行里按 `row.entry.fiber === ctx.fiber`(或按已知 id)反查。
962
+ - 旧代码 `settings.register(PROXY_NS, Config, {base})` 会走 guard 静默降级成"只读",
963
+ **不会崩但设置改不动**,必须显式迁移。
964
+
965
+ 3. **浏览器侧 `ctx.settingsScope` 被删除**(新内核里该字符串 0 命中)。
966
+ 新客户端用 `configForms` 服务(`ctx.get('configForms')`,`get(entryId)` /
967
+ `whileServed([ns], register)`)配 `ctx.remote.settings.mutate(ns, ops, revision)`。
968
+ 我们的客户端 `exports.inject` 里写着 `settingsScope`——cordis 的 inject 等服务不存在
969
+ **会让客户端半边永久 pending**(§6 的翻版),所以必须删掉。
970
+ - 处置(本次采用):客户端不再依赖任何设置传输,改成走**插件自己的 HTTP API**
971
+ (`GET/POST /dsh-proxy-pro/api/config`,宿主再经 settings 服务落盘)。
972
+ 好处:UI 与内核设置 API 解耦,旧/新内核都能跑。
973
+ - 槽位没变:`settings.section`、`conversation.session.header.utilities` 仍在,
974
+ `label` 经 `resolveSlotLabel()` 解析(字符串可用)。
975
+
976
+ **没有被改动的部分(别再白改)**:
977
+ - `@deepseek-ai/dsh-tools` 的 `defineTool` 与作者 schema DSL **语义未变**:
978
+ parameter map 与 output value schema 仍支持**逐属性 `required: true` 标记**,
979
+ 仍要求 object 节点显式 `additionalProperties: boolean`,只允许
980
+ `type/oneOf/properties/required/additionalProperties/items/enum/const`+注解。
981
+ (output 走 `valueSchemaSpecToJsonSchema`:根节点 `allowRequired:false`,
982
+ 属性节点 `allowRequired:true`——我们把 `required` 写在属性上,正确。)
983
+ - `@deepseek-ai/dsh-http-proxy` 0.2 仍导出 `installProxyFromEnvironment(env, report)`、
984
+ `proxyRouteFor`(返回 `{proxied:false}` 或 `{proxied:true, proxy, dispatcher}`)、
985
+ `proxyEnvironmentForChild()`、`clearedProxyEnv()`;`POLICY_ENV_NAMES` 仍是**双拼写**。
986
+
987
+ **护栏**:`test/manifest-compat.test.mjs`——断言每个 `@deepseek-ai/dsh*` peer 范围覆盖
988
+ 受支持的运行时世代(`0.1.5-rc.2`、`0.2.0-rc.2`),并断言客户端不再 inject `settingsScope`、
989
+ 不再 bind 设置 scope。**这类"静默跳过"只有靠清单级测试才能提前发现**——单元测试全绿
990
+ 也照样会被内核丢掉。
991
+
992
+ **排查手法(可复用)**:内核打包在 `…\DeepSeek Harness\resources\app.asar` 里,
993
+ PowerShell 读不到;用 asar 头解析脚本(`_asar_probe.mjs` / `_asar_grep.mjs`)列文件、
994
+ 按路径取文件、全库 grep 字符串,再顺着 `dsh-app-boot` → 兼容判定看真相。
package/lib/client.js CHANGED
@@ -10,9 +10,10 @@
10
10
  * NOT derived from settings), the editable config fields (mode / custom
11
11
  * URL / NO_PROXY), and a per-URL route diagnostic.
12
12
  *
13
- * Both read and write the `proxy` settings namespace through the shared
14
- * `ctx.settingsScope` transport (same as dsh-plugin-proxy); runtime facts
15
- * come from the same-origin /dsh-proxy-pro/api endpoints served by the host
13
+ * Both read and write configuration through the host's own
14
+ * `/dsh-proxy-pro/api/config` endpoint (the browser settings transport
15
+ * `ctx.settingsScope` was removed in dsh 0.2 — LESSONS §33); runtime facts
16
+ * come from the same-origin `/dsh-proxy-pro/api` endpoints served by the host
16
17
  * half (browser-trust fenced, see lib/index.js).
17
18
  *
18
19
  * This hand-written module mirrors the loader format the in-repo client
@@ -107,18 +108,85 @@ window.__ModuleLoader__.load({
107
108
  .catch(function () { return null; });
108
109
  }
109
110
 
110
- // ---- reactive settings scope (the `proxy` namespace) ----
111
- /** @returns `{ value, writable, ready }` from a bound SettingsScope. */
112
- function useProxySnapshot(scope) {
111
+ // ---- host-backed configuration store (dsh >= 0.2) ------------------
112
+ // The browser settings transport (`ctx.settingsScope`) was removed in dsh
113
+ // 0.2 (LESSONS §33), so the capsule and the settings page read/write the
114
+ // host's own /config API. The snapshot shape mirrors the old settings
115
+ // scope (`{ status, value, writable }`), so the render code below is
116
+ // unchanged; an older host simply has no /config route and the panel
117
+ // reports itself unavailable instead of crashing.
118
+ function createProxyStore() {
119
+ var listeners = [];
120
+ var snapshot = { status: "loading", value: undefined, writable: false };
121
+ var inflight = null;
122
+ function emit(next) {
123
+ snapshot = next;
124
+ listeners.slice().forEach(function (fn) {
125
+ try { fn(); } catch (error) { console.error("[dsh-proxy-pro] listener failed:", error); }
126
+ });
127
+ }
128
+ function accept(data) {
129
+ if (data === null || data.ok !== true || data.config === undefined || data.config === null) {
130
+ emit({ status: "unavailable", value: undefined, writable: false });
131
+ return false;
132
+ }
133
+ emit({ status: "ready", value: data.config, writable: data.writable !== false });
134
+ return true;
135
+ }
136
+ function refresh() {
137
+ if (inflight !== null) return inflight;
138
+ inflight = fetchJson("/config").then(function (data) {
139
+ inflight = null;
140
+ accept(data);
141
+ return data;
142
+ }, function () { inflight = null; return null; });
143
+ return inflight;
144
+ }
145
+ function set(field, value) {
146
+ var body = {};
147
+ body[field] = value;
148
+ return fetchJson("/config", {
149
+ method: "POST",
150
+ headers: { "content-type": "application/json" },
151
+ body: JSON.stringify(body),
152
+ }).then(function (data) {
153
+ if (data === null || data.ok !== true) {
154
+ throw new Error((data && data.error) || "config write failed");
155
+ }
156
+ accept(data);
157
+ });
158
+ }
159
+ return {
160
+ subscribe: function (fn) {
161
+ listeners.push(fn);
162
+ return function () {
163
+ var index = listeners.indexOf(fn);
164
+ if (index >= 0) listeners.splice(index, 1);
165
+ };
166
+ },
167
+ getSnapshot: function () { return snapshot; },
168
+ refresh: refresh,
169
+ set: set,
170
+ setEnabled: function (next) { return set("enabled", next === true); },
171
+ };
172
+ }
173
+
174
+ /** Subscribe to the host-backed config store → `{ value, writable, ready }`. */
175
+ function useProxySettings(store) {
113
176
  var snapshot = useSyncExternalStore(
114
- function (onChange) { return scope.subscribe(onChange); },
115
- function () { return scope.getSnapshot(); }
177
+ function (onChange) {
178
+ var unsubscribe = store.subscribe(onChange);
179
+ store.refresh();
180
+ return unsubscribe;
181
+ },
182
+ function () { return store.getSnapshot(); }
116
183
  );
117
184
  var ready = snapshot.status === "ready" && snapshot.value !== undefined;
118
185
  return {
119
186
  value: ready ? snapshot.value : undefined,
120
187
  writable: snapshot.writable === true && ready,
121
188
  ready: snapshot.status === "unavailable" ? false : snapshot.status === "ready",
189
+ unavailable: snapshot.status === "unavailable",
122
190
  };
123
191
  }
124
192
 
@@ -177,8 +245,8 @@ window.__ModuleLoader__.load({
177
245
 
178
246
  // ---- conversation header capsule button ----
179
247
  function ProxyHeaderButton(props) {
180
- var scope = props.scope;
181
- var settings = useProxySnapshot(scope);
248
+ var store = props.store;
249
+ var settings = useProxySettings(store);
182
250
  // Local optimistic value (2026-09-18): the capsule flips its visual
183
251
  // immediately on click instead of waiting for scope.set's round-trip
184
252
  // (the settings-page switch already behaves this way). null means no
@@ -201,7 +269,7 @@ window.__ModuleLoader__.load({
201
269
  if (!settings.writable) return;
202
270
  var next = !shownEnabled;
203
271
  setOptimistic(next);
204
- scope.set("enabled", next).catch(function (error) {
272
+ store.setEnabled(next).catch(function (error) {
205
273
  console.error("[dsh-proxy-pro] toggle failed:", error);
206
274
  // Roll back to the committed value on failure.
207
275
  setOptimistic(null);
@@ -236,8 +304,8 @@ window.__ModuleLoader__.load({
236
304
 
237
305
  // ---- Settings page: 代理管理 ----
238
306
  function ProxyManagerPanel(props) {
239
- var scope = props.scope;
240
- var settings = useProxySnapshot(scope);
307
+ var store = props.store;
308
+ var settings = useProxySettings(store);
241
309
  // Re-fetch the host snapshot when the configuration changes (enabled /
242
310
  // mode / customUrl / noProxy are the only inputs that move the effKey);
243
311
  // no timed polling, mount + change-driven only (§17.3d).
@@ -260,7 +328,10 @@ window.__ModuleLoader__.load({
260
328
 
261
329
  if (!settings.ready) {
262
330
  return react.createElement("div", { className: "dsxprx", "data-proxy-settings": true },
263
- react.createElement("p", { className: "dsxprx-hint" }, "代理设置加载中…"));
331
+ react.createElement("p", { className: "dsxprx-hint" },
332
+ settings.unavailable
333
+ ? "宿主未提供配置接口(/dsh-proxy-pro/api/config)——请确认插件已完整安装、宿主已重启。"
334
+ : "代理设置加载中…"));
264
335
  }
265
336
  var value = settings.value || { enabled: false, mode: "system", customUrl: "", noProxy: "" };
266
337
  var writable = settings.writable;
@@ -306,7 +377,7 @@ window.__ModuleLoader__.load({
306
377
 
307
378
  function setField(field) {
308
379
  return function (next) {
309
- scope.set(field, next).catch(function (error) {
380
+ store.set(field, next).catch(function (error) {
310
381
  console.error("[dsh-proxy-pro] save failed:", error);
311
382
  });
312
383
  };
@@ -457,12 +528,13 @@ window.__ModuleLoader__.load({
457
528
  }
458
529
 
459
530
  /**
460
- * Browser plugin body: bind the `proxy` settings scope, then register the
461
- * conversation header button and the 代理配置 settings section.
531
+ * Browser plugin body: create the host-backed config store, then register
532
+ * the conversation header button and the 代理配置 settings section.
462
533
  * @param ctx - client cordis context.
463
534
  */
464
535
  function apply(ctx) {
465
- var scope = ctx.settingsScope.bind({ namespace: "proxy" });
536
+ var store = createProxyStore();
537
+ ctx.effect(function () { return function () { /* no timers to clear */ }; }, "dsh-proxy-pro: config store");
466
538
  ctx.slots.inject("settings.section", function () {
467
539
  return ctx.slots.register({
468
540
  name: "settings.section",
@@ -470,7 +542,7 @@ window.__ModuleLoader__.load({
470
542
  order: 20,
471
543
  label: "代理配置",
472
544
  }, function Section() {
473
- return react.createElement(ProxyManagerPanel, { scope: scope });
545
+ return react.createElement(ProxyManagerPanel, { store: store });
474
546
  });
475
547
  });
476
548
  ctx.slots.inject("conversation.session.header.utilities", function () {
@@ -480,7 +552,7 @@ window.__ModuleLoader__.load({
480
552
  order: 10,
481
553
  label: "代理",
482
554
  }, function Header() {
483
- return react.createElement(ProxyHeaderButton, { scope: scope });
555
+ return react.createElement(ProxyHeaderButton, { store: store });
484
556
  });
485
557
  });
486
558
  }
@@ -490,10 +562,11 @@ window.__ModuleLoader__.load({
490
562
  // Cordis service names the browser loader must wait for — NOT package
491
563
  // names (those belong in package.json's dsh.client.inject). Writing
492
564
  // package names here keeps the plugin permanently pending and blocks
493
- // web boot (see docs/LESSONS.md §6).
565
+ // web boot (see docs/LESSONS.md §6). `settingsScope` was one of these
566
+ // until dsh 0.2 removed that service; requiring it now would keep the
567
+ // whole client half permanently pending (LESSONS §33).
494
568
  exports.inject = [
495
569
  "slots",
496
- "settingsScope",
497
570
  ];
498
571
  return module.exports;
499
572
  },
package/lib/index.js CHANGED
@@ -33,10 +33,14 @@ import {
33
33
  classifyProbeStatus,
34
34
  classifyTargetFailure,
35
35
  combineDualProbe,
36
+ configPayload,
37
+ DEFAULT_ENTRY_ID,
38
+ discoverEntryId,
36
39
  headNeedsGetRetry,
37
40
  keepaliveHint,
38
41
  makeSystemProxyReader,
39
42
  normalizeProxyUrl,
43
+ readEntryConfig,
40
44
  resolveProxyState,
41
45
  summarize,
42
46
  systemFactsEqual,
@@ -114,8 +118,7 @@ function makeEnvLike(effective) {
114
118
  }
115
119
  }
116
120
 
117
- /** Build the agent-facing status text from the latest sync snapshot. */
118
- function renderStatusText(snapshot) {
121
+ /** Build the agent-facing status text from the latest sync snapshot. */function renderStatusText(snapshot) {
119
122
  const effective = snapshot?.effective
120
123
  const config = snapshot?.config
121
124
  if (effective === undefined || config === undefined) {
@@ -478,23 +481,39 @@ function apply(ctx, config) {
478
481
  return syncing
479
482
  }
480
483
 
481
- // Configuration source: the `proxy` settings namespace layered over the
482
- // composition entry; every committed change re-syncs live. Registration goes
483
- // through the settings SERVICE — newer dsh-settings removed the
484
- // `installSettingsSection` export entirely, so importing it throws a
485
- // SyntaxError at boot and kills the whole profile (LESSONS §26).
486
- // register() + scope.get()/watch() exist on every version we target, and
487
- // update() recomputes registration.resolved, so scope.get() stays fresh.
484
+ // Configuration source — two settings generations (LESSONS §26, §33):
485
+ // - dsh <= 0.1.x: a per-plugin namespace registered through the settings
486
+ // service (register(ns, schema, {base}) → scope.get()/watch()).
487
+ // - dsh >= 0.2: register()/watch() are gone. The plugin's OWN composition
488
+ // 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.
488
491
  const settingsService = ctx.get('settings')
489
- if (settingsService !== undefined && typeof settingsService.register === 'function') {
492
+ const configEditor = ctx.get('configEditor')
493
+ const legacyRegister = typeof settingsService?.register === 'function'
494
+ const entryId = discoverEntryId(configEditor, ctx) ?? DEFAULT_ENTRY_ID
495
+ if (legacyRegister) {
490
496
  const proxyScope = settingsService.register(PROXY_NS, Config, { base: config })
491
497
  source = () => proxyScope.get()
492
498
  proxyScope.watch(() => {
493
499
  void requestSync().catch(() => {})
494
500
  })
501
+ } else if (typeof settingsService?.update === 'function') {
502
+ source = () => readEntryConfig(configEditor, entryId, config)
503
+ ctx.on('settings/document-updated', (ns) => {
504
+ if (ns === entryId) void requestSync().catch(() => {})
505
+ })
506
+ }
507
+ // Without either generation the plugin still runs read-only on the
508
+ // composition config (source keeps returning it).
509
+
510
+ /** Persist one config patch through whichever settings generation is present. */
511
+ const writeSettings = async (patch) => {
512
+ if (settingsService === undefined) {
513
+ throw new Error('the settings service is unavailable in this composition — change the proxy configuration in the composition instead')
514
+ }
515
+ await settingsService.update(legacyRegister ? PROXY_NS : entryId, patch)
495
516
  }
496
- // Without a settings service in this composition, `source` keeps the
497
- // composition default (initialized above) and the plugin still runs, read-only.
498
517
 
499
518
  // ---------- Agent tools ----------
500
519
 
@@ -538,11 +557,11 @@ function apply(ctx, config) {
538
557
  }],
539
558
  },
540
559
  execute: async (args) => {
541
- const settings = ctx.get('settings')
542
- if (settings === undefined) {
543
- throw new Error('proxy_set: the settings service is unavailable in this composition — change the proxy config instead')
560
+ try {
561
+ await writeSettings({ enabled: args.enabled === true })
562
+ } catch (error) {
563
+ throw new Error(`proxy_set: ${error?.message ?? error}`)
544
564
  }
545
- await settings.update(PROXY_NS, { enabled: args.enabled === true })
546
565
  await requestSync()
547
566
  return summarize(snapshot)
548
567
  },
@@ -604,10 +623,6 @@ function apply(ctx, config) {
604
623
  }],
605
624
  },
606
625
  execute: async (args) => {
607
- const settings = ctx.get('settings')
608
- if (settings === undefined) {
609
- throw new Error('proxy_config: the settings service is unavailable in this composition — change the proxy configuration in Settings instead')
610
- }
611
626
  const patch = {}
612
627
  if (args.enabled !== undefined) patch.enabled = args.enabled === true
613
628
  if (args.mode !== undefined) patch.mode = args.mode
@@ -624,7 +639,7 @@ function apply(ctx, config) {
624
639
  }
625
640
  }
626
641
  if (args.noProxy !== undefined) patch.noProxy = String(args.noProxy).trim()
627
- if (Object.keys(patch).length > 0) await settings.update(PROXY_NS, patch)
642
+ if (Object.keys(patch).length > 0) await writeSettings(patch)
628
643
  await requestSync()
629
644
  return summarize(snapshot)
630
645
  },
@@ -709,16 +724,58 @@ function apply(ctx, config) {
709
724
  }
710
725
  if (is('/toggle', 'POST')) {
711
726
  const body = await readJsonBody(req)
712
- const settings = ctx.get('settings')
713
- if (settings === undefined) {
714
- sendJson(res, 503, { ok: false, error: 'settings service unavailable' })
727
+ try {
728
+ await writeSettings({ enabled: body.enabled === true })
729
+ } catch (error) {
730
+ sendJson(res, 503, { ok: false, error: String(error?.message ?? error) })
715
731
  return
716
732
  }
717
- await settings.update(PROXY_NS, { enabled: body.enabled === true })
718
733
  await requestSync()
719
734
  sendJson(res, 200, { ok: true, status: summarize(snapshot) })
720
735
  return
721
736
  }
737
+ // Editable configuration for the client panel. dsh >= 0.2 removed
738
+ // the browser settings transport (`ctx.settingsScope`), so the panel
739
+ // reads and writes through this API instead (LESSONS §33).
740
+ if (is('/config', 'GET')) {
741
+ sendJson(res, 200, { ok: true, ...configPayload(source()) })
742
+ return
743
+ }
744
+ if (is('/config', 'POST')) {
745
+ const body = await readJsonBody(req)
746
+ const patch = {}
747
+ if (body.enabled !== undefined) patch.enabled = body.enabled === true
748
+ if (body.mode !== undefined) {
749
+ if (body.mode !== 'system' && body.mode !== 'custom') {
750
+ sendJson(res, 400, { ok: false, error: 'mode must be "system" or "custom"' })
751
+ return
752
+ }
753
+ patch.mode = body.mode
754
+ }
755
+ if (body.customUrl !== undefined) {
756
+ const raw = String(body.customUrl).trim()
757
+ if (raw === '') {
758
+ patch.customUrl = ''
759
+ } else {
760
+ const normalized = normalizeProxyUrl(raw)
761
+ if (normalized === null) {
762
+ sendJson(res, 400, { ok: false, error: `"${body.customUrl}" is not a usable proxy address — expected http(s)://host:port` })
763
+ return
764
+ }
765
+ patch.customUrl = normalized
766
+ }
767
+ }
768
+ if (body.noProxy !== undefined) patch.noProxy = String(body.noProxy).trim()
769
+ try {
770
+ if (Object.keys(patch).length > 0) await writeSettings(patch)
771
+ } catch (error) {
772
+ sendJson(res, 503, { ok: false, error: String(error?.message ?? error) })
773
+ return
774
+ }
775
+ await requestSync()
776
+ sendJson(res, 200, { ok: true, ...configPayload(source()) })
777
+ return
778
+ }
722
779
  sendJson(res, 404, { ok: false, error: 'not found' })
723
780
  } catch (error) {
724
781
  sendJson(res, 500, { ok: false, error: String(error?.message ?? error) })
package/lib/proxy-core.js CHANGED
@@ -15,6 +15,60 @@
15
15
  const PROXY_REG_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings'
16
16
  const LOCAL_ENTRIES = ['localhost', '127.0.0.1', '::1']
17
17
 
18
+ /** Bundle row id (cordis.patch.yml) — also the settings namespace on dsh >= 0.2,
19
+ * where a plugin's OWN composition entry is its settings form (LESSONS §33). */
20
+ export const DEFAULT_ENTRY_ID = 'dsh-proxy-pro'
21
+
22
+ /**
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.
26
+ */
27
+ export function discoverEntryId(configEditor, ctx) {
28
+ try {
29
+ const rows = configEditor?.configuration?.() ?? []
30
+ const own = rows.find((row) => row?.entry?.fiber === ctx?.fiber)
31
+ ?? rows.find((row) => row?.entry?.options?.id === DEFAULT_ENTRY_ID)
32
+ const id = own?.entry?.options?.id
33
+ return typeof id === 'string' && id.length > 0 ? id : undefined
34
+ } catch {
35
+ return undefined
36
+ }
37
+ }
38
+
39
+ /**
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.
42
+ */
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 */
50
+ }
51
+ return fallback
52
+ }
53
+
54
+ /**
55
+ * Editable configuration payload for the client panel (`/config` API). The
56
+ * browser cannot reach the settings transport on dsh >= 0.2, so the host hands
57
+ * the panel exactly the fields it edits (LESSONS §33).
58
+ */
59
+ export function configPayload(resolved) {
60
+ return {
61
+ config: {
62
+ enabled: resolved?.enabled === true,
63
+ mode: resolved?.mode ?? 'none',
64
+ customUrl: resolved?.customUrl ?? '',
65
+ noProxy: resolved?.noProxy ?? '',
66
+ systemPollMs: resolved?.systemPollMs ?? 0,
67
+ },
68
+ writable: true,
69
+ }
70
+ }
71
+
18
72
  /** Normalize a bare `host:port` or already-schemed URL into an http(s) URL. */
19
73
  export function normalizeProxyUrl(value) {
20
74
  if (typeof value !== 'string') return null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yanglaofish/dsh-proxy-pro",
3
- "version": "1.0.10",
3
+ "version": "1.1.0",
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",
44
- "@deepseek-ai/dsh-settings": "^0.1.5-rc.2",
45
- "@deepseek-ai/dsh-system-prompt": "^0.1.5-rc.2",
46
- "@deepseek-ai/dsh-tools": "^0.1.5-rc.2",
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",
47
47
  "@deepseek-ai/schemastery": "^3.18.0"
48
48
  },
49
49
  "dsh": {