@twinsearth/w2m-dsh-plugin 1.7.7 → 1.7.9

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.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "The w2m fleet plugins: one account, many machines, any of Windows, macOS or Linux.",
8
- "version": "1.7.7"
8
+ "version": "1.7.9"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "w2m-fleet",
13
13
  "source": "./",
14
14
  "description": "Read fleet state and dispatch commands across the machines registered to one account. Read-only MCP surface: the five exposed tools cannot change anything.",
15
- "version": "1.7.7",
15
+ "version": "1.7.9",
16
16
  "author": {
17
17
  "name": "TwinsEarth"
18
18
  },
@@ -26,5 +26,5 @@
26
26
  ]
27
27
  }
28
28
  ],
29
- "version": "1.7.7"
29
+ "version": "1.7.9"
30
30
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "w2m-fleet",
3
3
  "description": "Drive the same project on online Windows, macOS and Linux machines from one account: read fleet state, dispatch commands and read results. The MCP surface is deliberately read-only.",
4
- "version": "1.7.7",
4
+ "version": "1.7.9",
5
5
  "author": {
6
6
  "name": "TwinsEarth"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -7,6 +7,45 @@
7
7
 
8
8
  All notable changes to this project are documented here.
9
9
 
10
+ ## [1.7.9] — 2026-10-26
11
+
12
+ **GPW 三项操作性问题修复:拦截即时弹选择、名单长列表折叠、名单文件防篡改。**
13
+
14
+ - **名单策略调整:文件夹包含子文件夹**:名单条目按「根目录 + 全部子文件夹」递归匹配(`E:\work` 覆盖 `E:\work\a\b\c.txt`,`E:\workshop` 不会被误匹配;嵌套黑名单仍优先);命令工具里的相对路径按会话工作区解析后再分类。新增测试覆盖递归覆盖、同前缀兄弟目录与相对路径。
15
+ - **发布门禁稳定性**:`test/mcp-http-live.test.mjs` 的 relay 实连用例在出口网络不可达(或 MCP 工具层报 "could not reach the relay")时明确 `skip`,网络抖动不再把 release 门禁判红;其余用例仍覆盖本地绑定行为。
16
+ - **拦截即审批(不再静默挂起)**:`tools/pre-execute` 挂起 C 类调用后,客户端在 **composer 行**
17
+ (`conversation.input.left`)新增内联提示 `GpwPrompt`:显示工具名与「允许本次 / 拒绝本次 / 打开面板」;
18
+ 多个待审批显示数量。浮窗按钮增加待审批数量徽标,`w2m-gpw-open` 事件让内联提示直接打开面板。
19
+ - **名单折叠**:白名单、黑名单、最近被拦截默认各显示 3 条,其余通过「另有 N 个,展开 / 收起」折叠;
20
+ 待审批项新增「加入白名单并允许」「加入黑名单并拒绝」一键动作(服务端 pending 项补充 `folder`)。
21
+ - **名单文件防篡改(fail-closed)**:`$DSH_HOME/xclient/gpw-folders.json` 改为 **MAC 签名 v2(HMAC-SHA256)
22
+ + 原子写(tmp→rename)+ 写后只读 0444**。密钥在旁路 `*.key`(0600;或配置 `foldersKey` /
23
+ `W2M_GPW_FOLDERS_KEY`)。
24
+ - 外部修改(MAC 不匹配)、删除签名文件、去掉签名 → 状态 `tampered`,门控 **fail-closed**:所有命名路径的
25
+ 调用被直接拒绝;面板显示警告并提供「重置名单(清空并重新签名)」。
26
+ - 旧版无签名文件在首次启动时一次性迁移为 v2;升级后再次出现的无签名/缺失文件视为篡改。
27
+ - 诚实边界:DSH 与插件同用户运行时,OS 只读位可被有意的进程绕过;但签名校验使**静默改名单不可能**,
28
+ 篡改会被检测并锁定(需操作员重置)。强隔离需独立用户/服务账号(后续项)。
29
+ - **名单继承保证**:黑白名单与其 MAC 密钥位于 `$DSH_HOME/xclient/`(插件包之外),升级、重启、卸载重装都会原样继承;只有面板中的显式「移除 / 重置名单」会改变它。新增 `test/gpw-persistence.test.mjs`(5 项)覆盖。
30
+ - 测试:新增 test/gpw-hardening.test.mjs(8 项:签名往返、只读拒绝直写、MAC 不匹配/删除检测、legacy 标记、reset、密钥 sidecar)
31
+ 与 test/gpw-tamper-gate.test.mjs(4 项:篡改 fail-closed → 409 → 重置恢复 → 内联审批放行;legacy 迁移;客户端内联提示/折叠/重置静态断言)。
32
+ reset、密钥 sidecar)。`client-bundle` 更新为五个 composer 控件;`gpw-activation` 用临时名单文件隔离,
33
+ 不再写真实 `~/.dsh`。
34
+ - 证据:`gpw-hardening 8/8`、`gpw-folder-policy 9/9`、`gpw-activation 4/4`、`client-bundle 26/26`(node 进程内运行)。
35
+
36
+ ## [1.7.8] — 2026-10-26
37
+
38
+ **GPW 启用失败修复(第二个):`TypeError: Cannot read properties of undefined (reading 'layers')`。**
39
+
40
+ - 根因:v1.7.7 为做黑名单同步拒绝,在**激活期**调用根上下文的 `ctx.tools.guard()`。tools 服务是**按 agent 组合**的,
41
+ 根作用域实例在激活阶段还没有 layer 表,于是抛 `reading 'layers'`,dsh-gpw 仍无法激活。
42
+ - 修复:改为在 `agent/created` 监听里对**每个 agent 自己的上下文** `agent.ctx.tools.guard(...)` 注册黑名单守卫,
43
+ 并用 `ctx.effect` 统一释放;根上下文不再读取 tools 服务(`inject` 回到只有 `permissionPresets`)。
44
+ - 保留 `tools/pre-execute` 三分类拦截;guard 缺失的宿主不会因此激活失败(pre-execute 仍会拒绝黑名单)。
45
+ - 回归测试升级:`test/gpw-activation.test.mjs` 现在用严格 Cordis 语义 Proxy(读取根 `tools` 即抛错)验证激活,
46
+ 并通过伪造 `agent/created` 断言守卫注册在 agent 上下文。
47
+ - 同时补全 bundle patch 中 GPW v2 的配置项(requireFullAccess / whitelist / blacklist / pendingTimeoutMs)。
48
+ - 证据:`docs/acceptance/1.7.8/GPW-AGENT-SCOPE.json`。
10
49
  ## [1.7.7] — 2026-10-26
11
50
 
12
51
  **GPW 子插件启用失败修复:`cannot get property "tools" without inject`。**
@@ -12,6 +12,6 @@
12
12
 
13
13
  [mcp_servers.w2m-fleet]
14
14
  command = "npx"
15
- args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.7.7", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.7.9", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
package/cordis.patch.yml CHANGED
@@ -134,4 +134,10 @@
134
134
  maxPerWindow: 10
135
135
  windowMs: 60000
136
136
  minGapMs: 3000
137
+ # v1.7.5+: only act in the 完全权限 preset; the two folder lists are the gate's policy.
138
+ # Folders are added at runtime from the GPW panel into $DSH_HOME/xclient/gpw-folders.json.
139
+ requireFullAccess: true
140
+ whitelist: []
141
+ blacklist: []
142
+ pendingTimeoutMs: 120000
137
143
 
