dsh-retrace 0.4.29 → 0.4.30

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
@@ -360,7 +360,7 @@ Read it before filing an issue.
360
360
  ### Upgrading
361
361
 
362
362
  ```bash
363
- dsh plugin --profile desktop add dsh-retrace@0.4.29
363
+ dsh plugin --profile desktop add dsh-retrace@0.4.30
364
364
  # then restart DSH — plugins are not hot-reloaded
365
365
  ```
366
366
 
@@ -452,6 +452,21 @@ the **host surface**, not by semver alone.
452
452
  > arms the native gate; it relies on the running banner plus the dispose notice. The gate is
453
453
  > armed only where the host reports that the page really surfaces a native dialog
454
454
  > (`quitVeto: true`, i.e. browser pages).
455
+ >
456
+ > **Both criteria must hold** (2026-09-18, second round): the host side is only a
457
+ > *non-objection*, and the client keeps a **veto** — if this page's `navigator.userAgent`
458
+ > contains `Electron`, or the page URL contains `dsh-desktop-` (the external report's shell
459
+ > puts its desktop marker in the query string), the gate is **never** armed. The host-side
460
+ > criteria were widened to four request-level facts: the `x-dsh-desktop-renderer` capability
461
+ > header, a request `User-Agent` containing `Electron`, or a request URL **or `Referer`**
462
+ > containing `dsh-desktop-` — **any one** of them classifies the page as a desktop page. (Our own
463
+ > polling URL carries no query string, so the page marker is in practice read from `Referer`,
464
+ > which a same-origin `fetch` sends by default.) That covers the
465
+ > hosts that expose no desktop evidence at all (the reporter's: a pure-Node harness plus an
466
+ > Electron renderer, where all three older criteria were false, and the old code filed
467
+ > "no evidence at all" as a browser page ⇒ the gate was still armed ⇒ the exit still hung).
468
+ > If Desktop cannot quit, turning off "Exit confirmation (close guard)" in settings recovers
469
+ > immediately (no restart).
455
470
 
456
471
  > Command surface: `retrace.runningState` (host RPC) + `GET|POST /api/plugins/retrace/runningState` (HTTP).
457
472
 
package/README.zh.md CHANGED
@@ -336,6 +336,15 @@ dsh plugin --profile desktop add dsh-retrace@0.4.26
336
336
  > `requestQuit(0) → window.destroy() → app.exit(0)`)上,退出本来就不受影响。页面里分不出
337
337
  > 自己属于哪一类,因此**桌面端一律不武装**原生门,保护由运行中横幅 + dispose 提示承担;
338
338
  > 只有宿主回报"这个页面会弹原生确认框"(`quitVeto: true`,即普通浏览器页)时才武装。
339
+ >
340
+ > **两道判据都成立才武装**(2026-09-18 第二轮):宿主那条只是"不反对",客户端还有**一票否决** ——
341
+ > 本页 `navigator.userAgent` 含 `Electron`,或页面 URL 含 `dsh-desktop-`(外部报告那套壳的查询参数
342
+ > 前缀)⇒ 一律不武装。宿主侧的判据也补成四条请求级证据:能力头 `x-dsh-desktop-renderer` /
343
+ > 请求 UA 含 Electron / 请求 URL **或 `Referer`** 含 `dsh-desktop-`(我们自己的轮询 URL 没有 query,
344
+ > 所以页面标记实际是从 `Referer` 读到的),**任一成立**即判桌面页面。这样"壳什么桌面痕迹
345
+ > 都不给"的宿主(报告人那台:纯 Node 跑 harness + Electron 渲染页,三条旧判据全不成立,
346
+ > 旧实现把"完全没有证据"归成了浏览器页 ⇒ 照旧武装 ⇒ 仍退不掉)也不会再把退出卡住。
347
+ > 桌面端退不掉时,把设置里的「退出确认(关闭守卫)」关掉即可立即恢复(不用重启)。
339
348
 
340
349
  **未来计划**——agent 业务层规划(运行时守护、中断治理、生态开放接口)**尚未发布**,此节是**计划**而非已上线能力。本 README 描述的是**开发线(main)**,可能领先于 npm 上最新发布版。
341
350
 
