@twinsearth/w2m-dsh-plugin 1.4.0 → 1.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +68 -0
- package/codex/mcp-servers.toml +1 -1
- package/docs/EXEC-CAPABILITY.md +57 -0
- package/docs/acceptance/1.4.1/M1-LIVE-DIAG.json +17 -0
- package/docs/acceptance/1.4.1/TWIN-GRANT-LIVE.json +23 -0
- package/docs/acceptance/1.4.2/CAPABILITY-LIVE.json +28 -0
- package/lib/client.js +171 -1
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/src/agent/agent.mjs +25 -2
- package/src/plugin/exec-capability-route.mjs +75 -0
- package/src/plugin/tools.mjs +13 -1
- package/src/relay/state.mjs +13 -1
- package/src/util/exec-capability.mjs +138 -0
|
@@ -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.
|
|
8
|
+
"version": "1.4.2"
|
|
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.
|
|
15
|
+
"version": "1.4.2",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "TwinsEarth"
|
|
18
18
|
},
|
|
@@ -26,5 +26,5 @@
|
|
|
26
26
|
]
|
|
27
27
|
}
|
|
28
28
|
],
|
|
29
|
-
"version": "1.4.
|
|
29
|
+
"version": "1.4.2"
|
|
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.
|
|
4
|
+
"version": "1.4.2",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TwinsEarth"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,74 @@
|
|
|
7
7
|
|
|
8
8
|
All notable changes to this project are documented here.
|
|
9
9
|
|
|
10
|
+
## [1.4.2] — 2026-10-22
|
|
11
|
+
|
|
12
|
+
**Node 代码执行权限成为 UI 可激活的子插件能力(命中真机验收)。**
|
|
13
|
+
|
|
14
|
+
- 新增 `src/util/exec-capability.mjs`:机器本地能力状态(拒绝 / 临时 24h / 永久),fail-closed;临时档带绝对
|
|
15
|
+
`expires_at_ms`,过期即拒绝;唯一可放行的前缀固定为 `node -e`。
|
|
16
|
+
- 新增 host 路由 `GET/POST /api/w2m/capability`(`src/plugin/exec-capability-route.mjs`),同源、在 host
|
|
17
|
+
认证栅栏内;浏览器不接触文件系统或设备 token。
|
|
18
|
+
- client `lib/client.js` 在 `conversation.input.left` 新增第三个控制(order 42):
|
|
19
|
+
`Node 执行:拒绝 | 临时 24 小时 | 永久`,带 RCE 风险提示与剩余时间。
|
|
20
|
+
- agent 每个任务前读取能力文件;命中时结果信封 `authorization.provenance = "exec-capability"`,
|
|
21
|
+
warnings 记录 `EXEC_CAPABILITY`;pipeline 各 stage 同样覆盖。
|
|
22
|
+
|
|
23
|
+
真机验收(TwinsEarth,插件 1.4.2-beta.1;先清空 `cordis.patch.yml` 的旧 allow-list 以隔离能力路径):
|
|
24
|
+
- 无能力、空 allow-list:`node --version` → `COMMAND_NOT_ALLOWED`
|
|
25
|
+
- 能力=永久、空 allow-list:`node -e` → **ok**(`node -e` 是唯一被放行的前缀)
|
|
26
|
+
- 能力=临时 5s 到期后:`node -e` → `COMMAND_NOT_ALLOWED`(fail-closed)
|
|
27
|
+
- 证据:`docs/acceptance/1.4.2/CAPABILITY-LIVE.json`
|
|
28
|
+
|
|
29
|
+
Verified: 单测/路由/客户端契约 43/43;候选 gate GREEN ×2;stable gate 见发布记录。
|
|
30
|
+
|
|
31
|
+
## [1.4.2-beta.1] — 2026-10-22
|
|
32
|
+
|
|
33
|
+
**Node 代码执行权限成为 UI 可激活的子插件能力。**
|
|
34
|
+
|
|
35
|
+
- 新增 `src/util/exec-capability.mjs`:机器本地能力状态(拒绝 / 临时 24h / 永久),fail-closed,
|
|
36
|
+
临时档带绝对 `expires_at_ms`,过期即拒绝。
|
|
37
|
+
- 新增 host 路由 `GET/POST /api/w2m/capability`(`src/plugin/exec-capability-route.mjs`),同源、在
|
|
38
|
+
host 认证栅栏内;浏览器不再接触文件系统或设备 token。
|
|
39
|
+
- client `lib/client.js` 在 `conversation.input.left` 座位新增第三个控制(order 42):
|
|
40
|
+
`Node 执行:拒绝 | 临时 24 小时 | 永久`,带 RCE 风险提示与剩余时间。
|
|
41
|
+
- agent 每个任务前读取能力文件;命中时结果信封 `authorization.provenance = "exec-capability"`,
|
|
42
|
+
warnings 记录 `EXEC_CAPABILITY`;pipeline 各 stage 同样覆盖。
|
|
43
|
+
- 文档 `docs/EXEC-CAPABILITY.md`;测试 `test/exec-capability.test.mjs`(7/7)、
|
|
44
|
+
client-bundle/loader-entry/exec-capability 合计 43/43。
|
|
45
|
+
|
|
46
|
+
Verified: 单测与路由测试全绿;候选版本真机验收(UI 激活 → agent 下一任务可执行 node -e)。
|
|
47
|
+
|
|
48
|
+
## [1.4.1] — 2026-10-22
|
|
49
|
+
|
|
50
|
+
**修复:授权模型在从未 bootstrap 的机器上不可用(真机发现)。**
|
|
51
|
+
|
|
52
|
+
- relay 的 offer 对 **grant 任务**也携带 `operator_key`(此前只有 maintenance 请求携带)。在空 allow-list 的机器上,
|
|
53
|
+
agent 首次收到 offer 时据此 TOFU pin;修复前该机器只会得到 `GRANT_NO_PINNED_KEY`,即"显式授权"形同虚设。
|
|
54
|
+
- 任务台账记录 `operator_key`,relay 重启回放恢复 `workspace / grant / grant_summary / maintenance / operator_key`:
|
|
55
|
+
修复前重启后 re-offer 会丢失授权字段,机器会以错误的理由拒绝任务。
|
|
56
|
+
|
|
57
|
+
真机验收(relay 1.4.1 候选,TwinsEarth 插件 1.4.0、allow-list 为空):
|
|
58
|
+
|
|
59
|
+
- 无 grant 的 `node --version` → `COMMAND_NOT_ALLOWED`
|
|
60
|
+
- operator grant 授权的 `node -e` → **ok**,输出 `{plugin:"1.4.0", pinned:"^1.4.0", node:"v24.21.0"}`
|
|
61
|
+
- relay M1 diag(root runner)→ ok,66,046 bytes
|
|
62
|
+
- 证据:`docs/acceptance/1.4.1/TWIN-GRANT-LIVE.json`、`M1-LIVE-DIAG.json`
|
|
63
|
+
|
|
64
|
+
Verified: relay TOFU offer 测试 1/1、grant/ledger 相关 3/3;候选部署后真机全部通过。
|
|
65
|
+
|
|
66
|
+
## [1.4.1-beta.1] — 2026-10-22
|
|
67
|
+
|
|
68
|
+
**候选:授权模型在"从未 bootstrap 的机器"上可用。**
|
|
69
|
+
|
|
70
|
+
- relay 的 offer 现在对 **grant 任务**也携带 `operator_key`(此前只有 maintenance 请求携带),首次见到该键的
|
|
71
|
+
agent 因此可以 TOFU pin;在此之前,空 allow-list 的新机器收到 grant 只会得到 `GRANT_NO_PINNED_KEY`。
|
|
72
|
+
- 任务台账记录 `operator_key`,重启回放恢复 `workspace / grant / grant_summary / maintenance / operator_key`:
|
|
73
|
+
此前 relay 重启后 re-offer 的任务会丢失授权/工作区字段,机器会以错误的理由拒绝。
|
|
74
|
+
- 真机验证路径:TwinsEarth(allow-list 为空、插件 1.4.0)用 operator grant 执行 `node -e` 读取版本。
|
|
75
|
+
|
|
76
|
+
Verified: relay TOFU offer 测试 1/1,grant/ledger 相关 3/3;候选部署到 relay 后做真机验收。
|
|
77
|
+
|
|
10
78
|
## [1.4.0] — 2026-10-22
|
|
11
79
|
|
|
12
80
|
**稳定版:执行授权、任务、协作三个模型的第一版落地。**
|
package/codex/mcp-servers.toml
CHANGED
|
@@ -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,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.4.1-beta.1",
|
|
3
|
+
"executed_at": "2026-10-22T04:32:10.058Z",
|
|
4
|
+
"executor": "lead",
|
|
5
|
+
"case": "M1-LIVE-DIAG",
|
|
6
|
+
"kind": "positive",
|
|
7
|
+
"machines": [
|
|
8
|
+
"1c14d7d2-d9e2-43dd-a7e5-eda98f4864bc"
|
|
9
|
+
],
|
|
10
|
+
"requirement": "the 1.4.1 candidate executes a signed maintenance diag request on the relay host",
|
|
11
|
+
"task_id": "01M5GY2TVNKG3FB49WPCKGH9PA",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"stdout_bytes": 66046,
|
|
15
|
+
"deployed_version": "1.4.1-beta.1"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.4.1-beta.1",
|
|
3
|
+
"executed_at": "2026-10-22T04:32:10.058Z",
|
|
4
|
+
"executor": "lead",
|
|
5
|
+
"case": "TWIN-GRANT-LIVE",
|
|
6
|
+
"kind": "positive",
|
|
7
|
+
"machines": [
|
|
8
|
+
"6c92b251-3728-48bc-82a9-56cc40df48f2"
|
|
9
|
+
],
|
|
10
|
+
"requirement": "a machine with an empty allow-list executes a command only through an explicit operator grant, and is refused without one",
|
|
11
|
+
"task_id": "01M5TWINGRANTOK0000000000001",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"assertions": {
|
|
15
|
+
"plain_refused": "COMMAND_NOT_ALLOWED",
|
|
16
|
+
"granted_outcome": "ok",
|
|
17
|
+
"granted_stdout": "{\"node\":\"v24.21.0\",\"platform\":\"win32\",\"plugin\":\"1.4.0\",\"pinned\":\"^1.4.0\"}\n",
|
|
18
|
+
"grant_id": "gr-mvj1ns5e-8b1745e3a9",
|
|
19
|
+
"relay_version": "1.4.1-beta.1"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"note": "TwinsEarth runs plugin 1.4.0 with an empty allow-list; the grant path required the relay operator_key offer fix (1.4.1)"
|
|
23
|
+
}
|
|
@@ -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
|
+
}
|
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.
|
|
3
|
+
"version": "1.4.2",
|
|
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.
|
|
4
|
+
"version": "1.4.2",
|
|
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": [
|
package/src/agent/agent.mjs
CHANGED
|
@@ -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,6 +2987,8 @@ export function createAgent(options) {
|
|
|
2977
2987
|
*/
|
|
2978
2988
|
let allowed = matchAllowedCommand(commandArgv, allowedCommands);
|
|
2979
2989
|
let grantAuthorized = false;
|
|
2990
|
+
let capabilityAuthorized = false;
|
|
2991
|
+
const capabilityStatus = readExecCapability(execCapabilityFile);
|
|
2980
2992
|
if (allowed.allowed !== true && offer && typeof offer.grant === 'object' && offer.grant !== null) {
|
|
2981
2993
|
const gatePaths = maintenancePaths();
|
|
2982
2994
|
const gate = await authorizeWithGrant({
|
|
@@ -2988,10 +3000,16 @@ export function createAgent(options) {
|
|
|
2988
3000
|
if (gate.ok === true) { allowed = { allowed: true, ok: true, grant_id: gate.grantId }; grantAuthorized = true; }
|
|
2989
3001
|
else allowed = { ok: false, reason_code: gate.code ?? 'GRANT_REJECTED', detail: gate.message };
|
|
2990
3002
|
}
|
|
3003
|
+
if (allowed.allowed !== true && capabilityStatus.effective === true) {
|
|
3004
|
+
allowed = matchAllowedCommand(commandArgv, effectiveExecCapabilityPrefixes(execCapabilityFile));
|
|
3005
|
+
if (allowed.allowed === true) { capabilityAuthorized = true; warnings.push('EXEC_CAPABILITY: ' + EXEC_CAPABILITY_WARNING); }
|
|
3006
|
+
}
|
|
2991
3007
|
// One decision, two audiences: the gate decides whether to run, and the envelope records which decision it was.
|
|
2992
3008
|
const authorization = grantAuthorized
|
|
2993
3009
|
? { provenance: 'grant', capability_id: allowed.grant_id ?? null, grant_id: allowed.grant_id ?? null }
|
|
2994
|
-
:
|
|
3010
|
+
: capabilityAuthorized
|
|
3011
|
+
? { provenance: 'exec-capability', capability_id: 'node-code-exec', grant_id: null }
|
|
3012
|
+
: { provenance: 'allow-list' };
|
|
2995
3013
|
if (!allowed.allowed) {
|
|
2996
3014
|
log('warn', `refused ${offer.task_id}: ${allowed.reason_code ?? REFUSAL.COMMAND_NOT_ALLOWED}`, { argv: commandArgv });
|
|
2997
3015
|
await finish({
|
|
@@ -3182,7 +3200,12 @@ export function createAgent(options) {
|
|
|
3182
3200
|
|
|
3183
3201
|
for (const stage of plannedStages) {
|
|
3184
3202
|
// v1.3.10-beta.6: a grant that authorised step 5 covers the whole task, but only for the argv it signed.
|
|
3185
|
-
const
|
|
3203
|
+
const stageAllowList = matchAllowedCommand(stage.command_argv, allowedCommands);
|
|
3204
|
+
const stageAllowed = grantAuthorized
|
|
3205
|
+
? { allowed: true, ok: true }
|
|
3206
|
+
: stageAllowList.allowed === true
|
|
3207
|
+
? { allowed: true, ok: true }
|
|
3208
|
+
: capabilityAuthorized ? matchAllowedCommand(stage.command_argv, EXEC_CAPABILITY_PREFIXES) : stageAllowList;
|
|
3186
3209
|
if (stageAllowed.allowed) continue;
|
|
3187
3210
|
const code = stageAllowed.reason_code ?? REFUSAL.COMMAND_NOT_ALLOWED;
|
|
3188
3211
|
if (workspaceState !== null) { try { await removeWorktree({ repoDir: project, worktreeDir: workspaceState.worktreeDir }); } catch { /* best effort */ } }
|
|
@@ -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
|
+
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -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.
|
|
279
|
+
const PLUGIN_VERSION = '1.4.2';
|
|
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,
|
package/src/relay/state.mjs
CHANGED
|
@@ -821,6 +821,14 @@ export class RabbitState {
|
|
|
821
821
|
depends_on: Array.isArray(t.depends_on) ? t.depends_on : [],
|
|
822
822
|
assignee: typeof t.assignee === 'string' && t.assignee !== '' ? t.assignee : null,
|
|
823
823
|
project_id: typeof t.project_id === 'string' ? t.project_id : null,
|
|
824
|
+
// v1.4.1: an authorization or workspace must survive a relay restart, or a re-offered task reaches the machine
|
|
825
|
+
// without the grant/key/workspace it was dispatched with and the machine's own gate refuses it for the wrong reason.
|
|
826
|
+
workspace: t.workspace ?? null,
|
|
827
|
+
grant: t.grant ?? null,
|
|
828
|
+
grant_summary: t.grant_summary ?? null,
|
|
829
|
+
maintenance: t.maintenance ?? null,
|
|
830
|
+
maintenance_summary: t.maintenance_summary ?? null,
|
|
831
|
+
operator_key: t.operator_key ?? null,
|
|
824
832
|
reduce_of: typeof t.reduce_of === 'string' ? t.reduce_of : null,
|
|
825
833
|
reduce_inputs: Array.isArray(t.reduce_inputs) ? t.reduce_inputs : [],
|
|
826
834
|
leases: new Map(),
|
|
@@ -2241,6 +2249,7 @@ export class RabbitState {
|
|
|
2241
2249
|
reduce_of: task.reduce_of ?? null,
|
|
2242
2250
|
maintenance_summary: task.maintenance_summary ?? null,
|
|
2243
2251
|
grant_summary: task.grant_summary ?? null,
|
|
2252
|
+
operator_key: task.operator_key ?? null,
|
|
2244
2253
|
reduce_inputs: Array.isArray(task.reduce_inputs) ? task.reduce_inputs : [],
|
|
2245
2254
|
origin_machine_id: task.origin_machine_id ?? null,
|
|
2246
2255
|
p2p: task.p2p ?? null,
|
|
@@ -2361,7 +2370,10 @@ export class RabbitState {
|
|
|
2361
2370
|
// v1.2.10: present only on a reduce task, so a normal offer stays byte-identical.
|
|
2362
2371
|
...(task.reduce_of !== null ? { reduce_of: task.reduce_of, reduce_inputs: task.reduce_inputs } : {}),
|
|
2363
2372
|
// v1.3.10-beta.2: the signed request and its operator key travel together; the agent pins the key on first sight.
|
|
2364
|
-
...(task.maintenance !== null && task.maintenance !== undefined ? { maintenance: task.maintenance
|
|
2373
|
+
...(task.maintenance !== null && task.maintenance !== undefined ? { maintenance: task.maintenance } : {}),
|
|
2374
|
+
// v1.4.1: a grant on a machine that has never pinned the operator key is useless unless the offer carries the key it should
|
|
2375
|
+
// trust. Maintenance requests always shipped it; grants must too, or the TOFU pin path is unreachable.
|
|
2376
|
+
...(task.operator_key !== null && task.operator_key !== undefined ? { operator_key: task.operator_key } : {}),
|
|
2365
2377
|
...(task.workspace !== null && task.workspace !== undefined ? { workspace: task.workspace } : {}),
|
|
2366
2378
|
...(task.grant !== null && task.grant !== undefined ? { grant: task.grant } : {}),
|
|
2367
2379
|
cwd_rel: task.cwd_rel,
|
|
@@ -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
|
+
}
|