@@ -0,0 +1,21 @@
1
+ {
2
+ "version": "1.7.8",
3
+ "case": "GPW-AGENT-SCOPE",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-25T16:57:54.149Z",
9
+ "executor": "lead",
10
+ "task_id": "local-gpw-scope-20261025165754",
11
+ "requirement": "enabling dsh-gpw must activate it under a full CORDIS context: no root tools read, the pre-execute gate installed, and the blacklist guard registered on each agent context",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "symptom": "TypeError: Cannot read properties of undefined (reading 'layers')",
16
+ "cause": "ctx.tools.guard was called at activation on the root context; the tools service is composed per agent and has no layer table there",
17
+ "fix": "agent/created -> agent.ctx.tools.guard per agent, disposers released through ctx.effect; inject = [permissionPresets]",
18
+ "regression": "test/gpw-activation.test.mjs: strict Proxy throws on undeclared/root-tools reads; activation, agent/created guard installation and source checks all pass"
19
+ }
20
+ }
21
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "version": "1.7.9",
3
+ "case": "GPW-HARDENING",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-25T23:26:10.976Z",
9
+ "executor": "lead",
10
+ "task_id": "local-gpw-hardening-20261025232610",
11
+ "requirement": "a pending class-C call is visible and answerable inline, the three folder lists collapse after three rows, and the folder lists file is MAC-signed and read-only so an external edit is detected and fails closed until the operator resets",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "inline_prompt": "GpwPrompt is mounted on conversation.input.left (id w2m-gpw-prompt): pending count + tool name + allow/deny/open-panel; the launcher shows a badge too",
16
+ "collapsed_lists": "whitelist / blacklist / recently intercepted each show 3 rows, then \"另有 N 个,展开/收起\" with per-list state",
17
+ "signing": "v2 HMAC-SHA256 covers both lists; key from foldersKey config / W2M_GPW_FOLDERS_KEY / sidecar *.key (0600); atomic tmp->rename write then 0444 read-only",
18
+ "tamper_fail_closed": "missing / unreadable / bad-MAC / unsigned-after-seen -> tampered: every path-bearing call is denied (409 in clients) until POST /api/w2m/gpw/reset re-signs",
19
+ "legacy_migration": "an unsigned pre-1.7.9 file is migrated once, then treated as tampered if it reappears",
20
+ "tests": "gpw-hardening 8/8; gpw-tamper-gate 4/4; gpw-persistence 5/5; gpw-activation 4/4; gpw-folder-policy 12/12 (recursive + relative cases included); client-bundle 26/26; claims/installed-version/acceptance-doc pass",
21
+ "pack": "scripts/pack.mjs builds the tarball deterministically; the canonical sha256 is published on the GitHub Release asset and verified equal to the npm tarball after release",
22
+ "list_inheritance": "lists and MAC key live under $DSH_HOME/xclient (outside the plugin package): upgrades, restarts and uninstall/reinstall reuse the same signed file; only explicit panel actions (remove row / reset) change the lists — covered by test/gpw-persistence.test.mjs",
23
+ "recursive_folder_lists": "a listed folder is a root covering every subfolder (E:\\work matches E:\\work\\a\\b\\c.txt; E:\\workshop does not match); a nested blacklist root still wins; relative command paths are resolved against the session workspace before classification — covered by gpw-folder-policy tests"
24
+ }
25
+ }
26
+ }
package/lib/client.js CHANGED
@@ -155,6 +155,16 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
155
155
  gpwPendingEmpty: '当前没有待审批调用',
156
156
  gpwAllowOnce: '允许本次',
157
157
  gpwDenyOnce: '拒绝本次',
158
+ gpwMore: '另有 {count} 个,展开',
159
+ gpwFold: '收起',
160
+ gpwAddWhiteAllow: '加入白名单并允许',
161
+ gpwAddBlackDeny: '加入黑名单并拒绝',
162
+ gpwPendingShort: 'GPW 待审批:{tool}',
163
+ gpwPendingCount: '有 {count} 个调用等待审批',
164
+ gpwOpenPanel: '打开面板',
165
+ gpwTampered: '名单完整性校验失败({reason}),名单外路径已锁定。',
166
+ gpwReset: '重置名单(清空并重新签名)',
167
+ gpwResetConfirm: '确认重置?将清空白名单和黑名单的全部条目。',
158
168
  },
159
169
  en: {
160
170
  all: 'All machines',
@@ -207,6 +217,16 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
207
217
  gpwPendingEmpty: 'No calls are waiting for approval',
208
218
  gpwAllowOnce: 'Allow this call',
209
219
  gpwDenyOnce: 'Deny this call',
220
+ gpwMore: 'Show {count} more',
221
+ gpwFold: 'Show less',
222
+ gpwAddWhiteAllow: 'Always allow (add to whitelist)',
223
+ gpwAddBlackDeny: 'Always deny (add to blacklist)',
224
+ gpwPendingShort: 'GPW pending: {tool}',
225
+ gpwPendingCount: '{count} call(s) waiting for approval',
226
+ gpwOpenPanel: 'Open panel',
227
+ gpwTampered: 'Folder lists failed integrity check ({reason}); non-listed paths are locked.',
228
+ gpwReset: 'Reset lists (clear + re-sign)',
229
+ gpwResetConfirm: 'Reset the lists? Every whitelist and blacklist entry will be cleared.',
210
230
  },
211
231
  };
212
232
 
@@ -407,6 +427,9 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
407
427
  '.w2mCap_dot[data-on="true"]{background:var(--dsw-alias-state-warn-primary)}',
408
428
  '.w2mGpw_launcher{position:fixed;right:16px;bottom:96px;z-index:55;padding:6px 10px;border-radius:var(--dsw-radius-lg);border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft);font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary);cursor:pointer}',
409
429
  '.w2mGpw_launcher:hover{background:var(--dsw-alias-bg-layer-2)}',
430
+ '.w2mGpw_prompt{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:2px 4px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary)}',
431
+ '.w2mGpw_promptLabel{font-size:12px;color:var(--dsw-alias-state-warn-primary,var(--dsw-alias-label-primary))}',
432
+ '.w2mGpw_badge{display:inline-block;min-width:16px;padding:0 5px;margin-left:4px;border-radius:999px;border:.5px solid var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary);background:var(--dsw-alias-bg-layer-1);font-size:11px;line-height:14px;text-align:center}',
410
433
  '.w2mGpw_overlay{position:fixed;inset:0;z-index:70;background:color-mix(in srgb, var(--dsw-alias-label-primary) 24%, transparent);display:flex;align-items:flex-end;justify-content:flex-end;padding:16px}',
411
434
  '.w2mGpw_card{width:420px;max-width:calc(100vw - 32px);max-height:min(80vh,640px);overflow:auto;box-sizing:border-box;padding:10px 12px;border-radius:var(--dsw-radius-xl);border:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft);font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);display:flex;flex-direction:column;gap:6px}',
412
435
  '.w2mGpw_head{display:flex;align-items:center;justify-content:space-between}',
@@ -545,6 +568,7 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
545
568
  const [busy, setBusy] = React.useState(false);
546
569
  const [manual, setManual] = React.useState('');