@@ -261,6 +261,29 @@ function buildRunningCopy(snapshot, { locale = "zh", labelOf, now = Date.now(),
261
261
  function quitVetoOf(snapshot) {
262
262
  return snapshot?.quitVeto === true;
263
263
  }
264
+ var CLIENT_DESKTOP_URL_MARK = "dsh-desktop-";
265
+ var CLIENT_DESKTOP_UA_RE = /electron/i;
266
+ function clientDesktopEvidence(page = pageEnvironment()) {
267
+ const ua = typeof page?.userAgent === "string" ? page.userAgent : "";
268
+ const url = typeof page?.href === "string" ? page.href : "";
269
+ const electronUa = CLIENT_DESKTOP_UA_RE.test(ua);
270
+ const urlMark = url.includes(CLIENT_DESKTOP_URL_MARK);
271
+ return { electronUa, urlMark, desktop: electronUa || urlMark };
272
+ }
273
+ function pageEnvironment() {
274
+ const g = globalThis;
275
+ const ua = g?.navigator?.userAgent;
276
+ const href = g?.location?.href;
277
+ return {
278
+ userAgent: typeof ua === "string" ? ua : "",
279
+ href: typeof href === "string" ? href : ""
280
+ };
281
+ }
282
+ function shouldArmNativeGate(snapshot, page = pageEnvironment()) {
283
+ if (snapshot?.surface === "desktop-renderer") return false;
284
+ if (!quitVetoOf(snapshot)) return false;
285
+ return !clientDesktopEvidence(page).desktop;
286
+ }
264
287
  function createGuardStore({ now = Date.now, armTtlMs = GUARD_ARM_TTL_MS } = {}) {
265
288
  let snapshot = null;
266
289
  let armedAt = 0;
@@ -472,7 +495,7 @@ var zh = {
472
495
  "path.round": "\u7B2C {n} \u8F6E",
473
496
  "path.empty": "\uFF08\u5F53\u524D\u6CA1\u6709\u5BF9\u8BDD\u8F6E\u6B21\uFF09",
474
497
  "options.closeGuard": "\u9000\u51FA\u786E\u8BA4\uFF08\u5173\u95ED\u5B88\u536B\uFF09",
475
- "options.closeGuardDesc": "\u5F00\uFF08\u9ED8\u8BA4\uFF09\uFF1A\u9875\u9762\u5173\u95ED\u524D\u786E\u8BA4\u9000\u51FA\u2014\u2014\u6709\u8FD0\u884C\u4E2D\u4EFB\u52A1\u6216\u672A\u5B8C\u6210\u5BF9\u8BDD\u65F6\u5F3A\u62E6\u5E76\u5217\u51FA\u660E\u7EC6\uFF08\u9632\u8BEF\u5173\u4E22\u8FDB\u5EA6\uFF09\uFF0C\u65E0\u4EFB\u52A1\u65F6\u8F7B\u786E\u8BA4\u4E00\u6B21\u3002\u53EA\u5728\u5BBF\u4E3B\u786E\u8BA4\u4F1A\u663E\u793A\u539F\u751F\u786E\u8BA4\u6846\u7684\u9875\u9762\u4E0A\u6B66\u88C5\uFF08\u666E\u901A\u6D4F\u89C8\u5668\u6807\u7B7E\u9875\uFF1B\u5173\u6389\u672C\u9879\u540C\u65F6\u505C\u7528\u8FD0\u884C\u4E2D\u6A2A\u5E45\uFF09\u3002\u684C\u9762\u7AEF\uFF08DSH Desktop\uFF09\u4E00\u5F8B\u4E0D\u6B66\u88C5\uFF1A\u9000\u51FA\u5165\u53E3\u968F\u7248\u672C/\u5E73\u53F0\u800C\u53D8\u2014\u2014\u5916\u90E8\u62A5\u544A\u7684 0.9.0 \u4E00\u7C7B\u5165\u53E3\u4F1A\u89E6\u53D1\u9875\u9762 beforeunload\uFF0C\u800C\u684C\u9762\u58F3\u672A\u5904\u7406 will-prevent-unload \u21D2 \u5426\u51B3\u88AB\u9759\u9ED8\u541E\u6389\u3001\u9000\u51FA\u5361\u4F4F\uFF1B2.0.9 \u7684\u6258\u76D8\u9879\u8D70 requestQuit\u2192window.destroy\u2192app.exit\uFF0C\u6839\u672C\u4E0D\u7ECF\u8FC7\u5B83\u3002\u9875\u9762\u91CC\u5206\u4E0D\u51FA\u662F\u54EA\u4E00\u7C7B\uFF0C\u6545\u684C\u9762\u4FDD\u62A4\u7531\u8FD0\u884C\u4E2D\u6A2A\u5E45\u627F\u62C5\u3002"
498
+ "options.closeGuardDesc": "\u5F00\uFF08\u9ED8\u8BA4\uFF09\uFF1A\u9875\u9762\u5173\u95ED\u524D\u786E\u8BA4\u9000\u51FA\u2014\u2014\u6709\u8FD0\u884C\u4E2D\u4EFB\u52A1\u6216\u672A\u5B8C\u6210\u5BF9\u8BDD\u65F6\u5F3A\u62E6\u5E76\u5217\u51FA\u660E\u7EC6\uFF08\u9632\u8BEF\u5173\u4E22\u8FDB\u5EA6\uFF09\uFF0C\u65E0\u4EFB\u52A1\u65F6\u8F7B\u786E\u8BA4\u4E00\u6B21\u3002\u53EA\u5728\u5BBF\u4E3B\u786E\u8BA4\u4F1A\u663E\u793A\u539F\u751F\u786E\u8BA4\u6846\u7684\u9875\u9762\u4E0A\u6B66\u88C5\uFF08\u666E\u901A\u6D4F\u89C8\u5668\u6807\u7B7E\u9875\uFF1B\u5173\u6389\u672C\u9879\u540C\u65F6\u505C\u7528\u8FD0\u884C\u4E2D\u6A2A\u5E45\uFF09\u3002\u684C\u9762\u7AEF\uFF08DSH Desktop\uFF09\u4E00\u5F8B\u4E0D\u6B66\u88C5\uFF1A\u9000\u51FA\u5165\u53E3\u968F\u7248\u672C/\u5E73\u53F0\u800C\u53D8\u2014\u2014\u5916\u90E8\u62A5\u544A\u7684 0.9.0 \u4E00\u7C7B\u5165\u53E3\u4F1A\u89E6\u53D1\u9875\u9762 beforeunload\uFF0C\u800C\u684C\u9762\u58F3\u672A\u5904\u7406 will-prevent-unload \u21D2 \u5426\u51B3\u88AB\u9759\u9ED8\u541E\u6389\u3001\u9000\u51FA\u5361\u4F4F\uFF1B2.0.9 \u7684\u6258\u76D8\u9879\u8D70 requestQuit\u2192window.destroy\u2192app.exit\uFF0C\u6839\u672C\u4E0D\u7ECF\u8FC7\u5B83\u3002\u5224\u636E\u662F\u4E24\u9053\uFF1A\u5BBF\u4E3B\u8BF4\u4F1A\u5F39\u539F\u751F\u6846\uFF08quitVeto\uFF09\uFF0C\u4E14\u672C\u9875\u81EA\u5DF1\u770B\u4E0D\u51FA\u684C\u9762\u75D5\u8FF9\uFF08UA \u65E0 Electron\u3001URL \u65E0 dsh-desktop-\uFF09\u2014\u2014\u4EFB\u4E00\u6761\u4E0D\u6210\u7ACB\u5C31\u4E0D\u6B66\u88C5\uFF1B\u56E0\u6B64\u9875\u9762\u91CC\u5206\u4E0D\u51FA\u662F\u54EA\u4E00\u7C7B\u58F3\u4E5F\u6CA1\u5173\u7CFB\uFF0C\u684C\u9762\u4FDD\u62A4\u7531\u8FD0\u884C\u4E2D\u6A2A\u5E45\u627F\u62C5\u3002\u684C\u9762\u7AEF\u9000\u4E0D\u6389\u65F6\u5148\u5173\u8FD9\u4E00\u9879\uFF08\u7ACB\u5373\u6062\u590D\uFF0C\u4E0D\u7528\u91CD\u542F\uFF09\u3002"
476
499
  };
477
500
  var en = {
478
501
  "action.edit": "Edit",
@@ -619,7 +642,7 @@ var en = {
619
642
  "path.round": "Round {n}",
620
643
  "path.empty": "(no conversation rounds yet)",
621
644
  "options.closeGuard": "Exit confirmation (close guard)",
622
- "options.closeGuardDesc": "On (default): confirm before the page closes \u2014 when sessions have running or unfinished work a strong guard lists them (prevents accidental progress loss); otherwise a light one-time confirm. Armed only where the host reports a native confirm dialog (ordinary browser tabs; turning this off also disables the running banner). Desktop (DSH Desktop) never arms it: quit entries differ by version/platform \u2014 the 0.9.0-class entry in the external report does reach page beforeunload, and the shell has no will-prevent-unload handler, so the veto is swallowed silently and the exit hangs; the 2.0.9 tray item goes requestQuit\u2192window.destroy\u2192app.exit and never reaches it. The page cannot tell which one it is, so the running banner carries that protection instead."
645
+ "options.closeGuardDesc": "On (default): confirm before the page closes \u2014 when sessions have running or unfinished work a strong guard lists them (prevents accidental progress loss); otherwise a light one-time confirm. Armed only where the host reports a native confirm dialog (ordinary browser tabs; turning this off also disables the running banner). Desktop (DSH Desktop) never arms it: quit entries differ by version/platform \u2014 the 0.9.0-class entry in the external report does reach page beforeunload, and the shell has no will-prevent-unload handler, so the veto is swallowed silently and the exit hangs; the 2.0.9 tray item goes requestQuit\u2192window.destroy\u2192app.exit and never reaches it. Two criteria must both hold: the host says a native dialog appears (quitVeto) AND this page shows no desktop evidence itself (no Electron in the UA, no dsh-desktop- in the URL); if either fails it stays unarmed, so it does not matter that the page cannot tell which shell it is \u2014 the running banner carries that protection instead. If Desktop cannot quit, turn this off first (recovers immediately, no restart)."
623
646
  };
624
647
  function opFailureText(code, message, t) {
625
648
  if (code === "agent-busy") return t("error.busy");
@@ -2890,7 +2913,7 @@ function installCloseGuard(t) {
2890
2913
  if (store.isArmed()) return;
2891
2914
  const kind = classifySnapshot(store.get());
2892
2915
  if (kind === "unknown") return;
2893
- if (!quitVetoOf(store.get())) return;
2916
+ if (!shouldArmNativeGate(store.get())) return;
2894
2917
  try {
2895
2918
  event.preventDefault?.();
2896
2919
  } catch {
package/lib/client.js CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  buildRunningCopy,
25
25
  runningLines,
26
26
  createGuardStore,
27
- quitVetoOf,
27
+ shouldArmNativeGate,
28
28
  } from './close-guard-client.js'
29
29
 
30
30
  export const name = 'dsh-retrace'
@@ -239,7 +239,7 @@ export const zh = {
239
239
  'path.round': '第 {n} 轮',
240
240
  'path.empty': '(当前没有对话轮次)',
241
241
  'options.closeGuard': '退出确认(关闭守卫)',
242
- 'options.closeGuardDesc': '开(默认):页面关闭前确认退出——有运行中任务或未完成对话时强拦并列出明细(防误关丢进度),无任务时轻确认一次。只在宿主确认会显示原生确认框的页面上武装(普通浏览器标签页;关掉本项同时停用运行中横幅)。桌面端(DSH Desktop)一律不武装:退出入口随版本/平台而变——外部报告的 0.9.0 一类入口会触发页面 beforeunload,而桌面壳未处理 will-prevent-unload ⇒ 否决被静默吞掉、退出卡住;2.0.9 的托盘项走 requestQuit→window.destroy→app.exit,根本不经过它。页面里分不出是哪一类,故桌面保护由运行中横幅承担。',
242
+ 'options.closeGuardDesc': '开(默认):页面关闭前确认退出——有运行中任务或未完成对话时强拦并列出明细(防误关丢进度),无任务时轻确认一次。只在宿主确认会显示原生确认框的页面上武装(普通浏览器标签页;关掉本项同时停用运行中横幅)。桌面端(DSH Desktop)一律不武装:退出入口随版本/平台而变——外部报告的 0.9.0 一类入口会触发页面 beforeunload,而桌面壳未处理 will-prevent-unload ⇒ 否决被静默吞掉、退出卡住;2.0.9 的托盘项走 requestQuit→window.destroy→app.exit,根本不经过它。判据是两道:宿主说会弹原生框(quitVeto),且本页自己看不出桌面痕迹(UA 无 Electron、URL 无 dsh-desktop-)——任一条不成立就不武装;因此页面里分不出是哪一类壳也没关系,桌面保护由运行中横幅承担。桌面端退不掉时先关这一项(立即恢复,不用重启)。',
243
243
  }
244
244
  /** English dictionary, checked complete against the zh key set. */
245
245
  export const en = {
@@ -387,7 +387,7 @@ export const en = {
387
387
  'path.round': 'Round {n}',
388
388
  'path.empty': '(no conversation rounds yet)',
389
389
  'options.closeGuard': 'Exit confirmation (close guard)',
390
- 'options.closeGuardDesc': 'On (default): confirm before the page closes — when sessions have running or unfinished work a strong guard lists them (prevents accidental progress loss); otherwise a light one-time confirm. Armed only where the host reports a native confirm dialog (ordinary browser tabs; turning this off also disables the running banner). Desktop (DSH Desktop) never arms it: quit entries differ by version/platform — the 0.9.0-class entry in the external report does reach page beforeunload, and the shell has no will-prevent-unload handler, so the veto is swallowed silently and the exit hangs; the 2.0.9 tray item goes requestQuit→window.destroy→app.exit and never reaches it. The page cannot tell which one it is, so the running banner carries that protection instead.',
390
+ 'options.closeGuardDesc': 'On (default): confirm before the page closes — when sessions have running or unfinished work a strong guard lists them (prevents accidental progress loss); otherwise a light one-time confirm. Armed only where the host reports a native confirm dialog (ordinary browser tabs; turning this off also disables the running banner). Desktop (DSH Desktop) never arms it: quit entries differ by version/platform — the 0.9.0-class entry in the external report does reach page beforeunload, and the shell has no will-prevent-unload handler, so the veto is swallowed silently and the exit hangs; the 2.0.9 tray item goes requestQuit→window.destroy→app.exit and never reaches it. Two criteria must both hold: the host says a native dialog appears (quitVeto) AND this page shows no desktop evidence itself (no Electron in the UA, no dsh-desktop- in the URL); if either fails it stays unarmed, so it does not matter that the page cannot tell which shell it is — the running banner carries that protection instead. If Desktop cannot quit, turn this off first (recovers immediately, no restart).',
391
391
  }
392
392
 
393
393
  // ---------------------------------------------------------------------------
@@ -3420,10 +3420,10 @@ function whatBody(what, summaryText, t, { suppressContinue = false, maxLines = n
3420
3420
  // (装好的 2.0.9 app.asar 全文 0 命中)⇒ 否决被静默吞掉,那里表现为"退出卡住、
3421
3421
  // 只能强退";2.0.9 的托盘项走 requestQuit(0) → window.destroy() → app.exit(code),
3422
3422
  // 不经过 beforeunload,那类版本上退出本来就正常;
3423
- // - ⇒ 原生门的武装条件 = **宿主明确回报本页承载 quit-veto**(普通浏览器页面)。
3424
- // 页面里分不出宿主属于哪一类,而第一类上否决是静默的(会卡住退出),所以桌面端
3425
- // 一律不武装。承载面由宿主随 runningState 载荷下发(quitVeto === true 才武装;
3426
- // false/未知都不武装,未知取安全侧)。客户端不再自己猜宿主;
3423
+ // - ⇒ 原生门的武装条件 = **宿主明确回报本页承载 quit-veto**(普通浏览器页面)
3424
+ // **且本页自己看不出桌面痕迹**(UA 无 Electron、URL 无 dsh-desktop-;2026-09-18
3425
+ // 第二轮补的客户端一票否决)。承载面由宿主随 runningState 载荷下发
3426
+ // (quitVeto === true 且客户端不否决才武装;false/未知都不武装,未知取安全侧);
3427
3427
  // - 桌面端的保护 = 运行中横幅(有任务即常驻)+ dispose 提示,两者都与退出路径无关;
3428
3428
  // - 浏览器 beforeunload 确认框文案/按钮不可自定义(Chromium 统一原生框),
3429
3429
  // 故 A 强拦 = 原生门 + 「取消后」的中文明细模态;B 轻确认 = 原生一次确认。
@@ -3475,7 +3475,8 @@ function installCloseGuard(t) {
3475
3475
  if (!value || !Array.isArray(value.running)) return
3476
3476
  const prev = store.get()
3477
3477
  // quitVeto = 宿主回报的"本页是否承载 quit-veto"(见 close-guard-client.js
3478
- // quitVetoOf)。**原样缓存宿主的值**:缺字段 = 旧宿主 = 未知 ⇒ 不武装。
3478
+ // shouldArmNativeGate:宿主值 + 客户端一票否决两道)。**原样缓存宿主的值**:
3479
+ // 缺字段 = 旧宿主 = 未知 ⇒ 不武装。
3479
3480
  store.set({ running: value.running, quitVeto: value.quitVeto, surface: value.surface })
3480
3481
  if (runningSig(store.get()) !== runningSig(prev)) ui.dismissed = null
3481
3482
  renderBanner()
@@ -3634,9 +3635,13 @@ function installCloseGuard(t) {
3634
3635
  if (kind === 'unknown') return // 状态未同步(host 不可达/刚启动)→ 不打扰
3635
3636
  // 宿主承载面闸门(2026-09-18 外部 issue #1):桌面 Electron 页面上原生
3636
3637
  // beforeunload 门没有意义(2.0.9 那类入口不经过它;0.9.0 那类入口经过时否决被
3637
- // 壳静默吞掉、退出卡住)。仅当宿主明确回报 quitVeto === true(普通浏览器页面)
3638
- // 才武装;false 与未知一律不武装(未知取安全侧)。
3639
- if (!quitVetoOf(store.get())) return
3638
+ // 壳静默吞掉、退出卡住)。**两道判据都为真才武装**:
3639
+ // ① 宿主明确回报 quitVeto === true(它认为这是会弹原生确认框的普通浏览器页);
3640
+ // ② 页面自己**没有**桌面证据(navigator.userAgent 无 Electron 段、URL 无
3641
+ // `dsh-desktop-` 标记)—— 2026-09-18 第二轮补的**客户端一票否决**:
3642
+ // 报告人那台壳三条宿主判据全不成立 ⇒ 宿主错回 browser/true,只靠 ① 会照旧
3643
+ // 卡死(实测)。false 与未知一律不武装(未知取安全侧)。
3644
+ if (!shouldArmNativeGate(store.get())) return
3640
3645
  // A/B 共用浏览器原生门:returnValue 触发原生确认(Chromium 文案不可自定义,
3641
3646
  // 见 close-guard-client.js 头注释;用户控制可在设置关闭)。
3642
3647
  try { event.preventDefault?.() } catch { /* ignore */ }
@@ -3714,9 +3719,9 @@ export function apply(ctx) {
3714
3719
  const t = ctx.locale.bind(NS)
3715
3720
 
3716
3721
  // 关闭守卫 V2:页面关闭 beforeunload 拦截(A 强拦/B 轻
3717
- // 确认,运行中状态经 /runningState 轮询缓存)+ 运行中横幅。原生门只在宿主
3718
- // 回报本页承载 quit-veto 时武装(见 installCloseGuard 段头注释);桌面端的
3719
- // 可见提示 = 运行中横幅 + dispose 提示(与退出路径无关)。
3722
+ // 确认,运行中状态经 /runningState 轮询缓存)+ 运行中横幅。原生门只在**宿主回报
3723
+ // 本页承载 quit-veto 且客户端不否决**时武装(见 installCloseGuard 段头注释);
3724
+ // 桌面端的可见提示 = 运行中横幅 + dispose 提示(与退出路径无关)。
3720
3725
  // The close guard is DOM-only (no React), so the panel error boundary does not
3721
3726
  // apply to it: fail CLOSED instead — the guard degrades to "not installed"
3722
3727
  // rather than breaking the plugin's activation (and with it the client half).
@@ -28,6 +28,15 @@
28
28
  * `false`(桌面 Electron 页面)与 `null`/`undefined`(中性态:wire 通道拿不到请求
29
29
  * 证据 / 旧版桌面壳)一律不武装 —— 未知取安全侧,避免再次死锁。
30
30
  *
31
+ * ⚠️ **2026-09-18 第二轮(issue #1 复测仍卡死)**:只靠宿主那条还不够 —— 报告人那台
32
+ * 壳**三条旧判据一条都不成立**,宿主于是回了 `surface="browser"` / `quitVeto=true`,
33
+ * 客户端照旧武装 ⇒ 仍然退不掉(他抓到的原始回包就是
34
+ * `{"running":[],"surface":"browser","quitVeto":true}`)。⇒ 客户端加**自己的一票否决**:
35
+ * `navigator.userAgent` 含 Electron **或**页面 URL 含 `dsh-desktop-`
36
+ * (报告人那套壳的查询参数前缀)⇒ **一律不武装**,不管宿主怎么说。
37
+ * 语义 = 宿主说"可以武装"只是**不反对**,两者都为真才武装(见 `shouldArmNativeGate`)。
38
+ * 这两条是**页面自身**的属性,不需要壳配合,故能兜住"壳什么都不给"的宿主。
39
+ *
31
40
  * 浏览器 beforeunload 确认框文案/按钮不可自定义(Chromium 一律通用原生框)
32
41
  * ⇒ 本模块只产出语义/文案数据,选型由 client.js 装配层决定
33
42
  * (A 强拦 = 原生门 + 取消后中文明细模态;B 轻确认 = 原生一次确认)。
@@ -152,12 +161,68 @@ export function buildRunningCopy(snapshot, { locale = 'zh', labelOf, now = Date.
152
161
  * `false`(桌面 Electron 页面:壳不处理 will-prevent-unload,否决静默)与
153
162
  * `null`/`undefined`(中性态)都返回 false —— 未知一律取**不武装**的安全侧。
154
163
  * @param {{quitVeto?:boolean|null}} [snapshot] 宿主快照。
155
- * @returns {boolean} 是否可以在本页武装原生 `beforeunload` 门。
164
+ * @returns {boolean} 宿主是否"不反对"武装(注意:还要过客户端一票否决,见 shouldArmNativeGate)。
156
165
  */
157
166
  export function quitVetoOf(snapshot) {
158
167
  return snapshot?.quitVeto === true
159
168
  }
160
169
 
170
+ // ── 客户端一票否决(2026-09-18 第二轮,issue #1 复测仍卡死)────────────────────
171
+ /**
172
+ * 页面 URL 里的桌面壳标记(与宿主侧 `DESKTOP_URL_MARK` 同值,但这里读的是**页面自己**
173
+ * 的 location;外部报告那套壳的查询参数长这样:`?token=…&dsh-desktop-mode=advanced&
174
+ * dsh-desktop-platform=…`)。
175
+ */
176
+ export const CLIENT_DESKTOP_URL_MARK = 'dsh-desktop-'
177
+
178
+ /** 页面 `navigator.userAgent` 里的 Electron 段(Electron 渲染页默认带)。 */
179
+ export const CLIENT_DESKTOP_UA_RE = /electron/i
180
+
181
+ /**
182
+ * 页面自身的桌面证据(纯函数,可单测):UA 含 Electron,或 URL 含桌面壳标记。
183
+ * 为什么要有它:宿主侧的判据要壳配合(挂能力头 / 保留 UA / 带 URL 参数),
184
+ * **页面自己能看见的两条**是最后一道 —— 壳什么都不给、宿主又判错时它兜底。
185
+ * @param {{userAgent?:string, href?:string}} [page] 默认取本页全局环境。
186
+ * @returns {{electronUa:boolean, urlMark:boolean, desktop:boolean}}
187
+ */
188
+ export function clientDesktopEvidence(page = pageEnvironment()) {
189
+ const ua = typeof page?.userAgent === 'string' ? page.userAgent : ''
190
+ const url = typeof page?.href === 'string' ? page.href : ''
191
+ const electronUa = CLIENT_DESKTOP_UA_RE.test(ua)
192
+ const urlMark = url.includes(CLIENT_DESKTOP_URL_MARK)
193
+ return { electronUa, urlMark, desktop: electronUa || urlMark }
194
+ }
195
+
196
+ /** 读本页全局环境(浏览器里 navigator/location 就是全局;取不到一律空串)。 */
197
+ export function pageEnvironment() {
198
+ const g = globalThis
199
+ const ua = g?.navigator?.userAgent
200
+ const href = g?.location?.href
201
+ return {
202
+ userAgent: typeof ua === 'string' ? ua : '',
203
+ href: typeof href === 'string' ? href : '',
204
+ }
205
+ }
206
+
207
+ /**
208
+ * **最终判据**:本页是否可以武装原生 `beforeunload` 门。
209
+ * 语义 = 宿主说"可以武装"只是**不反对**,客户端还有**一票否决**:两道都为真才武装。
210
+ * ① 宿主 `quitVeto === true`(它认为这是会弹原生确认框的普通浏览器页);
211
+ * ② 页面自己**没有**桌面证据(UA 无 Electron 段、URL 无 `dsh-desktop-` 标记)。
212
+ * 任一条不成立 ⇒ 不武装 ⇒ 退化为"仅运行中横幅"(桌面端保护路径,见文件头注释)。
213
+ * @param {{quitVeto?:boolean|null}} [snapshot] 宿主快照。
214
+ * @param {{userAgent?:string, href?:string}} [page] 页面环境(默认取本页全局)。
215
+ */
216
+ export function shouldArmNativeGate(snapshot, page = pageEnvironment()) {
217
+ // 载荷里若**已经**说这是桌面页(surface=desktop-renderer),那就不用再看别的了 ——
218
+ // 客户端也读这个字段(实测:此前完全不读它,于是"自相矛盾载荷"
219
+ // `{surface:'desktop-renderer', quitVeto:true}` + 普通浏览器页仍会被武装。
220
+ // 当前宿主不产出这对值,但这是**廉价且直击**的一道,留着。)
221
+ if (snapshot?.surface === 'desktop-renderer') return false
222
+ if (!quitVetoOf(snapshot)) return false
223
+ return !clientDesktopEvidence(page).desktop
224
+ }
225
+
161
226
  /**
162
227
  * 关闭守卫客户端状态(纯,可单测):快照缓存 + 已放行标记。
163
228
  * 「放行」= 用户在一轮关闭手势中已选 [仍关闭];二次触发 beforeunload 时放行,
@@ -49,15 +49,47 @@
49
49
  * 通道,不新造)。客户端只在宿主明确回 true 时武装原生门(见
50
50
  * lib/close-guard-client.js quitVetoOf)。
51
51
  *
52
- * 判据取**请求级证据**:DSH Desktop 主进程给 renderer 的**每一个** HTTP/WS 请求
53
- * 挂一个代际能力头(`electron-runtime` 的 installRendererAccessHeader;
52
+ * 判据取**请求级证据**。能力头(第一条)是外部报告那个 issue 的**第一次修复**:
53
+ * DSH Desktop 主进程给 renderer 的**每一个** HTTP/WS 请求挂一个代际能力头
54
+ * (`electron-runtime` 的 installRendererAccessHeader;
54
55
  * `webRequest.onBeforeSendHeaders({urls:['<all_urls>']})`,且挂之前先剥掉请求里
55
- * 同名头 ⇒ 页面 JS 伪造不了)。头在 ⇒ 本请求来自 Desktop 的 Electron 页面;
56
- * 头不在且宿主看不出 Electron 痕迹 ⇒ 普通浏览器页面。
56
+ * 同名头 ⇒ 页面 JS 伪造不了)。头在 ⇒ 本请求来自 Desktop 的 Electron 页面。
57
+ *
58
+ * ⚠️ **2026-09-18 第二轮(外部 issue #1 复测仍卡死)**:那条修复只对**挂了能力头的
59
+ * 壳**成立。报告人那台(Dsh Desktop 0.9.0 / Windows)是**纯 Node 跑 harness**
60
+ * (`node.exe … resources\app\harness-node-entry.mjs`)+ Electron 渲染页,三条旧判据
61
+ * 逐条实测都不成立(能力头 0 命中;`desktopBrowserAccess`/`desktopRuntime`/
62
+ * `desktopPnpmBootstrap` 三个服务名 0 命中;`process.versions.electron` 为假)。
63
+ * 旧实现把"**完全没有证据**"归成了 `BROWSER`(`pageSurfaceOf` 末尾直接 return
64
+ * BROWSER)⇒ `quitVeto=true` ⇒ 客户端**照旧武装**原生门 ⇒ 仍然退不掉,而且比
65
+ * 0.9.0 时更隐蔽(空闲也拦)。报告人抓到的原始回包是决定性的:
66
+ * `{"running":[],"surface":"browser","quitVeto":true}`。
67
+ *
68
+ * ⇒ 判据补成**四条请求级证据 + 一条宿主痕迹**,任一请求级证据成立即判桌面页面:
69
+ * ① 能力头 `x-dsh-desktop-renderer` 非空(挂了头的壳);
70
+ * ② 请求 `user-agent` 含 `Electron`(**页面自身属性**,纯 Node 宿主也读得到);
71
+ * ③ 请求 URL 或 **`Referer`** 含 `dsh-desktop-`(报告人那套壳的查询参数前缀
72
+ * `?token=…&dsh-desktop-mode=advanced&dsh-desktop-platform=…`)。
73
+ * ⚠️ 2026-09-19 复核:我们**自己**的轮询打的是 `ROUTE_BASE+'/runningState'`
74
+ * (**无 query**)、wire 通道传 `undefined` ⇒ **URL 这一条对生产请求打不着**;
75
+ * 同源 fetch 默认 `strict-origin-when-cross-origin` 会在 `Referer` 里带**完整页面 URL**
76
+ * ⇒ **Referer 才是宿主侧真能看到页面标记的通道**(两条都留:URL 判据对外部直连调用仍有效);
77
+ * ④ 宿主痕迹(进程是 Electron / 注册了桌面专属服务)→ 中性态 `unknown`。
78
+ * 三条都没有 **且** 宿主没有桌面痕迹 ⇒ 才判 `BROWSER`(真浏览器页照旧武装;
79
+ * "把默认翻过来"会把网页端的保护一起关掉,所以补的是**正向判据**而不是翻默认)。
80
+ *
81
+ * 客户端另有**一票否决**(lib/close-guard-client.js `shouldArmNativeGate`):页面
82
+ * 自己的 `navigator.userAgent` 含 Electron 或页面 URL 含 `dsh-desktop-` ⇒ 一律不
83
+ * 武装。语义 = 宿主说"可以武装"只是"不反对",**两者都为真才武装** —— 两道独立判据
84
+ * 互为兜底(壳可以把 UA 里的 Electron 段改掉,那就靠宿主侧的能力头/URL 判据)。
57
85
  *
58
86
  * 已知中性态(默认取**不武装**这一侧):动态桥 wire 通道拿不到请求对象;宿主能看出
59
- * 自己是 Electron 但请求没带该头(旧版桌面壳/桌面版兼容模式的普通浏览器页)。
60
- * 两种都判 unknown ⇒ 客户端不武装原生门,退化为"仅运行中横幅"。
87
+ * 自己是 Electron 但请求没带任何请求级证据(旧版桌面壳/桌面版兼容模式的普通浏览器页)。
88
+ * 两种都判 unknown(或浏览器页但被客户端否决)⇒ 客户端不武装原生门,退化为
89
+ * "仅运行中横幅"。
90
+ *
91
+ * 探针(`installSurfaceProbe`):把每次判定的**入参与结果**按组合去重写进宿主日志,
92
+ * 供报告人一次跑完就定位是哪条判据生效(不再互相猜)。
61
93
  */
62
94
  // unclosedTurns 直接复用 interrupt-guard(该文件零 import,无环),
63
95
  // 不再内联复制一份——后续 interrupt-guard 演进(如 reason 感知)自动同步。
@@ -170,6 +202,12 @@ export function sessionRunningState(ctx, sessionId) {
170
202
  /** DSH Desktop 主进程给 renderer 每个请求挂的代际能力头(页面 JS 伪造不了)。 */
171
203
  export const DESKTOP_RENDERER_HEADER = 'x-dsh-desktop-renderer'
172
204
 
205
+ /** 请求 URL 里的桌面壳标记(外部报告那套壳的参数前缀,见文件头 ③)。 */
206
+ export const DESKTOP_URL_MARK = 'dsh-desktop-'
207
+
208
+ /** 请求 `user-agent` 里的 Electron 段(Electron 渲染页默认带;壳可改 ⇒ 只作三条之一)。 */
209
+ export const DESKTOP_UA_RE = /electron/i
210
+
173
211
  /** 承载面取值。语义见文件头注释。 */
174
212
  export const PAGE_SURFACE = Object.freeze({
175
213
  /** DSH Desktop 的 Electron 页面:原生 `beforeunload` 门在此静默。 */
@@ -204,32 +242,81 @@ export function desktopHostEvidence(ctx) {
204
242
  return false
205
243
  }
206
244
 
245
+ /**
246
+ * 大小写不敏感读一个请求头,值统一成字符串(缺失/空一律返回 '')。
247
+ * Node 的 `req.headers` 名字已小写,但经序列化/代理的形状未必如此 ⇒ 逐键比对。
248
+ */
249
+ function headerText(headers, name) {
250
+ if (!headers || typeof headers !== 'object') return ''
251
+ try {
252
+ for (const [key, value] of Object.entries(headers)) {
253
+ if (String(key).toLowerCase() !== name) continue
254
+ if (typeof value === 'string') return value
255
+ if (Array.isArray(value)) return value.filter((v) => typeof v === 'string').join(' ')
256
+ return value === undefined || value === null ? '' : String(value)
257
+ }
258
+ } catch { return '' }
259
+ return ''
260
+ }
261
+
207
262
  /** 请求头里是否带 Desktop renderer 能力头(值不看内容:非空即证据)。 */
208
263
  function hasDesktopRendererHeader(headers) {
209
- if (!headers || typeof headers !== 'object') return false
210
- const present = (value) => {
211
- if (typeof value === 'string') return value.length > 0
212
- if (Array.isArray(value)) return value.length > 0
213
- return Boolean(value)
264
+ return headerText(headers, DESKTOP_RENDERER_HEADER).length > 0
265
+ }
266
+
267
+ /** 请求 `user-agent` 是否含 Electron(页面自身属性;纯 Node 宿主也读得到)。 */
268
+ export function isElectronRequest(headers) {
269
+ return DESKTOP_UA_RE.test(headerText(headers, 'user-agent'))
270
+ }
271
+
272
+ /** 某个 URL/字符串是否带桌面壳标记(`dsh-desktop-`)。 */
273
+ export function hasDesktopUrlMark(url) {
274
+ return typeof url === 'string' && url.includes(DESKTOP_URL_MARK)
275
+ }
276
+
277
+ /**
278
+ * **页面标记在宿主侧真正看得见的通道 = `Referer`**(2026-09-19 实测指出):
279
+ * 我们客户端轮询打的是 `ROUTE_BASE + '/runningState'`(**无 query**),wire 通道更是传
280
+ * `undefined` ⇒ 第三条判据(请求 **URL** 含标记)对**生产请求**永远为假,"函数能过"不等于
281
+ * "生产可达"。而同源 `fetch` 默认 `strict-origin-when-cross-origin` 会在 `Referer` 里带上
282
+ * **完整页面 URL**(含 `?…&dsh-desktop-mode=…`)⇒ 那才是宿主侧真能读到页面标记的地方。
283
+ * 两条都保留:URL 判据对外部/直连调用仍有效,Referer 判据覆盖我们自己的轮询。
284
+ */
285
+ export function refererOf(headers) {
286
+ return headerText(headers, 'referer') || headerText(headers, 'referrer')
287
+ }
288
+
289
+ /**
290
+ * 判定用的**入参快照**(探针要把它写进日志,故单独成型、可单测)。
291
+ * @returns {{header:boolean, electronUa:boolean, urlMark:boolean, desktopHost:boolean, ua:string}}
292
+ */
293
+ export function surfaceEvidenceOf(headers, { desktopHost = false, url = '' } = {}) {
294
+ const ua = headerText(headers, 'user-agent')
295
+ return {
296
+ header: hasDesktopRendererHeader(headers),
297
+ electronUa: DESKTOP_UA_RE.test(ua),
298
+ urlMark: hasDesktopUrlMark(url),
299
+ refererMark: hasDesktopUrlMark(refererOf(headers)),
300
+ desktopHost: desktopHost === true,
301
+ // 只留前 160 字符:UA 可能很长,日志不该被它刷屏;够看清有没有 Electron 段。
302
+ ua: ua.length > 160 ? `${ua.slice(0, 160)}…` : ua,
214
303
  }
215
- try {
216
- // Node 的 req.headers 名字已小写;非 Node 形状(经序列化/代理)按大小写不敏感兜底。
217
- for (const [name, value] of Object.entries(headers)) {
218
- if (name.toLowerCase() === DESKTOP_RENDERER_HEADER && present(value)) return true
219
- }
220
- } catch { return false }
221
- return false
222
304
  }
223
305
 
224
306
  /**
225
- * 从**请求头 + 宿主痕迹**判定承载面(纯函数,可单测)。
307
+ * 从**请求证据 + 宿主痕迹**判定承载面(纯函数,可单测)。
308
+ * 顺序即优先级:任一请求级证据成立 ⇒ 桌面页面(不武装);都没有才看宿主痕迹。
226
309
  * @param {object} [headers] Node `req.headers` 形状;拿不到请求对象时为 undefined。
227
- * @param {object} [opts] `{ desktopHost }` = desktopHostEvidence(ctx) 的结果。
310
+ * @param {object} [opts] `{ desktopHost, url }`;url = 该请求的 URL(path+query)。
228
311
  * @returns {'desktop-renderer'|'browser'|'unknown'}
229
312
  */
230
- export function pageSurfaceOf(headers, { desktopHost = false } = {}) {
313
+ export function pageSurfaceOf(headers, { desktopHost = false, url = '' } = {}) {
231
314
  if (hasDesktopRendererHeader(headers)) return PAGE_SURFACE.DESKTOP_RENDERER
232
- // 无能力头。能看出宿主是 Electron ⇒ 中性态(可能是旧版桌面壳没挂头,也可能是
315
+ if (isElectronRequest(headers)) return PAGE_SURFACE.DESKTOP_RENDERER
316
+ if (hasDesktopUrlMark(url)) return PAGE_SURFACE.DESKTOP_RENDERER
317
+ // 页面标记的生产通道(见 refererOf 注释):我们自己的轮询 URL 无 query,标记只在 Referer 里。
318
+ if (hasDesktopUrlMark(refererOf(headers))) return PAGE_SURFACE.DESKTOP_RENDERER
319
+ // 无任何请求级证据。能看出宿主是 Electron ⇒ 中性态(可能是旧版桌面壳没挂头,也可能是
233
320
  // 桌面版兼容模式下的普通浏览器页;两者分不开)⇒ 交给调用方按安全侧处理。
234
321
  if (desktopHost) return PAGE_SURFACE.UNKNOWN
235
322
  return PAGE_SURFACE.BROWSER
@@ -246,14 +333,83 @@ export function quitVetoFor(surface) {
246
333
  }
247
334
 
248
335
  /**
249
- * 给 runningState 载荷用的承载面描述。
336
+ * 判定 + 入参快照(权威入口;`guardSurfaceOf` 与探针都用它)。
337
+ * @returns {{surface:string, quitVeto:boolean|null, evidence:object}}
338
+ */
339
+ export function surfaceDecisionOf(ctx, headers, url) {
340
+ const desktopHost = desktopHostEvidence(ctx)
341
+ const evidence = surfaceEvidenceOf(headers, { desktopHost, url })
342
+ const surface = pageSurfaceOf(headers, { desktopHost, url })
343
+ return { surface, quitVeto: quitVetoFor(surface), evidence }
344
+ }
345
+
346
+ // ── 承载面探针(2026-09-18 第二轮:让报告人一次跑完就定位判据)────────────────────
347
+ let surfaceProbeLog = null
348
+ const surfaceProbeSeen = new Map()
349
+
350
+ /**
351
+ * 装上承载面探针。**按"入参组合"去重**:同一组合只打印第一次(客户端每 5s 轮询一次,
352
+ * 不去重会把日志刷爆)。
353
+ *
354
+ * 组合空间是**有界的**:判定只看四个布尔(能力头 / UA-Electron / URL 标记 / 宿主桌面
355
+ * 痕迹)⇒ 最多 16 种组合,故"不刷屏"是结构性的(组合空间有界),不需要额外的条数上限
356
+ * (那种写法会有死代码 / 写错风险)。
357
+ * @param {(line:string)=>void} log 宿主日志函数(`ctx.logger.info` 那一层)。
358
+ */
359
+ export function installSurfaceProbe(log) {
360
+ surfaceProbeLog = typeof log === 'function' ? log : null
361
+ surfaceProbeSeen.clear()
362
+ if (surfaceProbeLog) {
363
+ surfaceProbeLog(
364
+ 'retrace-close-guard: 承载面探针已启用(按入参组合去重,组合最多 16 种)。判据 = ①能力头 ' +
365
+ `${DESKTOP_RENDERER_HEADER} ②请求 UA 含 Electron ③请求 URL **或 Referer** 含 ${DESKTOP_URL_MARK}` +
366
+ '(页面标记在宿主侧真正可见的通道是 Referer —— 我们自己的轮询 URL 无 query) ④宿主桌面痕迹(→unknown)。' +
367
+ '任一请求级判据成立 ⇒ surface=desktop-renderer / quitVeto=false(客户端不武装原生门)。',
368
+ )
369
+ }
370
+ }
371
+
372
+ /** 卸下探针(dispose/测试用)。 */
373
+ export function uninstallSurfaceProbe() {
374
+ surfaceProbeLog = null
375
+ surfaceProbeSeen.clear()
376
+ }
377
+
378
+ /** 探针状态(测试用:证明"同组合只一次")。 */
379
+ export function surfaceProbeState() {
380
+ return { installed: surfaceProbeLog !== null, combos: surfaceProbeSeen.size }
381
+ }
382
+
383
+ /** 按组合去重地把一次判定写进宿主日志。 */
384
+ function reportSurfaceDecision({ surface, quitVeto, evidence }) {
385
+ if (!surfaceProbeLog) return
386
+ const key = [
387
+ surface, quitVeto === null ? 'null' : String(quitVeto),
388
+ evidence.header ? 'H' : '-', evidence.electronUa ? 'U' : '-',
389
+ evidence.urlMark ? 'R' : '-', evidence.refererMark ? 'F' : '-', evidence.desktopHost ? 'D' : '-',
390
+ ].join('|')
391
+ const times = surfaceProbeSeen.get(key) ?? 0
392
+ surfaceProbeSeen.set(key, times + 1)
393
+ if (times > 0) return
394
+ const yn = (value) => (value ? '有' : '无')
395
+ surfaceProbeLog(
396
+ `retrace-close-guard: 承载面判定 surface=${surface} quitVeto=${quitVeto === null ? 'null' : quitVeto} | 入参 ` +
397
+ `能力头=${yn(evidence.header)} 请求UA-Electron=${yn(evidence.electronUa)} URL标记=${yn(evidence.urlMark)} ` +
398
+ `Referer标记=${yn(evidence.refererMark)} 宿主桌面痕迹=${yn(evidence.desktopHost)} | UA="${evidence.ua}"`,
399
+ )
400
+ }
401
+
402
+ /**
403
+ * 给 runningState 载荷用的承载面描述(宿主面**唯一的**判定入口)。
250
404
  * @param {object} ctx - cordis ctx(用于看宿主痕迹)。
251
405
  * @param {object} [headers] - 发起该请求的 `req.headers`;wire/harness 通道传 undefined。
406
+ * @param {string} [url] - 发起该请求的 URL(path+query);wire/harness 通道传 undefined。
252
407
  * @returns {{ surface:string, quitVeto:boolean|null }}
253
408
  */
254
- export function guardSurfaceOf(ctx, headers) {
255
- const surface = pageSurfaceOf(headers, { desktopHost: desktopHostEvidence(ctx) })
256
- return { surface, quitVeto: quitVetoFor(surface) }
409
+ export function guardSurfaceOf(ctx, headers, url) {
410
+ const decision = surfaceDecisionOf(ctx, headers, url)
411
+ reportSurfaceDecision(decision)
412
+ return { surface: decision.surface, quitVeto: decision.quitVeto }
257
413
  }
258
414
 
259
415
  /**
@@ -267,6 +267,29 @@ return {
267
267
  function quitVetoOf(snapshot) {
268
268
  return snapshot?.quitVeto === true;
269
269
  }
270
+ var CLIENT_DESKTOP_URL_MARK = "dsh-desktop-";
271
+ var CLIENT_DESKTOP_UA_RE = /electron/i;
272
+ function clientDesktopEvidence(page = pageEnvironment()) {
273
+ const ua = typeof page?.userAgent === "string" ? page.userAgent : "";
274
+ const url = typeof page?.href === "string" ? page.href : "";
275
+ const electronUa = CLIENT_DESKTOP_UA_RE.test(ua);
276
+ const urlMark = url.includes(CLIENT_DESKTOP_URL_MARK);
277
+ return { electronUa, urlMark, desktop: electronUa || urlMark };
278
+ }
279
+ function pageEnvironment() {
280
+ const g = globalThis;
281
+ const ua = g?.navigator?.userAgent;
282
+ const href = g?.location?.href;
283
+ return {
284
+ userAgent: typeof ua === "string" ? ua : "",
285
+ href: typeof href === "string" ? href : ""
286
+ };
287
+ }
288
+ function shouldArmNativeGate(snapshot, page = pageEnvironment()) {
289
+ if (snapshot?.surface === "desktop-renderer") return false;
290
+ if (!quitVetoOf(snapshot)) return false;
291
+ return !clientDesktopEvidence(page).desktop;
292
+ }
270
293
  function createGuardStore({ now = Date.now, armTtlMs = GUARD_ARM_TTL_MS } = {}) {
271
294
  let snapshot = null;
272
295
  let armedAt = 0;
@@ -478,7 +501,7 @@ return {
478
501
  "path.round": "\u7B2C {n} \u8F6E",
479
502
  "path.empty": "\uFF08\u5F53\u524D\u6CA1\u6709\u5BF9\u8BDD\u8F6E\u6B21\uFF09",
480
503
  "options.closeGuard": "\u9000\u51FA\u786E\u8BA4\uFF08\u5173\u95ED\u5B88\u536B\uFF09",
481
- "options.closeGuardDesc": "\u5F00\uFF08\u9ED8\u8BA4\uFF09\uFF1A\u9875\u9762\u5173\u95ED\u524D\u786E\u8BA4\u9000\u51FA\u2014\u2014\u6709\u8FD0\u884C\u4E2D\u4EFB\u52A1\u6216\u672A\u5B8C\u6210\u5BF9\u8BDD\u65F6\u5F3A\u62E6\u5E76\u5217\u51FA\u660E\u7EC6\uFF08\u9632\u8BEF\u5173\u4E22\u8FDB\u5EA6\uFF09\uFF0C\u65E0\u4EFB\u52A1\u65F6\u8F7B\u786E\u8BA4\u4E00\u6B21\u3002\u53EA\u5728\u5BBF\u4E3B\u786E\u8BA4\u4F1A\u663E\u793A\u539F\u751F\u786E\u8BA4\u6846\u7684\u9875\u9762\u4E0A\u6B66\u88C5\uFF08\u666E\u901A\u6D4F\u89C8\u5668\u6807\u7B7E\u9875\uFF1B\u5173\u6389\u672C\u9879\u540C\u65F6\u505C\u7528\u8FD0\u884C\u4E2D\u6A2A\u5E45\uFF09\u3002\u684C\u9762\u7AEF\uFF08DSH Desktop\uFF09\u4E00\u5F8B\u4E0D\u6B66\u88C5\uFF1A\u9000\u51FA\u5165\u53E3\u968F\u7248\u672C/\u5E73\u53F0\u800C\u53D8\u2014\u2014\u5916\u90E8\u62A5\u544A\u7684 0.9.0 \u4E00\u7C7B\u5165\u53E3\u4F1A\u89E6\u53D1\u9875\u9762 beforeunload\uFF0C\u800C\u684C\u9762\u58F3\u672A\u5904\u7406 will-prevent-unload \u21D2 \u5426\u51B3\u88AB\u9759\u9ED8\u541E\u6389\u3001\u9000\u51FA\u5361\u4F4F\uFF1B2.0.9 \u7684\u6258\u76D8\u9879\u8D70 requestQuit\u2192window.destroy\u2192app.exit\uFF0C\u6839\u672C\u4E0D\u7ECF\u8FC7\u5B83\u3002\u9875\u9762\u91CC\u5206\u4E0D\u51FA\u662F\u54EA\u4E00\u7C7B\uFF0C\u6545\u684C\u9762\u4FDD\u62A4\u7531\u8FD0\u884C\u4E2D\u6A2A\u5E45\u627F\u62C5\u3002"
504
+ "options.closeGuardDesc": "\u5F00\uFF08\u9ED8\u8BA4\uFF09\uFF1A\u9875\u9762\u5173\u95ED\u524D\u786E\u8BA4\u9000\u51FA\u2014\u2014\u6709\u8FD0\u884C\u4E2D\u4EFB\u52A1\u6216\u672A\u5B8C\u6210\u5BF9\u8BDD\u65F6\u5F3A\u62E6\u5E76\u5217\u51FA\u660E\u7EC6\uFF08\u9632\u8BEF\u5173\u4E22\u8FDB\u5EA6\uFF09\uFF0C\u65E0\u4EFB\u52A1\u65F6\u8F7B\u786E\u8BA4\u4E00\u6B21\u3002\u53EA\u5728\u5BBF\u4E3B\u786E\u8BA4\u4F1A\u663E\u793A\u539F\u751F\u786E\u8BA4\u6846\u7684\u9875\u9762\u4E0A\u6B66\u88C5\uFF08\u666E\u901A\u6D4F\u89C8\u5668\u6807\u7B7E\u9875\uFF1B\u5173\u6389\u672C\u9879\u540C\u65F6\u505C\u7528\u8FD0\u884C\u4E2D\u6A2A\u5E45\uFF09\u3002\u684C\u9762\u7AEF\uFF08DSH Desktop\uFF09\u4E00\u5F8B\u4E0D\u6B66\u88C5\uFF1A\u9000\u51FA\u5165\u53E3\u968F\u7248\u672C/\u5E73\u53F0\u800C\u53D8\u2014\u2014\u5916\u90E8\u62A5\u544A\u7684 0.9.0 \u4E00\u7C7B\u5165\u53E3\u4F1A\u89E6\u53D1\u9875\u9762 beforeunload\uFF0C\u800C\u684C\u9762\u58F3\u672A\u5904\u7406 will-prevent-unload \u21D2 \u5426\u51B3\u88AB\u9759\u9ED8\u541E\u6389\u3001\u9000\u51FA\u5361\u4F4F\uFF1B2.0.9 \u7684\u6258\u76D8\u9879\u8D70 requestQuit\u2192window.destroy\u2192app.exit\uFF0C\u6839\u672C\u4E0D\u7ECF\u8FC7\u5B83\u3002\u5224\u636E\u662F\u4E24\u9053\uFF1A\u5BBF\u4E3B\u8BF4\u4F1A\u5F39\u539F\u751F\u6846\uFF08quitVeto\uFF09\uFF0C\u4E14\u672C\u9875\u81EA\u5DF1\u770B\u4E0D\u51FA\u684C\u9762\u75D5\u8FF9\uFF08UA \u65E0 Electron\u3001URL \u65E0 dsh-desktop-\uFF09\u2014\u2014\u4EFB\u4E00\u6761\u4E0D\u6210\u7ACB\u5C31\u4E0D\u6B66\u88C5\uFF1B\u56E0\u6B64\u9875\u9762\u91CC\u5206\u4E0D\u51FA\u662F\u54EA\u4E00\u7C7B\u58F3\u4E5F\u6CA1\u5173\u7CFB\uFF0C\u684C\u9762\u4FDD\u62A4\u7531\u8FD0\u884C\u4E2D\u6A2A\u5E45\u627F\u62C5\u3002\u684C\u9762\u7AEF\u9000\u4E0D\u6389\u65F6\u5148\u5173\u8FD9\u4E00\u9879\uFF08\u7ACB\u5373\u6062\u590D\uFF0C\u4E0D\u7528\u91CD\u542F\uFF09\u3002"
482
505
  };
483
506
  var en = {
484
507
  "action.edit": "Edit",
@@ -625,7 +648,7 @@ return {
625
648
  "path.round": "Round {n}",
626
649
  "path.empty": "(no conversation rounds yet)",
627
650
  "options.closeGuard": "Exit confirmation (close guard)",
628
- "options.closeGuardDesc": "On (default): confirm before the page closes \u2014 when sessions have running or unfinished work a strong guard lists them (prevents accidental progress loss); otherwise a light one-time confirm. Armed only where the host reports a native confirm dialog (ordinary browser tabs; turning this off also disables the running banner). Desktop (DSH Desktop) never arms it: quit entries differ by version/platform \u2014 the 0.9.0-class entry in the external report does reach page beforeunload, and the shell has no will-prevent-unload handler, so the veto is swallowed silently and the exit hangs; the 2.0.9 tray item goes requestQuit\u2192window.destroy\u2192app.exit and never reaches it. The page cannot tell which one it is, so the running banner carries that protection instead."
651
+ "options.closeGuardDesc": "On (default): confirm before the page closes \u2014 when sessions have running or unfinished work a strong guard lists them (prevents accidental progress loss); otherwise a light one-time confirm. Armed only where the host reports a native confirm dialog (ordinary browser tabs; turning this off also disables the running banner). Desktop (DSH Desktop) never arms it: quit entries differ by version/platform \u2014 the 0.9.0-class entry in the external report does reach page beforeunload, and the shell has no will-prevent-unload handler, so the veto is swallowed silently and the exit hangs; the 2.0.9 tray item goes requestQuit\u2192window.destroy\u2192app.exit and never reaches it. Two criteria must both hold: the host says a native dialog appears (quitVeto) AND this page shows no desktop evidence itself (no Electron in the UA, no dsh-desktop- in the URL); if either fails it stays unarmed, so it does not matter that the page cannot tell which shell it is \u2014 the running banner carries that protection instead. If Desktop cannot quit, turn this off first (recovers immediately, no restart)."
629
652
  };
630
653
  function opFailureText(code, message, t) {
631
654
  if (code === "agent-busy") return t("error.busy");
@@ -2896,7 +2919,7 @@ return {
2896
2919
  if (store.isArmed()) return;
2897
2920
  const kind = classifySnapshot(store.get());
2898
2921
  if (kind === "unknown") return;
2899
- if (!quitVetoOf(store.get())) return;
2922
+ if (!shouldArmNativeGate(store.get())) return;
2900
2923
  try {
2901
2924
  event.preventDefault?.();
2902
2925
  } catch {
package/lib/http.js CHANGED
@@ -297,7 +297,9 @@ export function createRetraceHttpHandler(ctx, { sessions, agents, seam, rollback
297
297
  * runningSessions(ctx) 全会话运行中清单(agent/queued/jobs/未闭合轮)。
298
298
  * 无 sessionId → { running: [...] };有 → 单会话 runningState。
299
299
  * 全会话形状另带 { surface, quitVeto }(承载面,见文件头):**这一段是宿主侧唯一的
300
- * 承载面证据来源** —— 请求头带 Desktop renderer 能力头就是 Electron 页面。
300
+ * 承载面证据来源** —— 请求级四条判据(能力头 / 请求 UA 含 Electron / 请求 URL **或 Referer** 含
301
+ * `dsh-desktop-`)+ 宿主桌面痕迹;任一请求级判据成立 ⇒ 桌面页面(客户端不武装原生门)。
302
+ * 注:我们自己的轮询 URL 无 query ⇒ 页面标记实际是从 **Referer** 读到的(见 close-guard.js refererOf)。
301
303
  * 纯读,无副作用(守卫只检测+确认,绝不中断/写事件)。
302
304
  */
303
305
  function handleRunningState(req, res, hostCtx, sessions, agents, searchParams) {
@@ -308,7 +310,10 @@ export function createRetraceHttpHandler(ctx, { sessions, agents, seam, rollback
308
310
  const sessionId = searchParams.get('sessionId') ?? ''
309
311
  const value = sessionId
310
312
  ? sessionRunningState(guardCtx, sessionId)
311
- : { running: runningSessions(guardCtx), ...guardSurfaceOf(hostCtx, req?.headers) }
313
+ // url 一并传 + 宿主自己读 Referer:报告人那套壳把桌面标记放在查询参数里
314
+ // (`?…&dsh-desktop-mode=…`),而**我们自己的轮询 URL 没有 query** ⇒ 标记只在 Referer 里
315
+ // (见 close-guard.js refererOf / pageSurfaceOf)。
316
+ : { running: runningSessions(guardCtx), ...guardSurfaceOf(hostCtx, req?.headers, req?.url) }
312
317
  sendJson(res, 200, { ok: true, value })
313
318
  } catch (error) {
314
319
  sendError(res, error, log)
@@ -512,7 +517,7 @@ export function createRetraceHttpHandler(ctx, { sessions, agents, seam, rollback
512
517
  const sid = String(args?.sessionId ?? '')
513
518
  return sid
514
519
  ? sessionRunningState(guardCtx, sid)
515
- : { running: runningSessions(guardCtx), ...guardSurfaceOf(ctx, req?.headers) }
520
+ : { running: runningSessions(guardCtx), ...guardSurfaceOf(ctx, req?.headers, req?.url) }
516
521
  })
517
522
  }
518
523
  // 客户端半自报(2026-09-14 事故 2 的可见性补丁):证明"客户端半装载了 + 注册了
package/lib/index.js CHANGED
@@ -28,7 +28,10 @@ import { createMarkerGuard } from './prewrite-guard.js'
28
28
  import { createWatchdog } from './watchdog.js'
29
29
  import { createBootPinRetry } from './boot-pin.js'
30
30
  import { attachExitWarning } from './interrupt-guard.js'
31
- import { attachCloseGuard, runningSessions, sessionRunningState, guardSurfaceOf } from './close-guard.js'
31
+ import {
32
+ attachCloseGuard, runningSessions, sessionRunningState, guardSurfaceOf,
33
+ installSurfaceProbe, uninstallSurfaceProbe,
34
+ } from './close-guard.js'
32
35
  import { sessionBadge as fnvBadge } from './badge.js'
33
36
  import { dshAdapter, semanticBadgeOf } from './adapter/dsh.js'
34
37
  // span 显式状态结果 → 业务层判定输入的统一下传(单一真相模块)
@@ -404,9 +407,10 @@ export function apply(ctx) {
404
407
  return { ok: true, value: sessionRunningState(ctx, sid) }
405
408
  }
406
409
  // 负载形状与 HTTP 面同构(含宿主承载面)。wire 通道拿不到请求对象 ⇒
407
- // guardSurfaceOf(ctx, undefined) 只能靠宿主痕迹判:桌面宿主 → unknown
408
- // (客户端不武装原生门,安全侧),web 宿主 → browser。
409
- return { ok: true, value: { running: runningSessions(ctx), ...guardSurfaceOf(ctx, undefined) } }
410
+ // guardSurfaceOf(ctx, undefined, undefined) 只能靠宿主痕迹判:桌面宿主 →
411
+ // unknown(客户端不武装原生门,安全侧),web 宿主 → browser。**客户端那边
412
+ // 还有一票否决**(UA/URL),所以这条通道判错也不会把桌面端卡死。
413
+ return { ok: true, value: { running: runningSessions(ctx), ...guardSurfaceOf(ctx, undefined, undefined) } }
410
414
  } catch (error) {
411
415
  return { ok: false, error: { code: 'internal', message: String(error) } }
412
416
  }
@@ -427,11 +431,18 @@ export function apply(ctx) {
427
431
  // host 无关闭前弹窗 seam → 替代路径 = dispose 强提示 + runningState handler(client 可查)。
428
432
  const guardClose = attachCloseGuard(ctx, log)
429
433
 
434
+ // 承载面探针(2026-09-18 第二轮,issue #1):把每次判定的**入参(能力头 / 请求 UA /
435
+ // 请求 URL 标记 / 宿主桌面痕迹)与结果(surface/quitVeto)**按组合去重写进宿主日志。
436
+ // 用途:报告人跑一次就能确认是哪条判据生效(不必再互相猜是哪一层判反了)。
437
+ // 只打日志,不改任何判定。
438
+ installSurfaceProbe(log)
439
+
430
440
  ctx.effect(() => () => {
431
441
  disposeRoute()
432
442
  disposeHarness()
433
443
  watchdog.dispose()
434
444
  warnUnclosed()
435
445
  guardClose()
446
+ uninstallSurfaceProbe()
436
447
  }, 'dsh-retrace: transports')
437
448
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-retrace",
3
3
  "description": "Retrace · 回溯 — Recall, edit-and-resend, regenerate, and conversation/artifact versioning (timeline, rollback, fork map) for DeepSeek Harness — Web and Desktop",
4
- "version": "0.4.29",
4
+ "version": "0.4.30",
5
5
  "packageManager": "pnpm@11.7.0",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",