@twinsearth/w2m-dsh-plugin 1.4.1 → 1.4.3

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.4.1"
8
+ "version": "1.4.3"
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.4.1",
15
+ "version": "1.4.3",
16
16
  "author": {
17
17
  "name": "TwinsEarth"
18
18
  },
@@ -26,5 +26,5 @@
26
26
  ]
27
27
  }
28
28
  ],
29
- "version": "1.4.1"
29
+ "version": "1.4.3"
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.4.1",
4
+ "version": "1.4.3",
5
5
  "author": {
6
6
  "name": "TwinsEarth"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -7,6 +7,61 @@
7
7
 
8
8
  All notable changes to this project are documented here.
9
9
 
10
+ ## [1.4.3] — 2026-10-22
11
+
12
+ **微信编队桥接:回复可靠性修复(不回复/静默问题)。**
13
+
14
+ - 每条消息独立 try/catch:任何处理异常不再让进程退出,改为回复
15
+ `处理失败(阶段,错误码):原因 + /help 提示`(新增 `renderBridgeError`)。
16
+ - 桥接启动调用 `ilink/bot/msg/notifystart`:修复"token 有效、进程在跑、但 iLink 不推送消息"的静默状态;
17
+ 日志记录 `notifystart : ok`,失败也记录原因。
18
+ - 非文本、空消息、未以 `/run` 开头、权限不足、派发失败/超时等路径均保证有回复与下一步提示。
19
+ - 运行方式支持 launchd 常驻(KeepAlive 自动拉起;macOS plist 由运维部署,不进入安装包)。
20
+
21
+ - **授权优先级修复**:exec-capability 只在任务未携带 grant 时兜底;grant 被拒(replay / 过期 / 机器不符)
22
+ 原样上报,不再被能力分支覆盖成路径匹配错误,`GRANT_REPLAY` 语义恢复。
23
+
24
+ 真机验收(macmini):`notifystart ok`、错误回复代码在位、launchd state=running、bridge 进程存活;
25
+ 证据 `docs/acceptance/1.4.3/BRIDGE-LIVE.json`。测试:chatops-ilink-fleet-bridge 36/36。
26
+
27
+ ## [1.4.2] — 2026-10-22
28
+
29
+ **Node 代码执行权限成为 UI 可激活的子插件能力(命中真机验收)。**
30
+
31
+ - 新增 `src/util/exec-capability.mjs`:机器本地能力状态(拒绝 / 临时 24h / 永久),fail-closed;临时档带绝对
32
+ `expires_at_ms`,过期即拒绝;唯一可放行的前缀固定为 `node -e`。
33
+ - 新增 host 路由 `GET/POST /api/w2m/capability`(`src/plugin/exec-capability-route.mjs`),同源、在 host
34
+ 认证栅栏内;浏览器不接触文件系统或设备 token。
35
+ - client `lib/client.js` 在 `conversation.input.left` 新增第三个控制(order 42):
36
+ `Node 执行:拒绝 | 临时 24 小时 | 永久`,带 RCE 风险提示与剩余时间。
37
+ - agent 每个任务前读取能力文件;命中时结果信封 `authorization.provenance = "exec-capability"`,
38
+ warnings 记录 `EXEC_CAPABILITY`;pipeline 各 stage 同样覆盖。
39
+
40
+ 真机验收(TwinsEarth,插件 1.4.2-beta.1;先清空 `cordis.patch.yml` 的旧 allow-list 以隔离能力路径):
41
+ - 无能力、空 allow-list:`node --version` → `COMMAND_NOT_ALLOWED`
42
+ - 能力=永久、空 allow-list:`node -e` → **ok**(`node -e` 是唯一被放行的前缀)
43
+ - 能力=临时 5s 到期后:`node -e` → `COMMAND_NOT_ALLOWED`(fail-closed)
44
+ - 证据:`docs/acceptance/1.4.2/CAPABILITY-LIVE.json`
45
+
46
+ Verified: 单测/路由/客户端契约 43/43;候选 gate GREEN ×2;stable gate 见发布记录。
47
+
48
+ ## [1.4.2-beta.1] — 2026-10-22
49
+
50
+ **Node 代码执行权限成为 UI 可激活的子插件能力。**
51
+
52
+ - 新增 `src/util/exec-capability.mjs`:机器本地能力状态(拒绝 / 临时 24h / 永久),fail-closed,
53
+ 临时档带绝对 `expires_at_ms`,过期即拒绝。
54
+ - 新增 host 路由 `GET/POST /api/w2m/capability`(`src/plugin/exec-capability-route.mjs`),同源、在
55
+ host 认证栅栏内;浏览器不再接触文件系统或设备 token。
56
+ - client `lib/client.js` 在 `conversation.input.left` 座位新增第三个控制(order 42):
57
+ `Node 执行:拒绝 | 临时 24 小时 | 永久`,带 RCE 风险提示与剩余时间。
58
+ - agent 每个任务前读取能力文件;命中时结果信封 `authorization.provenance = "exec-capability"`,
59
+ warnings 记录 `EXEC_CAPABILITY`;pipeline 各 stage 同样覆盖。
60
+ - 文档 `docs/EXEC-CAPABILITY.md`;测试 `test/exec-capability.test.mjs`(7/7)、
61
+ client-bundle/loader-entry/exec-capability 合计 43/43。
62
+
63
+ Verified: 单测与路由测试全绿;候选版本真机验收(UI 激活 → agent 下一任务可执行 node -e)。
64
+
10
65
  ## [1.4.1] — 2026-10-22
11
66
 
12
67
  **修复:授权模型在从未 bootstrap 的机器上不可用(真机发现)。**
@@ -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.4.1", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.4.3", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
@@ -0,0 +1,57 @@
1
+ # Node 代码执行能力(子插件能力,DSH 界面激活)
2
+
3
+ **状态:v1.4.2 实现。** 把"允许该机器执行任意 Node 代码"从一份静态 allow-list 变成了
4
+ **插件界面里的一个显式开关**:客户在 DSH 的 composer 座位(目标机器旁)看到
5
+ `Node 执行:拒绝 | 临时 24 小时 | 永久`,点选即生效,不需要手改配置文件。
6
+
7
+ ## 为什么需要它
8
+
9
+ `node -e` 前缀等于放行任意 Node 代码,等效远程代码执行(RCE)。此前的做法是让用户手改
10
+ `~/.w2m/machine.json` 的 `allowedCommands`:容易写成永久、没有到期、没有 UI 提示、没有审计措辞。
11
+ 把这一条能力单独做成"子插件能力",其余 allow-list 语义不变,就能做到:
12
+
13
+ - **默认拒绝**:文件缺失/损坏/过期都读作 `denied`,fail-closed;
14
+ - **三档显式选择**:拒绝 / 临时 24h(可配置 TTL,上限 7 天)/ 永久;
15
+ - **到期自失效**:临时档写入绝对 `expires_at_ms`,agent 每次任务前读取,过期即拒绝,不需要重启;
16
+ - **可审计**:每次任务的结果信封记录 `authorization.provenance = "exec-capability"`,并在 warnings 里
17
+ 写明 `EXEC_CAPABILITY: node code execution …`;台账里仍是 task id + argv + 机器。
18
+
19
+ ## 组成
20
+
21
+ | 层 | 文件 | 作用 |
22
+ |---|---|---|
23
+ | util | `src/util/exec-capability.mjs` | 状态文件读写、过期判定、固定前缀 `node -e`、`describeExecCapability` |
24
+ | host 路由 | `src/plugin/exec-capability-route.mjs` | `GET/POST /api/w2m/capability`,同源、在 host 认证栅栏内 |
25
+ | agent | `src/agent/agent.mjs` | 每次任务前读文件;allow-list 拒绝后、grant 之前/之后按策略并集判定;stage 同样覆盖 |
26
+ | client | `lib/client.js` | `conversation.input.left` 座位第三个控制(order 42),三档菜单 + 风险提示 |
27
+
28
+ ## 状态文件
29
+
30
+ `~/.w2m/exec-capability.json`(每台机器本地,不通过 relay 同步):
31
+
32
+ ```json
33
+ { "protocol": "w2m-exec-capability/v1", "state": "temporary",
34
+ "activated_at_ms": 1792650000000, "expires_at_ms": 1792736400000,
35
+ "actor": { "kind": "operator", "id": "plugin-ui", "at": "..." } }
36
+ ```
37
+
38
+ ## 生效顺序
39
+
40
+ ```
41
+ 允许执行 ⇔ allow-list 匹配
42
+ ∨ grant 授权(显式、单次、≤10 分钟、可撤销)
43
+ ∨ exec-capability 激活(本机 UI 开关;临时档未过期)
44
+ ```
45
+
46
+ 本机开关与 grant 是**并列的两种授权来源**:grant 适合"操作员对某一次跨机执行签字",
47
+ capability 适合"本机机主长期/为期一天地打开这个能力"。结果信封的 provenance 会区分二者。
48
+
49
+ ## 撤销
50
+
51
+ 界面切回"拒绝"即可;或删除 `~/.w2m/exec-capability.json`;或手改 `state: "denied"`。
52
+ agent 下一个任务生效,不需要重启;插件自身回滚仍是"覆盖 `…w2m-dsh-plugin.pre-<ver>` 后重启 DSH"。
53
+
54
+ ## 测试
55
+
56
+ `test/exec-capability.test.mjs`:默认拒绝、三态写入、24h 到期边界、非法输入、路由 GET/POST/405;
57
+ `test/client-bundle.test.mjs`:第三个座位控制被注册、inject 面、样式与激活契约。
@@ -0,0 +1,28 @@
1
+ {
2
+ "version": "1.4.2-beta.1",
3
+ "case": "CAPABILITY-LIVE",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "6c92b251-3728-48bc-82a9-56cc40df48f2"
7
+ ],
8
+ "executed_at": "2026-10-22T05:36:57.619Z",
9
+ "executor": "lead",
10
+ "requirement": "with an empty allow-list, node -e executes only while the UI-activated exec capability is effective; an expired temporary activation is refused",
11
+ "task_id": "01M5H1R5B8A0QAWNPDES19SZ1F",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "plugin_version": "1.4.2-beta.1",
16
+ "allow_list_empty": true,
17
+ "plain_node_refused": true,
18
+ "capability_executed": true,
19
+ "expired_capability_refused": true
20
+ }
21
+ },
22
+ "tasks": {
23
+ "plain_refused": "01M5H1QPA9QD3SKK4KFB7V18WX",
24
+ "capability_allowed": "01M5H1R5B8A0QAWNPDES19SZ1F",
25
+ "expired_refused": "01M5H1S0BKNBVF9RMC8P1X0665"
26
+ },
27
+ "note": "candidate installed from the release tree through the relay blob channel; the stale allow-list in cordis.patch.yml was cleared so the capability alone authorized the execution; the capability file is what the DSH capability control writes"
28
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "version": "1.4.3",
3
+ "case": "BRIDGE-LIVE",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "71f0dd07-e957-4889-bf82-319a0d6b434a"
7
+ ],
8
+ "executed_at": "2026-10-22T07:08:13.838Z",
9
+ "executor": "lead",
10
+ "requirement": "the deployed WeChat bridge enables iLink message push (notifystart ok), answers failures through the renderBridgeError path, and runs under launchd KeepAlive",
11
+ "task_id": "01M5BRIDGEACCMVJ78GU0",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "notifystart": true,
16
+ "error_reply_code": true,
17
+ "launchd_state": "state = running",
18
+ "bridge_processes": 3
19
+ }
20
+ },
21
+ "note": "hotfixed bridge files were verified live on macmini before packaging; the same files are what v1.4.3 ships"
22
+ }
package/lib/client.js CHANGED
@@ -76,6 +76,8 @@ window.__ModuleLoader__.load({
76
76
  // the route is mounted inside the host's authentication fence, so the page's own cookie is the
77
77
  // credential and there is no descriptor to keep in sync.
78
78
  const ROUTE = '/api/w2m/target';
79
+ // v1.4.2: the UI-activated Node-code-execution switch (host: src/plugin/exec-capability-route.mjs).
80
+ const CAPABILITY_ROUTE = '/api/w2m/capability';
79
81
 
80
82
  /**
81
83
  * The locale namespace and its two dictionaries.
@@ -103,6 +105,15 @@ window.__ModuleLoader__.load({
103
105
  unreachable: '无法连接插件',
104
106
  routeMissing: '这个 Harness 没有提供插件目标路由',
105
107
  fleetUnreadable: '机器列表读取失败',
108
+ capLabel: 'Node 执行:{state}',
109
+ capDenied: '拒绝',
110
+ capTemporary: '临时 24 小时',
111
+ capPermanent: '永久',
112
+ capWarning: '开启后该机器可执行任意 Node 代码(等效远程代码执行)',
113
+ capMenuLabel: 'Node 代码执行能力',
114
+ capRemaining: '剩余 {hours} 小时',
115
+ capRouteMissing: '这个 Harness 没有提供能力路由',
116
+ capRefused: '能力切换被拒绝:{error}',
106
117
  },
107
118
  en: {
108
119
  all: 'All machines',
@@ -118,6 +129,15 @@ window.__ModuleLoader__.load({
118
129
  unreachable: 'the plugin could not be reached',
119
130
  routeMissing: 'this host does not serve the W2M target route',
120
131
  fleetUnreadable: 'the fleet could not be read',
132
+ capLabel: 'Node exec: {state}',
133
+ capDenied: 'denied',
134
+ capTemporary: 'temporary 24 h',
135
+ capPermanent: 'permanent',
136
+ capWarning: 'once on, this machine runs arbitrary Node code (equivalent to remote code execution)',
137
+ capMenuLabel: 'Node code execution capability',
138
+ capRemaining: '{hours} h left',
139
+ capRouteMissing: 'this host does not serve the capability route',
140
+ capRefused: 'the capability switch was refused: {error}',
121
141
  },
122
142
  };
123
143
 
@@ -230,6 +250,23 @@ window.__ModuleLoader__.load({
230
250
  },
231
251
  });
232
252
 
253
+ /** One JSON call to the capability route: refusals are values, exactly like the target route. */
254
+ async function callCapability(options) {
255
+ let response;
256
+ try {
257
+ response = await fetch(options.url, options.init);
258
+ } catch (error) {
259
+ return { ok: false, error: 'the plugin could not be reached: ' + (error instanceof Error ? error.message : String(error)) };
260
+ }
261
+ if (response.status === 404) return { ok: false, status: 404, routeMissing: true, error: 'capability route missing' };
262
+ let body = null;
263
+ try { body = await response.json(); } catch { return { ok: false, error: 'the capability route answered HTTP ' + response.status } }
264
+ if (response.ok !== true) return { ok: false, status: response.status, error: typeof body?.error === 'string' ? body.error : 'HTTP ' + response.status };
265
+ return { ok: true, capacity: body?.capability ?? null };
266
+ }
267
+ const readCapability = (signal) => callCapability({ url: CAPABILITY_ROUTE, init: { method: 'GET', credentials: 'same-origin', headers: { accept: 'application/json' }, signal } });
268
+ const writeCapability = (state, ttlMs) => callCapability({ url: CAPABILITY_ROUTE, init: { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify(ttlMs === undefined ? { state } : { state, ttl_ms: ttlMs }) } });
269
+
233
270
  const labelOf = (state, t) => state?.selected?.machine_id != null
234
271
  ? (state.selected.name ?? state.selected.machine_id)
235
272
  : t('all');
@@ -283,6 +320,18 @@ window.__ModuleLoader__.load({
283
320
  '.w2mTarget_name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
284
321
  '.w2mTarget_detail{margin-left:auto;flex:none;color:var(--dsw-alias-label-tertiary);font-size:11px;font-weight:400}',
285
322
  '.w2mTarget_option:disabled .w2mTarget_detail{color:var(--dsw-alias-label-caption)}',
323
+ '.w2mCap{position:relative;display:inline-flex;align-items:center}',
324
+ '.w2mCap_trigger{display:inline-flex;align-items:center;gap:4px;height:28px;padding:0 8px;border:none;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-secondary);font-size:13px;font-weight:500;line-height:20px;cursor:pointer;outline:none}',
325
+ '.w2mCap_trigger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
326
+ '.w2mCap_trigger:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}',
327
+ '.w2mCap_trigger[data-on="true"]{color:var(--dsw-alias-state-warn-primary)}',
328
+ '.w2mCap_dot{flex:none;width:8px;height:8px;border-radius:999px;background:var(--dsw-alias-state-idle-primary);box-shadow:inset 0 0 0 1px var(--dsw-alias-border-l2)}',
329
+ '.w2mCap_dot[data-on="true"]{background:var(--dsw-alias-state-warn-primary)}',
330
+ '.w2mCap_menu{position:fixed;z-index:60;box-sizing:border-box;width:280px;padding:4px;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:13px;line-height:20px;color:var(--dsw-alias-label-primary)}',
331
+ '.w2mCap_option{display:block;width:100%;box-sizing:border-box;padding:6px 8px;border:none;border-radius:var(--dsw-radius-sm);background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}',
332
+ '.w2mCap_option:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
333
+ '.w2mCap_option[data-active="true"]{color:var(--dsw-alias-state-business-primary)}',
334
+ '.w2mCap_note{padding:6px 8px;color:var(--dsw-alias-label-tertiary);font-weight:400}',
286
335
  '.w2mChat{position:relative;display:inline-flex;align-items:center}',
287
336
  '.w2mChat_trigger{display:inline-flex;align-items:center;gap:4px;height:28px;padding:0 8px;border:none;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-secondary);font-size:13px;font-weight:500;line-height:20px;cursor:pointer;outline:none}',
288
337
  '.w2mChat_trigger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
@@ -833,6 +882,114 @@ window.__ModuleLoader__.load({
833
882
  : null);
834
883
  }
835
884
 
885
+
886
+ /**
887
+ * v1.4.2: the Node-code-execution capability switch.
888
+ *
889
+ * Three explicit decisions, no fourth: deny, temporary (24 h, expiring), permanent. The host owns the file
890
+ * and the agent reads it before every task, so a choice here takes effect on the next dispatch; the control
891
+ * never claims the choice succeeded until the host's own answer says so.
892
+ */
893
+ function ExecCapability({ t }) {
894
+ const [open, setOpen] = React.useState(false);
895
+ const [capability, setCapability] = React.useState(null);
896
+ const [error, setError] = React.useState(null);
897
+ const [busy, setBusy] = React.useState(false);
898
+ const anchorRef = React.useRef(null);
899
+ const menuRef = React.useRef(null);
900
+ const [placement, setPlacement] = React.useState(null);
901
+
902
+ React.useEffect(() => {
903
+ const controller = new AbortController();
904
+ readCapability(controller.signal).then((result) => {
905
+ if (controller.signal.aborted) return;
906
+ if (result.ok) { setCapability(result.capacity); setError(null); }
907
+ else setError(result.routeMissing ? t('capRouteMissing') : result.error);
908
+ });
909
+ return () => controller.abort();
910
+ }, []);
911
+
912
+ React.useEffect(() => {
913
+ if (!open) return undefined;
914
+ const reposition = () => {
915
+ const anchor = anchorRef.current;
916
+ if (anchor === null) return;
917
+ const rect = anchor.getBoundingClientRect();
918
+ const width = Math.min(280, Math.max(240, Math.round(rect.width) + 120));
919
+ const left = Math.max(8, Math.min(rect.left, window.innerWidth - width - 8));
920
+ const top = Math.max(8, rect.top - 220);
921
+ setPlacement({ left, width, top });
922
+ };
923
+ const onKeyDown = (event) => { if (event.key === 'Escape') { setOpen(false); anchorRef.current?.focus?.(); } };
924
+ const onPointerDown = (event) => {
925
+ if (anchorRef.current?.contains(event.target) === true) return;
926
+ if (menuRef.current?.contains(event.target) === true) return;
927
+ setOpen(false);
928
+ };
929
+ reposition();
930
+ document.addEventListener('keydown', onKeyDown, true);
931
+ document.addEventListener('pointerdown', onPointerDown, true);
932
+ return () => {
933
+ document.removeEventListener('keydown', onKeyDown, true);
934
+ document.removeEventListener('pointerdown', onPointerDown, true);
935
+ };
936
+ }, [open]);
937
+
938
+ const active = capability?.effective === true;
939
+ const stateName = active ? (capability.state === 'temporary' ? t('capTemporary') : t('capPermanent')) : t('capDenied');
940
+ const hours = capability?.remaining_ms === null || capability?.remaining_ms === undefined
941
+ ? null
942
+ : Math.max(0, Math.round(capability.remaining_ms / 3_600_000));
943
+ const title = capability === null
944
+ ? t('capMenuLabel')
945
+ : active ? t('capWarning') : t('capDenied');
946
+
947
+ const choose = (state, ttlMs) => {
948
+ setBusy(true);
949
+ writeCapability(state, ttlMs).then((result) => {
950
+ setBusy(false);
951
+ if (result.ok) { setCapability(result.capacity); setError(null); setOpen(false); return; }
952
+ setError(result.routeMissing ? t('capRouteMissing') : result.error);
953
+ });
954
+ };
955
+
956
+ const menu = open && placement !== null
957
+ ? h('div', {
958
+ className: 'w2mCap_menu',
959
+ role: 'menu',
960
+ 'aria-label': t('capMenuLabel'),
961
+ ref: menuRef,
962
+ style: { left: placement.left + 'px', top: placement.top + 'px', width: placement.width + 'px' },
963
+ }, [
964
+ h('div', { key: 'warn', className: 'w2mCap_note', role: 'note' }, t('capWarning')),
965
+ h('button', { key: 'deny', type: 'button', className: 'w2mCap_option', 'data-active': active ? 'false' : 'true', onClick: () => choose('denied') }, t('capDenied')),
966
+ h('button', { key: 'temp', type: 'button', className: 'w2mCap_option', 'data-active': capability?.state === 'temporary' ? 'true' : 'false', onClick: () => choose('temporary', 24 * 60 * 60 * 1000) }, t('capTemporary')),
967
+ h('button', { key: 'perm', type: 'button', className: 'w2mCap_option', 'data-active': capability?.state === 'permanent' ? 'true' : 'false', onClick: () => choose('permanent') }, t('capPermanent')),
968
+ error === null ? null : h('div', { key: 'err', className: 'w2mCap_note', role: 'status' }, error),
969
+ ])
970
+ : null;
971
+
972
+ return h(React.Fragment, null, [
973
+ h('button', {
974
+ key: 'trigger',
975
+ type: 'button',
976
+ ref: anchorRef,
977
+ className: 'w2mCap_trigger',
978
+ 'data-on': active ? 'true' : 'false',
979
+ 'aria-label': t('capMenuLabel'),
980
+ 'aria-haspopup': 'menu',
981
+ 'aria-expanded': open,
982
+ 'aria-busy': busy,
983
+ title,
984
+ onClick: () => setOpen((wasOpen) => !wasOpen),
985
+ }, [
986
+ h('span', { key: 'dot', className: 'w2mCap_dot', 'data-on': active ? 'true' : 'false', 'aria-hidden': 'true' }),
987
+ h('span', { key: 'label' }, t('capLabel', { state: stateName }) + (hours === null ? '' : ' · ' + t('capRemaining', { hours }))),
988
+ ]),
989
+ menu,
990
+ ]);
991
+ }
992
+
836
993
  /**
837
994
  * Client plugin body: install the stylesheet, register the dictionaries, take the seat.
838
995
  *
@@ -909,7 +1066,20 @@ window.__ModuleLoader__.load({
909
1066
  sessionId,
910
1067
  t,
911
1068
  }),
912
- }, TargetSelect));
1069
+ }, TargetSelect)); // v1.4.2: the Node-code-execution switch, one seat to the right of the chat panel.
1070
+ try {
1071
+ slots.inject('conversation.input.left', () => slots.register({
1072
+ name: 'conversation.input.left',
1073
+ id: 'w2m-capability',
1074
+ order: 42,
1075
+ label: 'node code execution',
1076
+ inject: () => ({ t }),
1077
+ }, ExecCapability));
1078
+ } catch (error) {
1079
+ console.error('@twinsearth/w2m-dsh-plugin: the capability switch was not mounted; the rest of the page is unaffected', error);
1080
+ }
1081
+
1082
+
913
1083
 
914
1084
  // The fleet-chat panel: the same session-scoped seat, one slot after the target control.
915
1085
  // Wrapped in its own try/catch on purpose — the target control is the primary half of this
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
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",
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",
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",
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.4.1",
4
+ "version": "1.4.3",
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": [
@@ -55,6 +55,13 @@ import {
55
55
  import { createSpool } from './spool.mjs';
56
56
  import { MEMORY_FILE, appendMemory, memoryRecord } from './memory.mjs';
57
57
  import { matchAllowedCommand, parseAllowedCommands } from './allowed-commands.mjs';
58
+ import {
59
+ EXEC_CAPABILITY_PREFIXES,
60
+ EXEC_CAPABILITY_WARNING,
61
+ defaultExecCapabilityFile,
62
+ effectiveExecCapabilityPrefixes,
63
+ readExecCapability,
64
+ } from '../util/exec-capability.mjs';
58
65
  import { CRYPTO_REASON, DEVICE_KEY_FILE, DeviceKey, PeerKeyStore, deriveSigningKey } from './crypto.mjs';
59
66
  import { DEFAULT_P2P_MODE, P2P_DEFAULTS, P2PNode, normalizeP2PMode, normalizeP2PPort } from './p2p-node.mjs';
60
67
  import { punchSession, reverseDialSession } from './p2p.mjs';
@@ -1022,6 +1029,9 @@ export function createAgent(options) {
1022
1029
  // `string | string[][]` form is accepted, and a bare string list such as
1023
1030
  // `['node --test']` cannot reach `.map((prefix) => prefix.join(' '))`.
1024
1031
  const allowedCommands = parseAllowedCommands(options.allowedCommands ?? []);
1032
+ // v1.4.2: the UI-activated Node-code-execution capability. Read before every task, so turning the switch in the
1033
+ // plugin UI takes effect on the next task without restarting the agent; an expired temporary activation denies.
1034
+ const execCapabilityFile = options.execCapabilityFile ?? defaultExecCapabilityFile();
1025
1035
 
1026
1036
  if (!rabbitUrl) throw new TypeError('createAgent: rabbitUrl is required');
1027
1037
  if (!project) throw new TypeError('createAgent: project is required');
@@ -2977,7 +2987,11 @@ export function createAgent(options) {
2977
2987
  */
2978
2988
  let allowed = matchAllowedCommand(commandArgv, allowedCommands);
2979
2989
  let grantAuthorized = false;
2990
+ let capabilityAuthorized = false;
2991
+ let grantAttempted = false;
2992
+ const capabilityStatus = readExecCapability(execCapabilityFile);
2980
2993
  if (allowed.allowed !== true && offer && typeof offer.grant === 'object' && offer.grant !== null) {
2994
+ grantAttempted = true;
2981
2995
  const gatePaths = maintenancePaths();
2982
2996
  const gate = await authorizeWithGrant({
2983
2997
  offer,
@@ -2988,10 +3002,19 @@ export function createAgent(options) {
2988
3002
  if (gate.ok === true) { allowed = { allowed: true, ok: true, grant_id: gate.grantId }; grantAuthorized = true; }
2989
3003
  else allowed = { ok: false, reason_code: gate.code ?? 'GRANT_REJECTED', detail: gate.message };
2990
3004
  }
3005
+ // A capability is a machine-local standing authorization, so it may rescue an allow-list miss -- but never an
3006
+ // explicit grant refusal (replay, expiry, wrong machine). Masking GRANT_REPLAY with a capability would execute a
3007
+ // token the operator already spent, and the envelope would report a reason that names neither decision.
3008
+ if (allowed.allowed !== true && grantAttempted !== true && capabilityStatus.effective === true) {
3009
+ allowed = matchAllowedCommand(commandArgv, effectiveExecCapabilityPrefixes(execCapabilityFile));
3010
+ if (allowed.allowed === true) { capabilityAuthorized = true; warnings.push('EXEC_CAPABILITY: ' + EXEC_CAPABILITY_WARNING); }
3011
+ }
2991
3012
  // One decision, two audiences: the gate decides whether to run, and the envelope records which decision it was.
2992
3013
  const authorization = grantAuthorized
2993
3014
  ? { provenance: 'grant', capability_id: allowed.grant_id ?? null, grant_id: allowed.grant_id ?? null }
2994
- : { provenance: 'allow-list' };
3015
+ : capabilityAuthorized
3016
+ ? { provenance: 'exec-capability', capability_id: 'node-code-exec', grant_id: null }
3017
+ : { provenance: 'allow-list' };
2995
3018
  if (!allowed.allowed) {
2996
3019
  log('warn', `refused ${offer.task_id}: ${allowed.reason_code ?? REFUSAL.COMMAND_NOT_ALLOWED}`, { argv: commandArgv });
2997
3020
  await finish({
@@ -3182,7 +3205,12 @@ export function createAgent(options) {
3182
3205
 
3183
3206
  for (const stage of plannedStages) {
3184
3207
  // v1.3.10-beta.6: a grant that authorised step 5 covers the whole task, but only for the argv it signed.
3185
- const stageAllowed = grantAuthorized ? { allowed: true, ok: true } : matchAllowedCommand(stage.command_argv, allowedCommands);
3208
+ const stageAllowList = matchAllowedCommand(stage.command_argv, allowedCommands);
3209
+ const stageAllowed = grantAuthorized
3210
+ ? { allowed: true, ok: true }
3211
+ : stageAllowList.allowed === true
3212
+ ? { allowed: true, ok: true }
3213
+ : capabilityAuthorized ? matchAllowedCommand(stage.command_argv, EXEC_CAPABILITY_PREFIXES) : stageAllowList;
3186
3214
  if (stageAllowed.allowed) continue;
3187
3215
  const code = stageAllowed.reason_code ?? REFUSAL.COMMAND_NOT_ALLOWED;
3188
3216
  if (workspaceState !== null) { try { await removeWorktree({ repoDir: project, worktreeDir: workspaceState.worktreeDir }); } catch { /* best effort */ } }
@@ -372,6 +372,25 @@ export function renderTaskOutcome({ taskId, body }) {
372
372
  return `${renderVerdict({ taskId, verdict, machines: rows }).text}`;
373
373
  }
374
374
 
375
+ /**
376
+ * Render a per-message handling failure as something the operator can act on.
377
+ *
378
+ * The rule this exists for: **every message gets an answer, including the ones that broke something.**
379
+ * Before this, an exception anywhere in the per-message path escaped the loop, killed the process, and the
380
+ * chat window went silent with no indication why. A failure is now a reply with the phase, a stable code and
381
+ * the next step, and the loop continues.
382
+ *
383
+ * @param {unknown} error
384
+ * @param {string} [phase] where it failed: message | parse | reply | dispatch
385
+ * @returns {string}
386
+ */
387
+ export function renderBridgeError(error, phase = 'message') {
388
+ const code = error?.code ?? error?.name ?? 'ERR_UNKNOWN';
389
+ const message = error?.message ?? String(error ?? '未知错误');
390
+ return `处理失败(${phase},${code}):${String(message).slice(0, 300)}\n`
391
+ + '可以发送 /help 查看用法,或稍后重试这条指令。';
392
+ }
393
+
375
394
  /** Render a dispatch that never became a task. */
376
395
  export function renderFailure(error) {
377
396
  return renderDispatchFailure({
@@ -0,0 +1,75 @@
1
+ /**
2
+ * `/api/w2m/capability` — the host half of the UI-activated Node-code-execution switch.
3
+ *
4
+ * The composer's capability control (lib/client.js) reads and writes this same-origin route; the route owns
5
+ * the machine-local file (`~/.w2m/exec-capability.json`) so the browser never touches the filesystem and the
6
+ * device token never enters the page. The agent reads the same file before every task, which is what makes an
7
+ * activation effective without restarting the agent.
8
+ *
9
+ * Wire:
10
+ * GET /api/w2m/capability -> { ok, capability: describeExecCapability(...) }
11
+ * POST /api/w2m/capability { state: 'denied'|'temporary'|'permanent', ttl_ms? }
12
+ * -> { ok, capability }
13
+ *
14
+ * The states are the three choices an operator gets: refuse, temporary (24 h default) or permanent. There is no
15
+ * fourth, and the prefix it can add is fixed in the util module (`node -e`).
16
+ */
17
+ import {
18
+ EXEC_CAPABILITY_ROUTE_PATH,
19
+ describeExecCapability,
20
+ writeExecCapability,
21
+ } from '../util/exec-capability.mjs';
22
+
23
+ const NO_STORE = { 'cache-control': 'no-store' };
24
+
25
+ /**
26
+ * Mount the route on the host's Connection fetch registry.
27
+ *
28
+ * @param {object} ctx Cordis context
29
+ * @param {{capabilityFile: () => string}} deps
30
+ * @returns {{mounted: boolean, reason: string|null}}
31
+ */
32
+ export function registerExecCapabilityRoutes(ctx, deps) {
33
+ const status = { mounted: false, reason: 'the host has no Connection service' };
34
+ if (typeof ctx?.inject !== 'function') return status;
35
+ ctx.inject(['connection'], (webCtx) => {
36
+ const registry = webCtx?.connection?.fetch;
37
+ if (registry === undefined || typeof registry.register !== 'function') {
38
+ status.reason = 'the connection service carries no fetch registry';
39
+ return;
40
+ }
41
+ registry.register({
42
+ path: EXEC_CAPABILITY_ROUTE_PATH,
43
+ methods: ['GET', 'POST'],
44
+ requestBody: 'buffered',
45
+ fetch: async (request) => {
46
+ if (request.method !== 'GET' && request.method !== 'POST') {
47
+ return Response.json({ ok: false, error: request.method + ' is not a method this route answers', reason_code: 'METHOD_NOT_ALLOWED' }, { status: 405, headers: NO_STORE });
48
+ }
49
+ const file = deps.capabilityFile();
50
+ if (request.method === 'GET') {
51
+ return Response.json({ ok: true, capability: describeExecCapability(file) }, { headers: NO_STORE });
52
+ }
53
+ let body = null;
54
+ try {
55
+ body = await request.json();
56
+ } catch { /* a malformed body is refused below */ }
57
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) {
58
+ return Response.json({ ok: false, error: 'a JSON body with a state is required', reason_code: 'CAPABILITY_BODY_INVALID' }, { status: 400, headers: NO_STORE });
59
+ }
60
+ const written = writeExecCapability(file, {
61
+ state: body.state,
62
+ ttl_ms: body.ttl_ms,
63
+ actor: { kind: 'operator', id: 'plugin-ui', at: new Date().toISOString() },
64
+ });
65
+ if (written.ok !== true) {
66
+ return Response.json({ ok: false, error: written.detail, reason_code: written.code }, { status: 400, headers: NO_STORE });
67
+ }
68
+ return Response.json({ ok: true, capability: describeExecCapability(file) }, { headers: NO_STORE });
69
+ },
70
+ });
71
+ status.mounted = true;
72
+ status.reason = null;
73
+ });
74
+ return status;
75
+ }
@@ -184,6 +184,8 @@ import {
184
184
  // v1.1.17 chat: the host route behind the composer's fleet-chat panel. The relay side and the
185
185
  // `w2m_chat` tool shipped first; this is the browser-facing half that was still missing.
186
186
  import { registerChatRoutes } from './fleet-chat-route.mjs';
187
+ import { registerExecCapabilityRoutes } from './exec-capability-route.mjs';
188
+ import { defaultExecCapabilityFile } from '../util/exec-capability.mjs';
187
189
  import { createFleetCouncil, fleetCouncilThread } from './council-fleet.mjs';
188
190
  import { renderMemoryView } from './memory-view.mjs';
189
191
  import { renderMergeHint } from './merge-hint.mjs';
@@ -274,7 +276,7 @@ const MAX_STAGES = 16;
274
276
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
275
277
  * overrides it at runtime for tests and packagers.
276
278
  */
277
- const PLUGIN_VERSION = '1.4.1';
279
+ const PLUGIN_VERSION = '1.4.3';
278
280
 
279
281
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
280
282
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -3482,6 +3484,16 @@ export async function apply(ctx, config = {}) {
3482
3484
  // The composer's fleet-chat panel: `GET/POST /api/w2m/chat`, proxied to the relay's device-gated
3483
3485
  // `/v1/fleet/messages`. Optional like the target routes — a host without Connection keeps every
3484
3486
  // tool and the panel is simply not mounted.
3487
+ // v1.4.2: the UI-activated Node-code-execution capability (`GET/POST /api/w2m/capability`). The composer
3488
+ // control reads and writes it, the agent reads the same file before every task, so an activation takes effect
3489
+ // on the next task without a restart. Optional like the other browser routes.
3490
+ const capabilityRoutes = registerExecCapabilityRoutes(ctx, {
3491
+ capabilityFile: () => defaultExecCapabilityFile(),
3492
+ });
3493
+ if (capabilityRoutes.mounted !== true) {
3494
+ ctx.logger?.warn?.('w2m: the execution-capability control is inactive — ' + capabilityRoutes.reason);
3495
+ }
3496
+
3485
3497
  const chatRoutes = registerChatRoutes(ctx, {
3486
3498
  cfg: () => cfg,
3487
3499
  request,
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Node-code-execution capability: the machine-local switch behind the plugin UI.
3
+ *
4
+ * The allow-list is a standing decision made by the machine owner in a config file. This module is the
5
+ * *UI-activated* form of the same decision, scoped to one dangerous ability -- running arbitrary Node code
6
+ * (`node -e`) -- with an explicit state: denied, temporary (24 h by default) or permanent. The DSH plugin
7
+ * client renders the switch; the host route writes this file; the agent reads it before every task and
8
+ * treats an active capability exactly like an additional allow-list prefix.
9
+ *
10
+ * Fail-closed rules:
11
+ * * a missing, unreadable or malformed file means `denied` (the standing default), never "on";
12
+ * * `temporary` whose `expires_at_ms` has passed reads as `denied` and says `expired`, so an expiry
13
+ * cannot be mistaken for a configuration error;
14
+ * * the only prefix a capability can ever add is `node -e` -- a fixed constant, not caller input.
15
+ */
16
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
17
+ import { homedir } from 'node:os';
18
+ import { dirname, join } from 'node:path';
19
+
20
+ export const EXEC_CAPABILITY_PROTOCOL = 'w2m-exec-capability/v1';
21
+ export const EXEC_CAPABILITY_ROUTE_PATH = '/api/w2m/capability';
22
+ /** The one ability the switch can turn on. Fixed here, never supplied by a request. */
23
+ export const EXEC_CAPABILITY_PREFIXES = Object.freeze([Object.freeze(['node', '-e'])]);
24
+ export const EXEC_CAPABILITY_STATES = Object.freeze(['denied', 'temporary', 'permanent']);
25
+ export const DEFAULT_TEMPORARY_TTL_MS = 24 * 60 * 60 * 1000;
26
+ export const MAX_TEMPORARY_TTL_MS = 7 * 24 * 60 * 60 * 1000;
27
+ export const EXEC_CAPABILITY_WARNING = 'node code execution: allows any Node code on this machine (RCE)';
28
+
29
+ export function defaultExecCapabilityFile(home = homedir()) {
30
+ return join(home, '.w2m', 'exec-capability.json');
31
+ }
32
+
33
+ /** Normalise one state string; anything unknown is `null` (a caller error, not a default). */
34
+ export function normalizeExecCapabilityState(value) {
35
+ return typeof value === 'string' && EXEC_CAPABILITY_STATES.includes(value) ? value : null;
36
+ }
37
+
38
+ /**
39
+ * Read the capability state. Never throws: a broken file is `denied` with reason `unreadable`/`invalid`.
40
+ *
41
+ * @param {string} file
42
+ * @param {number} [nowMs]
43
+ * @returns {{state: string, effective: boolean, expires_at_ms: number|null, activated_at_ms: number|null, actor: object|null, reason: string|null}}
44
+ */
45
+ export function readExecCapability(file, nowMs = Date.now()) {
46
+ const denied = (reason) => ({ state: 'denied', effective: false, expires_at_ms: null, activated_at_ms: null, actor: null, reason });
47
+ let text;
48
+ try {
49
+ text = readFileSync(file, 'utf8');
50
+ } catch (error) {
51
+ return denied(error?.code === 'ENOENT' ? 'absent' : 'unreadable');
52
+ }
53
+ let raw;
54
+ try {
55
+ raw = JSON.parse(text);
56
+ } catch {
57
+ return denied('invalid');
58
+ }
59
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return denied('invalid');
60
+ const state = normalizeExecCapabilityState(raw.state);
61
+ if (state === null) return denied('invalid');
62
+ if (state === 'denied') return denied(null);
63
+ const expiresAt = Number.isFinite(raw.expires_at_ms) ? raw.expires_at_ms : null;
64
+ const activatedAt = Number.isFinite(raw.activated_at_ms) ? raw.activated_at_ms : null;
65
+ if (state === 'temporary') {
66
+ if (expiresAt === null) return denied('invalid');
67
+ if (expiresAt <= nowMs) return { state: 'denied', effective: false, expires_at_ms: expiresAt, activated_at_ms: activatedAt, actor: null, reason: 'expired' };
68
+ }
69
+ if (state === 'permanent' && expiresAt !== null) return denied('invalid');
70
+ return {
71
+ state,
72
+ effective: true,
73
+ expires_at_ms: state === 'temporary' ? expiresAt : null,
74
+ activated_at_ms: activatedAt,
75
+ actor: typeof raw.actor === 'object' && raw.actor !== null ? { ...raw.actor } : null,
76
+ reason: null,
77
+ };
78
+ }
79
+
80
+ /** The prefixes an active capability adds, or an empty array when it is denied/expired. */
81
+ export function effectiveExecCapabilityPrefixes(file, nowMs = Date.now()) {
82
+ return readExecCapability(file, nowMs).effective === true ? EXEC_CAPABILITY_PREFIXES.map((prefix) => [...prefix]) : [];
83
+ }
84
+
85
+ /**
86
+ * Write one capability decision. `temporary` requires a TTL and records an absolute expiry.
87
+ *
88
+ * @returns {{ok: boolean, code?: string, detail?: string, capability?: object}}
89
+ */
90
+ export function writeExecCapability(file, input, nowMs = Date.now()) {
91
+ const state = normalizeExecCapabilityState(input?.state);
92
+ if (state === null) return { ok: false, code: 'CAPABILITY_STATE_INVALID', detail: 'state must be denied, temporary or permanent' };
93
+ if (state === 'denied') {
94
+ try {
95
+ mkdirSync(dirname(file), { recursive: true });
96
+ writeFileSync(file, JSON.stringify({ protocol: EXEC_CAPABILITY_PROTOCOL, state: 'denied', activated_at_ms: nowMs, actor: input.actor ?? null }, null, 2) + '\n');
97
+ } catch (error) {
98
+ return { ok: false, code: 'CAPABILITY_WRITE_FAILED', detail: error?.message ?? String(error) };
99
+ }
100
+ return { ok: true, capability: readExecCapability(file, nowMs) };
101
+ }
102
+ let ttlMs = input?.ttl_ms;
103
+ if (state === 'temporary') {
104
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) ttlMs = DEFAULT_TEMPORARY_TTL_MS;
105
+ if (ttlMs > MAX_TEMPORARY_TTL_MS) return { ok: false, code: 'CAPABILITY_TTL_TOO_LONG', detail: 'temporary activation is limited to ' + MAX_TEMPORARY_TTL_MS + ' ms' };
106
+ ttlMs = Math.floor(ttlMs);
107
+ }
108
+ const record = {
109
+ protocol: EXEC_CAPABILITY_PROTOCOL,
110
+ state,
111
+ activated_at_ms: nowMs,
112
+ expires_at_ms: state === 'temporary' ? nowMs + ttlMs : null,
113
+ actor: typeof input?.actor === 'object' && input.actor !== null ? { ...input.actor } : null,
114
+ };
115
+ try {
116
+ mkdirSync(dirname(file), { recursive: true });
117
+ writeFileSync(file, JSON.stringify(record, null, 2) + '\n');
118
+ } catch (error) {
119
+ return { ok: false, code: 'CAPABILITY_WRITE_FAILED', detail: error?.message ?? String(error) };
120
+ }
121
+ return { ok: true, capability: readExecCapability(file, nowMs) };
122
+ }
123
+
124
+ /** The JSON the UI renders: state, effective, remaining time and the one prefix it would allow. */
125
+ export function describeExecCapability(file, nowMs = Date.now()) {
126
+ const capability = readExecCapability(file, nowMs);
127
+ return {
128
+ protocol: EXEC_CAPABILITY_PROTOCOL,
129
+ state: capability.state,
130
+ effective: capability.effective,
131
+ reason: capability.reason,
132
+ activated_at_ms: capability.activated_at_ms,
133
+ expires_at_ms: capability.expires_at_ms,
134
+ remaining_ms: capability.state === 'temporary' && capability.expires_at_ms !== null ? Math.max(0, capability.expires_at_ms - nowMs) : null,
135
+ allows: capability.effective === true ? EXEC_CAPABILITY_PREFIXES.map((prefix) => prefix.join(' ')) : [],
136
+ warning: EXEC_CAPABILITY_WARNING,
137
+ };
138
+ }