547
570
  const [badPath, setBadPath] = React.useState(null);
571
+ const [expanded, setExpanded] = React.useState({});
548
572
  const call = React.useCallback(async (url, init) => {
549
573
  try {
550
574
  const response = await fetch(url, { credentials: 'same-origin', headers: { accept: 'application/json', ...(init?.body === undefined ? {} : { 'content-type': 'application/json' }) }, ...init });
@@ -588,13 +612,37 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
588
612
  setBusy(true);
589
613
  try { await call('/api/w2m/gpw/decide', { method: 'POST', body: JSON.stringify({ id, decision }) }); await load(); } finally { setBusy(false); }
590
614
  };
591
- const folderList = (title, list) => h('div', { key: list, className: 'w2mGpw_block' }, [
592
- h('div', { key: 'title', className: 'w2mGpw_title' }, title),
593
- ...(state?.folders?.[list] ?? []).map((path) => h('div', { key: path, className: 'w2mGpw_row' }, [
594
- h('span', { key: 'p', className: 'w2mGpw_path', title: path }, path),
595
- h('button', { key: 'x', type: 'button', className: 'w2mGpw_small', disabled: busy, onClick: () => removeFolder(list, path) }, '×'),
596
- ])),
597
- ]);
615
+ /** v1.7.9: after a tamper report the signed lists are locked until the operator resets them. */
616
+ const resetLists = async () => {
617
+ if (typeof window !== 'undefined' && typeof window.confirm === 'function' && window.confirm(t('gpwResetConfirm')) !== true) return;
618
+ setBusy(true);
619
+ try { await call('/api/w2m/gpw/reset', { method: 'POST', body: JSON.stringify({ confirm: 'reset' }) }); await load(); } finally { setBusy(false); }
620
+ };
621
+ /** v1.7.9: lists show three rows by default; the rest fold behind one toggle. */
622
+ const foldItems = (items, key) => (expanded[key] === true ? items : items.slice(0, 3));
623
+ const foldRow = (items, key) => (items.length <= 3 ? null : h('button', {
624
+ key: 'fold-' + key,
625
+ type: 'button',
626
+ className: 'w2mGpw_btn',
627
+ disabled: busy,
628
+ onClick: () => setExpanded((prev) => ({ ...prev, [key]: prev[key] !== true })),
629
+ }, expanded[key] === true ? t('gpwFold') : t('gpwMore', { count: items.length - 3 })));
630
+ const allowWithFolder = async (item, list) => {
631
+ if (typeof item?.folder !== 'string' || item.folder === '') return;
632
+ const added = await addFolder(list, item.folder);
633
+ if (added === true) await decide(item.id, list === 'whitelist' ? 'allow' : 'deny');
634
+ };
635
+ const folderList = (title, list) => {
636
+ const items = Array.isArray(state?.folders?.[list]) ? state.folders[list] : [];
637
+ return h('div', { key: list, className: 'w2mGpw_block' }, [
638
+ h('div', { key: 'title', className: 'w2mGpw_title' }, title),
639
+ ...foldItems(items, list).map((path) => h('div', { key: path, className: 'w2mGpw_row' }, [
640
+ h('span', { key: 'p', className: 'w2mGpw_path', title: path }, path),
641
+ h('button', { key: 'x', type: 'button', className: 'w2mGpw_small', disabled: busy, onClick: () => removeFolder(list, path) }, '×'),
642
+ ])),
643
+ foldRow(items, list),
644
+ ]);
645
+ };
598
646
  const recent = Array.isArray(state?.recent) ? state.recent : [];
599
647
  const pending = Array.isArray(state?.pending) ? state.pending : [];
600
648
  return h('div', { className: 'w2mGpw_overlay', role: 'dialog', 'aria-label': t('gpwTitle') }, [
@@ -604,6 +652,12 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
604
652
  h('button', { key: 'close', type: 'button', className: 'w2mGpw_close', onClick: onClose }, '×'),
605
653
  ]),
606
654
  error === null ? null : h('div', { key: 'err', className: 'w2mGpw_note' }, error),
655
+ state?.tampered === true ? h('div', { key: 'tampered', className: 'w2mGpw_block' }, [
656
+ h('div', { key: 't', className: 'w2mGpw_note' }, t('gpwTampered', { reason: state?.tampered_reason ?? '' })),
657
+ h('div', { key: 'a', className: 'w2mGpw_actions' }, [
658
+ h('button', { key: 'r', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: resetLists }, t('gpwReset')),
659
+ ]),
660
+ ]) : null,
607
661
  h('div', { key: 'manual', className: 'w2mGpw_manual' }, [
608
662
  h('div', { key: 'label', className: 'w2mGpw_title' }, t('gpwManual')),
609
663
  h('input', {
@@ -636,6 +690,7 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
636
690
  h('button', { key: 'b', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => addFolder('blacklist', item.folder) }, t('gpwAddBlackShort')),
637
691
  ]),
638
692
  ])),
693
+ foldRow(recent, 'recent'),
639
694
  ]),
640
695
  pending.length === 0 ? null : h('div', { key: 'pending', className: 'w2mGpw_block' }, [
641
696
  h('div', { key: 'title', className: 'w2mGpw_title' }, t('gpwPending')),
@@ -645,6 +700,8 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
645
700
  h('div', { key: 'p2', className: 'w2mGpw_actions' }, [
646
701
  h('button', { key: 'a', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => decide(item.id, 'allow') }, t('gpwAllowOnce')),
647
702
  h('button', { key: 'd', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => decide(item.id, 'deny') }, t('gpwDenyOnce')),
703
+ item.folder === null || item.folder === undefined ? null : h('button', { key: 'aw', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => allowWithFolder(item, 'whitelist') }, t('gpwAddWhiteAllow')),
704
+ item.folder === null || item.folder === undefined ? null : h('button', { key: 'ab', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => allowWithFolder(item, 'blacklist') }, t('gpwAddBlackDeny')),
648
705
  ]),
649
706
  ])),
650
707
  ]),
@@ -652,22 +709,107 @@ const PLUGIN_UPDATE_ROUTE = '/api/w2m/plugin-update';
652
709
  ]);
653
710
  }
654
711
 
655
- /** The floating launcher: the panel is fixed to the lower-right corner and does not consume composer space. */
712
+ /** The floating launcher: a pending badge, and the panel fixed to the lower-right corner. */
656
713
  function GpwLauncher({ t }) {
657
714
  const [open, setOpen] = React.useState(false);
715
+ const [pendingCount, setPendingCount] = React.useState(0);
716
+ React.useEffect(() => {
717
+ let alive = true;
718
+ const load = async () => {
719
+ try {
720
+ const response = await fetch('/api/w2m/gpw/state', { credentials: 'same-origin', headers: { accept: 'application/json' } });
721
+ const body = response.ok ? await response.json() : null;
722
+ if (alive) setPendingCount(body?.ok === true && Array.isArray(body.pending) ? body.pending.length : 0);
723
+ } catch {
724
+ if (alive) setPendingCount(0);
725
+ }
726
+ };
727
+ load();
728
+ const timer = setInterval(load, 3000);
729
+ const onOpen = () => setOpen(true);
730
+ window.addEventListener('w2m-gpw-open', onOpen);
731
+ return () => {
732
+ alive = false;
733
+ clearInterval(timer);
734
+ window.removeEventListener('w2m-gpw-open', onOpen);
735
+ };
736
+ }, []);
658
737
  return h(React.Fragment, null, [
659
738
  h('button', {
660
739
  key: 'launcher',
661
740
  type: 'button',
662
741
  className: 'w2mGpw_launcher',
663
- title: t('gpwLauncher'),
742
+ title: pendingCount > 0 ? t('gpwPendingCount', { count: pendingCount }) : t('gpwLauncher'),
664
743
  'aria-expanded': open,
665
744
  onClick: () => setOpen((wasOpen) => !wasOpen),
666
- }, t('gpwLauncher')),
745
+ }, [
746
+ t('gpwLauncher'),
747
+ pendingCount > 0 ? h('span', { key: 'badge', className: 'w2mGpw_badge' }, String(pendingCount)) : null,
748
+ ]),
667
749
  open ? h(GpwPanel, { key: 'panel', t, onClose: () => setOpen(false) }) : null,
668
750
  ]);
669
751
  }
670
752
 
753
+ /**
754
+ * v1.7.9: the inline approval prompt -- an interception asks a question in the composer row
755
+ * (allow once / deny once / open the panel) instead of waiting silently for the operator.
756
+ */
757
+ function GpwPrompt({ t }) {
758
+ const [state, setState] = React.useState(null);
759
+ const [busy, setBusy] = React.useState(false);
760
+ React.useEffect(() => {
761
+ let alive = true;
762
+ const load = async () => {
763
+ try {
764
+ const response = await fetch('/api/w2m/gpw/state', { credentials: 'same-origin', headers: { accept: 'application/json' } });
765
+ const body = response.ok ? await response.json() : null;
766
+ if (alive) setState(body);
767
+ } catch {
768
+ if (alive) setState(null);
769
+ }
770
+ };
771
+ load();
772
+ const timer = setInterval(load, 2000);
773
+ return () => {
774
+ alive = false;
775
+ clearInterval(timer);
776
+ };
777
+ }, []);
778
+ const pending = Array.isArray(state?.pending) ? state.pending : [];
779
+ if (pending.length === 0) return null;
780
+ const item = pending[0];
781
+ const decide = async (decision) => {
782
+ setBusy(true);
783
+ try {
784
+ await fetch('/api/w2m/gpw/decide', {
785
+ method: 'POST',
786
+ credentials: 'same-origin',
787
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
788
+ body: JSON.stringify({ id: item.id, decision }),
789
+ });
790
+ } catch {
791
+ /* the next poll shows the queue again */
792
+ } finally {
793
+ setBusy(false);
794
+ }
795
+ };
796
+ const openPanel = () => {
797
+ try {
798
+ window.dispatchEvent(new CustomEvent('w2m-gpw-open'));
799
+ } catch {
800
+ /* no window in a headless render */
801
+ }
802
+ };
803
+ return h('div', { className: 'w2mGpw_prompt', 'data-w2m-gpw-prompt': 'inline' }, [
804
+ h('span', { key: 'label', className: 'w2mGpw_promptLabel' }, pending.length > 1
805
+ ? t('gpwPendingCount', { count: pending.length })
806
+ : t('gpwPendingShort', { tool: item.tool ?? 'tool' })),
807
+ h('button', { key: 'a', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => decide('allow') }, t('gpwAllowOnce')),
808
+ h('button', { key: 'd', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: () => decide('deny') }, t('gpwDenyOnce')),
809
+ h('button', { key: 'o', type: 'button', className: 'w2mGpw_btn', disabled: busy, onClick: openPanel }, t('gpwOpenPanel')),
810
+ ]);
811
+ }
812
+
671
813
  /** v1.6.8: one button beside the relay rows: check npm for a newer plugin, install it, ask for a restart. */
672
814
  function PluginUpdate({ t }) {
673
815
  const [state, setState] = React.useState({ status: 'idle', info: null, error: null });
@@ -1543,6 +1685,19 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
1543
1685
  } catch (error) {
1544
1686
  console.error('@twinsearth/w2m-dsh-plugin: the GPW folder panel was not mounted; the rest of the page is unaffected', error);
1545
1687
  }
1688
+ // v1.7.9: the inline pending prompt, mounted in the same composer row so an interception
1689
+ // asks allow/deny immediately instead of waiting silently in the panel.
1690
+ try {
1691
+ slots.inject('conversation.input.left', () => slots.register({
1692
+ name: 'conversation.input.left',
1693
+ id: 'w2m-gpw-prompt',
1694
+ order: 44,
1695
+ label: 'GPW pending prompt',
1696
+ inject: () => ({ t }),
1697
+ }, GpwPrompt));
1698
+ } catch (error) {
1699
+ console.error('@twinsearth/w2m-dsh-plugin: the GPW approval prompt was not mounted; the panel still works', error);
1700
+ }
1546
1701
  try {
1547
1702
  slots.inject('conversation.input.left', () => slots.register({
1548
1703
  name: 'conversation.input.left',
@@ -1601,6 +1756,7 @@ function TargetSelect({ sessionId, locked: _locked, t }) {
1601
1756
  exports.inject = inject;
1602
1757
  exports.TargetSelect = TargetSelect;
1603
1758
  exports.GpwLauncher = GpwLauncher;
1759
+ exports.GpwPrompt = GpwPrompt;
1604
1760
  exports.FleetChat = FleetChat;
1605
1761
  exports.injectStyle = injectStyle;
1606
1762
  exports.makeTranslator = makeTranslator;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "1.7.7",
3
+ "version": "1.7.9",
4
4
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -64,7 +64,7 @@
64
64
  "pack": "node scripts/pack.mjs",
65
65
  "reachability": "node scripts/reachability-check.mjs",
66
66
  "reachability:classify": "node scripts/reachability-classify.mjs",
67
- "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs",
67
+ "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs",
68
68
  "test:acceptance-doc": "node --test --test-force-exit test/acceptance-doc.test.mjs",
69
69
  "test:account-identity": "node --test --test-force-exit test/account-identity.test.mjs",
70
70
  "test:account-join": "node --test --test-force-exit test/account-join.test.mjs",
@@ -82,7 +82,7 @@
82
82
  "test:council-session": "node --test --test-force-exit test/council-session.test.mjs",
83
83
  "test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
84
84
  "test:crypto-blake2s": "node --test --test-force-exit test/crypto-blake2s.test.mjs",
85
- "test:e2e": "node --test --test-force-exit test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/p2p-agent.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs",
85
+ "test:e2e": "node --test --test-force-exit test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/p2p-agent.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs",
86
86
  "test:exec-env": "node --test --test-force-exit test/exec-env-injection.test.mjs",
87
87
  "test:exit-channel": "node --test --test-force-exit test/exit-channel.test.mjs",
88
88
  "test:field-authority": "node --test --test-force-exit test/field-authority.test.mjs",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "twinsearth-w2m-dsh",
4
- "version": "1.7.7",
4
+ "version": "1.7.9",
5
5
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -107,7 +107,7 @@ const COMMAND_TOOL = /^(bash|pwsh|powershell|shell|terminal|run_command|exec)$/i
107
107
  * @param {{name?: string, arguments?: string|object}} call
108
108
  * @returns {string[]} raw (not yet normalised) path candidates
109
109
  */
110
- export function pathsOfToolCall(call) {
110
+ export function pathsOfToolCall(call, options = {}) {
111
111
  let args = call?.arguments;
112
112
  if (typeof args === 'string') {
113
113
  if (args.trim() === '') return [];
@@ -137,8 +137,20 @@ export function pathsOfToolCall(call) {
137
137
  : typeof call?.arguments === 'string'
138
138
  ? call.arguments
139
139
  : JSON.stringify(args ?? '');
140
- const matches = text.match(/(?:[A-Za-z]:[\\/][^"'\s|;&<>]*|\\\\[^"'\s|;&<>]+|\/(?:[^"'\s|;&<>]+\/?)+)/g) ?? [];
140
+ const matches = text.match(/(?<![^\s"'=(])(?:[A-Za-z]:[\\/][^"'\s|;&<>]*|\\\\[^"'\s|;&<>]+|\/(?:[^"'\s|;&<>]+\/?)+)/g) ?? [];
141
141
  for (const m of matches) push(m);
142
+ // v1.7.9: relative tokens (sub/file.txt, ./x, ..\y) are resolved against the workspace base below, so a command
143
+ // that names a subfolder is classified instead of passing through as "names no path". Only tokens that actually
144
+ // contain a separator are taken; URLs, flags and bare words are left alone.
145
+ if (options.allowRelative === true) {
146
+ for (const token of text.split(/\s+/)) {
147
+ const cleaned = token.replace(/^["'(=]+|["'),;]+$/g, '');
148
+ if (cleaned === '' || cleaned.startsWith('-') || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(cleaned)) continue;
149
+ if (!/[\\/]/.test(cleaned)) continue;
150
+ if (/[\\/]$/.test(cleaned) && cleaned.length === 1) continue;
151
+ push(cleaned);
152
+ }
153
+ }
142
154
  }
143
155
  return out;
144
156
  }
@@ -149,11 +161,11 @@ export function pathsOfToolCall(call) {
149
161
  * the ordinary one.
150
162
  *
151
163
  * @param {{name?: string, arguments?: string|object, whitelist?: string[], blacklist?: string[],
152
- * workspace?: string|null, base?: string, platform?: string}} call
164
+ * workspace?: string|null, base?: string, platform?: string, allowRelative?: boolean}} call
153
165
  * @returns {{decision: 'deny'|'allow'|'pass', path: string|null, folder: string|null, reason: string, paths: string[]}}
154
166
  */
155
167
  export function classifyToolCall(call) {
156
- const candidates = pathsOfToolCall(call);
168
+ const candidates = pathsOfToolCall(call, { allowRelative: call.allowRelative === true });
157
169
  let weakest = { decision: 'pass', path: null, folder: null, reason: 'this call names no path', paths: [] };
158
170
  for (const candidate of candidates) {
159
171
  const result = classifyPath({
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * ## The three classes and who answers
5
5
  *
6
- * A. blacklist -> denied. A synchronous `ctx.tools.guard()` is registered as well, so the denial holds no matter
6
+ * A. blacklist -> denied. A synchronous guard is registered on each agent (agent.ctx.tools.guard), so the denial holds no matter
7
7
  * which other pre-execute listener runs first.
8
8
  * B. whitelist -> allowed without a human. The whitelist is an explicit extension of the workspace (same decision,
9
9
  * plus the auto-approval budget below).
@@ -25,35 +25,41 @@
25
25
  *
26
26
  * ## Operator surface
27
27
  *
28
- * The panel is rendered by the w2m client (`lib/client.js`): a floating "GPW 文件夹" button opens the three sections
29
- * (whitelist / blacklist / recently intercepted) plus the pending queue. Folders can be typed (validated against the
30
- * filesystem here) or taken from the recently-intercepted list in one click. Lists live in a JSON file under
31
- * `$DSH_HOME/xclient/`, never in the profile patch.
28
+ * The panel is rendered by the w2m client (`lib/client.js`): a pending prompt sits inline in the composer row,
29
+ * and a floating "GPW 文件夹" button opens the three sections (whitelist / blacklist / recently intercepted)
30
+ * plus the pending queue. Folders can be typed (validated against the filesystem here) or taken from the
31
+ * recently-intercepted list in one click. Lists live in a MAC-signed v2 JSON file under `$DSH_HOME/xclient/`
32
+ * (written read-only 0444), never in the profile patch; an external edit is detected and locks class-B/C paths
33
+ * until the operator resets the lists.
32
34
  */
33
35
 
34
36
  import { spawn } from 'node:child_process';
35
- import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
37
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
36
38
  import { homedir } from 'node:os';
37
39
  import { dirname, join, resolve } from 'node:path';
38
- import { randomUUID } from 'node:crypto';
40
+ import { createHmac, randomBytes, randomUUID } from 'node:crypto';
39
41
 
40
42
  import { classifyToolCall } from './folder-policy.mjs';
41
43
 
42
44
  export const name = 'dsh-gpw';
43
45
 
44
46
  /** Services this plugin cannot work without. `connection` is used only for the operator routes. */
45
- export const inject = ['permissionPresets', 'tools'];
47
+ export const inject = ['permissionPresets'];
46
48
 
47
49
  export const FULL_ACCESS_PRESET = 'danger-full-access';
48
50
  export const GPW_ROUTE = '/api/w2m/gpw/state';
49
51
  export const GPW_FOLDERS_ROUTE = '/api/w2m/gpw/folders';
50
52
  export const GPW_DECIDE_ROUTE = '/api/w2m/gpw/decide';
51
53
  export const GPW_PICK_ROUTE = '/api/w2m/gpw/pick';
54
+ export const GPW_RESET_ROUTE = '/api/w2m/gpw/reset';
52
55
 
53
56
  const DEFAULT_BUDGET = { maxPerWindow: 10, windowMs: 60_000, minGapMs: 3_000 };
54
57
  const DEFAULT_PENDING_TIMEOUT_MS = 120_000;
55
58
  const RECENT_LIMIT = 20;
56
59
  const NO_STORE = { 'cache-control': 'no-store' };
60
+ /** v1.7.9: lists are MAC-signed (v2) so an external edit is detectable instead of silently trusted. */
61
+ const FOLDERS_VERSION = 2;
62
+ const FOLDERS_MAC_ALGO = 'sha256';
57
63
 
58
64
  /** Resolve the folders file: config first, then `$DSH_HOME/xclient/gpw-folders.json`. */
59
65
  export function foldersFileOf(config = {}) {
@@ -62,28 +68,116 @@ export function foldersFileOf(config = {}) {
62
68
  return join(home, 'xclient', 'gpw-folders.json');
63
69
  }
64
70
 
65
- /** Read the operator-managed lists. A missing or corrupt file is empty lists, never a crash on mount. */
66
- export function readFolders(file) {
71
+ /** The sidecar file that holds the MAC key. Kept next to (never inside) the lists file. */
72
+ export function foldersKeyFileOf(file) {
73
+ return `${file}.key`;
74
+ }
75
+
76
+ /**
77
+ * Resolve the MAC key: explicit config / env first, else a generated 32-byte key in a 0600 sidecar.
78
+ * `created` is true only when this call generated the key, which is what lets a legacy plain-JSON
79
+ * file be migrated exactly once and a later unsigned file be treated as tampering instead.
80
+ */
81
+ export function ensureFoldersKey(file, config = {}) {
82
+ const fromConfig = typeof config.foldersKey === 'string' && config.foldersKey.trim() !== '' ? config.foldersKey.trim() : null;
83
+ const fromEnv = typeof process.env.W2M_GPW_FOLDERS_KEY === 'string' && process.env.W2M_GPW_FOLDERS_KEY.trim() !== ''
84
+ ? process.env.W2M_GPW_FOLDERS_KEY.trim()
85
+ : null;
86
+ const explicit = fromConfig ?? fromEnv;
87
+ if (explicit !== null) return { key: explicit, created: false, source: fromConfig !== null ? 'config' : 'env' };
88
+ const keyFile = foldersKeyFileOf(file);
67
89
  try {
68
- const parsed = JSON.parse(readFileSync(file, 'utf8'));
69
- return {
70
- whitelist: Array.isArray(parsed?.whitelist) ? parsed.whitelist.filter((v) => typeof v === 'string' && v !== '') : [],
71
- blacklist: Array.isArray(parsed?.blacklist) ? parsed.blacklist.filter((v) => typeof v === 'string' && v !== '') : [],
72
- };
90
+ const existing = readFileSync(keyFile, 'utf8').trim();
91
+ if (existing !== '') return { key: existing, created: false, source: 'file' };
73
92
  } catch {
74
- return { whitelist: [], blacklist: [] };
93
+ /* generated below */
75
94
  }
95
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
96
+ const key = randomBytes(32).toString('hex');
97
+ writeFileSync(keyFile, `${key}\n`, { encoding: 'utf8', mode: 0o600 });
98
+ try { chmodSync(keyFile, 0o600); } catch { /* best effort on Windows */ }
99
+ return { key, created: true, source: 'file' };
76
100
  }
77
101
 
78
- /** Persist the operator-managed lists. */
79
- export function writeFolders(file, folders) {
80
- mkdirSync(dirname(file), { recursive: true });
81
- const next = {
82
- whitelist: [...new Set((folders?.whitelist ?? []).filter((v) => typeof v === 'string' && v.trim() !== ''))],
83
- blacklist: [...new Set((folders?.blacklist ?? []).filter((v) => typeof v === 'string' && v.trim() !== ''))],
84
- };
85
- writeFileSync(file, JSON.stringify(next, null, 2) + '\n', 'utf8');
86
- return next;
102
+ /** Canonical MAC over the two lists (order-independent, so adding/removing a row is what changes it). */
103
+ export function macOfFolders(whitelist, blacklist, key) {
104
+ const canonical = JSON.stringify({
105
+ whitelist: [...new Set(whitelist)].sort(),
106
+ blacklist: [...new Set(blacklist)].sort(),
107
+ });
108
+ return createHmac(FOLDERS_MAC_ALGO, key).update(canonical).digest('hex');
109
+ }
110
+
111
+ /**
112
+ * Read the operator-managed lists.
113
+ *
114
+ * - missing file + `allowMissing` -> clean empty lists (first run)
115
+ * - missing file otherwise -> `tampered` (the signed file was deleted)
116
+ * - valid v2 + MAC -> lists
117
+ * - v2 with a bad MAC -> `tampered` (edited outside GPW)
118
+ * - no version/MAC (legacy) -> `legacy: true`; the caller migrates once or treats it as tampered
119
+ */
120
+ export function readFolders(file, key = null, options = {}) {
121
+ let raw;
122
+ try {
123
+ raw = readFileSync(file, 'utf8');
124
+ } catch (error) {
125
+ if (error?.code === 'ENOENT' && key !== null && options.allowMissing !== true) {
126
+ return { whitelist: [], blacklist: [], tampered: true, reason: 'folder lists file is missing' };
127
+ }
128
+ if (error?.code !== 'ENOENT') {
129
+ return { whitelist: [], blacklist: [], tampered: true, reason: `folder lists unreadable: ${error?.message ?? error}` };
130
+ }
131
+ return { whitelist: [], blacklist: [], tampered: false, reason: null, legacy: false };
132
+ }
133
+ let parsed = null;
134
+ try {
135
+ parsed = JSON.parse(raw);
136
+ } catch {
137
+ return { whitelist: [], blacklist: [], tampered: true, reason: 'folder lists file is not valid JSON' };
138
+ }
139
+ const whitelist = Array.isArray(parsed?.whitelist) ? parsed.whitelist.filter((v) => typeof v === 'string' && v !== '') : [];
140
+ const blacklist = Array.isArray(parsed?.blacklist) ? parsed.blacklist.filter((v) => typeof v === 'string' && v !== '') : [];
141
+ if (parsed?.version !== FOLDERS_VERSION || typeof parsed?.mac !== 'string') {
142
+ return { whitelist, blacklist, tampered: false, reason: null, legacy: true };
143
+ }
144
+ if (key === null) return { whitelist, blacklist, tampered: false, reason: null, legacy: false };
145
+ if (macOfFolders(whitelist, blacklist, key) !== parsed.mac) {
146
+ return { whitelist, blacklist, tampered: true, reason: 'folder lists were modified outside GPW (MAC mismatch)' };
147
+ }
148
+ return { whitelist, blacklist, tampered: false, reason: null, legacy: false };
149
+ }
150
+
151
+ /**
152
+ * Persist the operator-managed lists.
153
+ *
154
+ * Atomic (tmp + rename), MAC-signed when a key is supplied, and left **read-only** (0444) so an
155
+ * accidental or casual edit from the agent's file tools fails instead of silently changing policy.
156
+ * GPW itself rewrites by clearing the flag, writing a new tmp file and renaming over the target.
157
+ */
158
+ export function writeFolders(file, folders, key = null) {
159
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
160
+ const whitelist = [...new Set((folders?.whitelist ?? []).filter((v) => typeof v === 'string' && v.trim() !== ''))];
161
+ const blacklist = [...new Set((folders?.blacklist ?? []).filter((v) => typeof v === 'string' && v.trim() !== ''))];
162
+ const next = { version: FOLDERS_VERSION, whitelist, blacklist };
163
+ if (key !== null) {
164
+ next.algo = FOLDERS_MAC_ALGO;
165
+ next.mac = macOfFolders(whitelist, blacklist, key);
166
+ }
167
+ const payload = `${JSON.stringify(next, null, 2)}\n`;
168
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
169
+ // Windows refuses to rename over a read-only target: clear it first, then the atomic replace runs.
170
+ try { chmodSync(file, 0o644); } catch { /* first write */ }
171
+ writeFileSync(tmp, payload, { encoding: 'utf8', mode: 0o600 });
172
+ try { chmodSync(tmp, 0o600); } catch { /* best effort */ }
173
+ renameSync(tmp, file);
174
+ try { chmodSync(file, 0o444); } catch { /* read-only is best effort on Windows, MAC still verifies */ }
175
+ return { whitelist, blacklist };
176
+ }
177
+
178
+ /** Operator reset: drop every entry and re-sign. Used only after a tamper report. */
179
+ export function resetFolders(file, key) {
180
+ return writeFolders(file, { whitelist: [], blacklist: [] }, key);
87
181
  }
88
182
 
89
183
  /**
@@ -229,6 +323,33 @@ export function apply(ctx, config = {}) {
229
323
  const pendingTimeoutMs = positiveInt(config.pendingTimeoutMs, DEFAULT_PENDING_TIMEOUT_MS);
230
324
  const requireFullAccess = config.requireFullAccess !== false;
231
325
  const foldersFile = foldersFileOf(config);
326
+ const keyInfo = ensureFoldersKey(foldersFile, config);
327
+ const foldersKey = keyInfo.key;
328
+ /** Set once the signed file has been read cleanly; a later disappearance then reads as tampering. */
329
+ let foldersSeen = false;
330
+ /** Non-null until the operator resets the lists; class-B/C paths are locked while it is set. */
331
+ let tamperedReason = null;
332
+
333
+ const noteTamper = (reason) => {
334
+ const next = reason ?? 'folder lists failed integrity check';
335
+ if (tamperedReason === null) ctx.logger?.warn?.(`dsh-gpw: ${next}; class-B/C paths are locked until the operator resets the lists`);
336
+ tamperedReason = next;
337
+ };
338
+
339
+ // First read: migrate a legacy plain-JSON file exactly once; after that an unsigned/missing file is tampering.
340
+ const initialFolders = readFolders(foldersFile, foldersKey, { allowMissing: keyInfo.created || !existsSync(foldersFile) });
341
+ if (initialFolders.legacy) ctx.logger?.info?.('dsh-gpw: migrating legacy folder lists to MAC-signed v2 (read-only)');
342
+ if (initialFolders.tampered) {
343
+ noteTamper(initialFolders.reason);
344
+ } else {
345
+ // Always leave a signed v2 file behind (first run creates the empty one; later runs re-sign it).
346
+ try {
347
+ writeFolders(foldersFile, initialFolders, foldersKey);
348
+ foldersSeen = true;
349
+ } catch (error) {
350
+ noteTamper(`folder lists could not be initialized: ${error?.message ?? error}`);
351
+ }
352
+ }
232
353
 
233
354
  /** Timestamps of auto-approvals in the current window. */
234
355
  const approvals = [];
@@ -247,11 +368,20 @@ export function apply(ctx, config = {}) {
247
368
  };
248
369
 
249
370
  const folderLists = () => {
250
- const stored = readFolders(foldersFile);
371
+ const stored = readFolders(foldersFile, foldersKey, { allowMissing: keyInfo.created && !foldersSeen });
372
+ if (stored.legacy) {
373
+ noteTamper('folder lists lost their integrity signature (edited outside GPW?)');
374
+ } else if (stored.tampered) {
375
+ noteTamper(stored.reason);
376
+ } else {
377
+ foldersSeen = true;
378
+ }
251
379
  const merge = (a, b) => [...new Set([...(Array.isArray(a) ? a : []), ...b])];
252
380
  return {
253
381
  whitelist: merge(config.whitelist, stored.whitelist),
254
382
  blacklist: merge(config.blacklist, stored.blacklist),
383
+ tampered: tamperedReason !== null,
384
+ reason: tamperedReason,
255
385
  };
256
386
  };
257
387
 
@@ -322,38 +452,85 @@ export function apply(ctx, config = {}) {
322
452
  };
323
453
 
324
454
  // --- A: the blacklist denial must hold regardless of listener order -------------------------------------------------
325
- let guard = null;
326
- try {
327
- guard = ctx.tools?.guard;
328
- } catch {
329
- guard = null; // older host without the guard extension: pre-execute still denies
330
- }
331
- guard?.((exec) => {
455
+ //
456
+ // v1.7.8: the guard is registered on each agent's own context, from an `agent/created` listener.
457
+ //
458
+ // Registering it on the root context (ctx.tools.guard at activation) crashed dsh-gpw with
459
+ // TypeError: Cannot read properties of undefined (reading 'layers')
460
+ // because the tools service is composed per agent: the root-scope instance has no layer table yet at activation time.
461
+ // The documented shape for per-agent behavior is agent.ctx obtained in agent/created; the disposer is kept so an
462
+ // unloaded plugin cannot leave guards behind.
463
+ /** @type {Map<object, () => void>} */
464
+ const agentGuards = new Map();
465
+ const blacklistGuard = () => (exec) => {
332
466
  if (!isFullAccess(exec?.agent)) return undefined;
333
467
  const lists = folderLists();
334
- if (lists.blacklist.length === 0) return undefined;
335
468
  const call = toolCallOf(exec);
469
+ if (lists.tampered) {
470
+ // Fail closed: while the lists cannot be trusted, every path-naming call is refused outright.
471
+ const probe = classifyToolCall({ ...call, whitelist: [], blacklist: [] });
472
+ if (probe.paths.length === 0) return undefined;
473
+ deniedTotal += 1;
474
+ noteRecent({ tool: String(call.name ?? 'tool'), decision: 'deny', reason: lists.reason, paths: probe.paths });
475
+ ctx.logger?.warn?.(`dsh-gpw: refused ${String(call.name ?? 'tool')} -- lists locked (${lists.reason})`);
476
+ return `GPW: folder lists failed integrity check (${lists.reason}); reset them in the GPW panel`;
477
+ }
478
+ if (lists.blacklist.length === 0) return undefined;
336
479
  const result = classifyToolCall({ ...call, blacklist: lists.blacklist });
337
480
  if (result.decision !== 'deny') return undefined;
338
481
  deniedTotal += 1;
339
482
  noteRecent({ tool: String(call.name ?? 'tool'), decision: 'deny', reason: result.reason, paths: result.paths });
340
483
  ctx.logger?.warn?.(`dsh-gpw: refused ${String(call.name ?? 'tool')} -- ${result.reason}`);
341
484
  return `GPW: ${result.reason}; the call was not executed`;
485
+ };
486
+ ctx.on('agent/created', ({ agent } = {}) => {
487
+ try {
488
+ const guard = agent?.ctx?.tools?.guard;
489
+ if (typeof guard !== 'function') return; // host without the guard extension: pre-execute still denies
490
+ const dispose = guard(blacklistGuard());
491
+ if (typeof dispose === 'function') agentGuards.set(agent, dispose);
492
+ } catch {
493
+ /* the pre-execute branch below still enforces the blacklist */
494
+ }
342
495
  });
496
+ if (typeof ctx.effect === 'function') {
497
+ ctx.effect(() => () => {
498
+ for (const dispose of agentGuards.values()) {
499
+ try {
500
+ dispose();
501
+ } catch {
502
+ /* already disposed with its agent */
503
+ }
504
+ }
505
+ agentGuards.clear();
506
+ }, 'dsh-gpw: per-agent blacklist guards');
507
+ }
508
+
343
509
 
344
510
  // --- B/C: the pre-execute gate ---------------------------------------------------------------------------------------
345
511
  ctx.on('tools/pre-execute', async (exec, next) => {
346
512
  if (requireFullAccess && !isFullAccess(exec?.agent)) return next();
347
513
  const lists = folderLists();
348
- if (lists.blacklist.length === 0 && lists.whitelist.length === 0) return next();
349
514
  const call = toolCallOf(exec);
515
+ if (lists.tampered) {
516
+ // Fail closed: a tampered list file must not be able to silently turn policy off.
517
+ const probe = classifyToolCall({ ...call, whitelist: [], blacklist: [] });
518
+ if (probe.paths.length === 0) return next();
519
+ deniedTotal += 1;
520
+ noteRecent({ tool: String(call.name ?? 'tool'), decision: 'deny', reason: lists.reason, paths: probe.paths });
521
+ ctx.logger?.warn?.(`dsh-gpw: refused ${String(call.name ?? 'tool')} -- lists locked (${lists.reason})`);
522
+ return { kind: 'deny', reason: `GPW: folder lists failed integrity check (${lists.reason}); reset them in the GPW panel` };
523
+ }
524
+ if (lists.blacklist.length === 0 && lists.whitelist.length === 0) return next();
350
525
  let cwd = null;
351
526
  try {
352
527
  cwd = await ctx.workingDirectory?.ensure?.(exec.agent, exec.signal);
353
528
  } catch {
354
529
  cwd = null;
355
530
  }
356
- const classification = classifyToolCall({ ...call, whitelist: lists.whitelist, blacklist: lists.blacklist, workspace: cwd ?? null });
531
+ // A listed folder covers its subfolders; relative command paths are resolved against the workspace so a
532
+ // subfolder reference is classified rather than treated as "names no path".
533
+ const classification = classifyToolCall({ ...call, whitelist: lists.whitelist, blacklist: lists.blacklist, workspace: cwd ?? null, base: cwd ?? undefined, allowRelative: cwd !== null });
357
534
  if (classification.decision === 'pass' && classification.paths.length === 0) return next();
358
535
  const budget = classification.decision === 'allow' ? takeBudgetSlot() : false;
359
536
  const plan = planGate({ classification, autoAllowBudgetRemaining: classification.decision === 'allow' ? budget !== false : undefined });
@@ -383,28 +560,34 @@ export function apply(ctx, config = {}) {
383
560
  path: GPW_ROUTE,
384
561
  methods: ['GET'],
385
562
  requestBody: 'buffered',
386
- fetch: async () => json({
387
- ok: true,
388
- enabled: true,
389
- require_full_access: requireFullAccess,
390
- folders: folderLists(),
391
- counters: { allowedTotal, pendingTotal, deniedTotal, budgetWindow: approvals.length, maxPerWindow },
392
- recent: recent.map((entry) => ({
393
- at: entry.at,
394
- tool: entry.tool,
395
- decision: entry.decision,
396
- reason: entry.reason,
397
- paths: entry.paths,
398
- folder: entry.paths.length > 0 ? folderForPath(entry.paths[0]) : null,
399
- })),
400
- pending: [...pending.entries()].map(([id, entry]) => ({
401
- id,
402
- tool: entry.tool,
403
- reason: entry.reason,
404
- paths: entry.paths,
405
- pending_ms: Date.now() - entry.createdAt,
406
- })),
407
- }),
563
+ fetch: async () => {
564
+ const lists = folderLists();
565
+ return json({
566
+ ok: true,
567
+ enabled: true,
568
+ require_full_access: requireFullAccess,
569
+ folders: { whitelist: lists.whitelist, blacklist: lists.blacklist },
570
+ tampered: lists.tampered,
571
+ tampered_reason: lists.reason,
572
+ counters: { allowedTotal, pendingTotal, deniedTotal, budgetWindow: approvals.length, maxPerWindow },
573
+ recent: recent.map((entry) => ({
574
+ at: entry.at,
575
+ tool: entry.tool,
576
+ decision: entry.decision,
577
+ reason: entry.reason,
578
+ paths: entry.paths,
579
+ folder: entry.paths.length > 0 ? folderForPath(entry.paths[0]) : null,
580
+ })),
581
+ pending: [...pending.entries()].map(([id, entry]) => ({
582
+ id,
583
+ tool: entry.tool,
584
+ reason: entry.reason,
585
+ paths: entry.paths,
586
+ folder: entry.paths.length > 0 ? folderForPath(entry.paths[0]) : null,
587
+ pending_ms: Date.now() - entry.createdAt,
588
+ })),
589
+ });
590
+ },
408
591
  });
409
592
 
410
593
  registry.register({
@@ -421,23 +604,58 @@ export function apply(ctx, config = {}) {
421
604
  const list = body?.list;
422
605
  if (list !== 'whitelist' && list !== 'blacklist') return json({ ok: false, error: 'list must be whitelist or blacklist' }, 400);
423
606
  if (body?.action !== 'add' && body?.action !== 'remove') return json({ ok: false, error: 'action must be add or remove' }, 400);
607
+ const current = folderLists();
608
+ if (current.tampered) {
609
+ return json({ ok: false, error: `folder lists are locked after an integrity failure (${current.reason}); reset them first` }, 409);
610
+ }
611
+ const stored = readFolders(foldersFile, foldersKey, { allowMissing: true });
424
612
  if (body.action === 'remove') {
425
613
  if (typeof body?.path !== 'string' || body.path.trim() === '') return json({ ok: false, error: 'path must be a non-empty string' }, 400);
426
- const stored = readFolders(foldersFile);
427
614
  const next = { ...stored, [list]: stored[list].filter((entry) => entry !== body.path) };
428
- writeFolders(foldersFile, next);
615
+ try {
616
+ writeFolders(foldersFile, next, foldersKey);
617
+ } catch (error) {
618
+ return json({ ok: false, error: `could not write folder lists: ${error?.message ?? error}` }, 500);
619
+ }
429
620
  return json({ ok: true, folders: folderLists() });
430
621
  }
431
622
  const validated = validateFolderInput(body?.path);
432
623
  if (!validated.ok) return json({ ok: false, error: validated.error }, 400);
433
- const stored = readFolders(foldersFile);
434
624
  const next = { ...stored, [list]: [...new Set([...stored[list], validated.path])] };
435
- writeFolders(foldersFile, next);
625
+ try {
626
+ writeFolders(foldersFile, next, foldersKey);
627
+ } catch (error) {
628
+ return json({ ok: false, error: `could not write folder lists: ${error?.message ?? error}` }, 500);
629
+ }
436
630
  ctx.logger?.info?.(`dsh-gpw: added ${validated.path} to ${list}`);
437
631
  return json({ ok: true, folders: folderLists() });
438
632
  },
439
633
  });
440
634
 
635
+ registry.register({
636
+ path: GPW_RESET_ROUTE,
637
+ methods: ['POST'],
638
+ requestBody: 'buffered',
639
+ fetch: async (request) => {
640
+ let body = null;
641
+ try {
642
+ body = await request.json();
643
+ } catch {
644
+ /* refused below */
645
+ }
646
+ if (body?.confirm !== 'reset') return json({ ok: false, error: "confirm must be the string 'reset'" }, 400);
647
+ try {
648
+ resetFolders(foldersFile, foldersKey);
649
+ } catch (error) {
650
+ return json({ ok: false, error: `could not reset folder lists: ${error?.message ?? error}` }, 500);
651
+ }
652
+ tamperedReason = null;
653
+ foldersSeen = true;
654
+ ctx.logger?.warn?.('dsh-gpw: folder lists were reset by the operator; all entries cleared and re-signed');
655
+ return json({ ok: true, tampered: false, folders: folderLists() });
656
+ },
657
+ });
658
+
441
659
  registry.register({
442
660
  path: GPW_DECIDE_ROUTE,
443
661
  methods: ['POST'],
@@ -283,7 +283,7 @@ const MAX_STAGES = 16;
283
283
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
284
284
  * overrides it at runtime for tests and packagers.
285
285
  */
286
- const PLUGIN_VERSION = '1.7.7';
286
+ const PLUGIN_VERSION = '1.7.9';
287
287
 
288
288
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
289
289
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;