@twinsearth/w2m-dsh-plugin 1.5.0 → 1.5.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.
@@ -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.5.0"
8
+ "version": "1.5.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.5.0",
15
+ "version": "1.5.2",
16
16
  "author": {
17
17
  "name": "TwinsEarth"
18
18
  },
@@ -26,5 +26,5 @@
26
26
  ]
27
27
  }
28
28
  ],
29
- "version": "1.5.0"
29
+ "version": "1.5.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.5.0",
4
+ "version": "1.5.2",
5
5
  "author": {
6
6
  "name": "TwinsEarth"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -7,6 +7,40 @@
7
7
 
8
8
  All notable changes to this project are documented here.
9
9
 
10
+ ## [1.5.2] — 2026-10-23
11
+
12
+ **新机器自动获得 relay 归属(验签 bootstrap)+ 一次性配置工具。**
13
+
14
+ - 新增 `src/plugin/bootstrap.mjs`:机器用 fleetKey 派生 HMAC 证明请求 relay 列表;响应必须由
15
+ operator 公钥(pin)对 canonical payload(fleet_id + 排序后的 relays + issued_at + expires_at)
16
+ 验签;过期/错 fleet/被篡改/坏 JSON 全部命名拒绝。缓存每次读取都重验签名与有效期,手改缓存无效。
17
+ **任何失败都不改现有 relay 配置**,原因写入 `w2m_status.bootstrap.last_error`。
18
+ - `startLocalStack` 接线:无 `relayUrls` 且配置 `bootstrapUrl`+`fleetKey`+operator 公钥时启动即解析
19
+ (缓存优先,其次网络),成功则采用该 relay 池(首个为 primary);`w2m_status` 新增
20
+ `bootstrap {configured, url, source, relays, expires_at, last_error}`。
21
+ - 新增 `bin/w2m-bootstrap.mjs`:一条命令写 `relayUrls`/`fleetKey`/`autoStartAgent` 到 profile patch,
22
+ 只改受管键、写前备份、所有输入先校验;`--temporary` 写 `relayTemporary: true`,面板以
23
+ **红色“临时 Relay 节点(回退配置)”**显示。
24
+ - 新增 `bin/w2m-bootstrap-sign.mjs`:运维侧用 operator 私钥签名 relay 列表,输出可静态托管。
25
+ - 控制面板 relay 折叠区支持 temporary 红色标记(仅主题 token,无硬编码颜色)。
26
+ - 层模型修复:bootstrap 模块不依赖 chatops 层(layer-check 保持 clean)。
27
+ - 测试:bootstrap 5/5、bootstrap-cli 6/6、fleet-target 49/49、client-bundle 26/26;
28
+ 证据 `docs/acceptance/1.5.2/VERIFIED-BOOTSTRAP.json`(真实 fleet task `01M5ANYMVKXNU8E`)。
29
+ ## [1.5.1] — 2026-10-23
30
+
31
+ **备用链路的身份隔离 + 控制面板的 relay 折叠。**
32
+
33
+ - `bin/w2m-secondary-link.mjs`:默认给子进程独立身份目录(`<stateDir>/dsh-home`),并同时设置
34
+ `DSH_HOME` / `USERPROFILE` / `HOME`,可用 `--identity-home` 指定。修复的是 v1.5.0 现场实测的坑:
35
+ 备用链路首次启动覆盖了主链路 `~/.dsh/xclient/device.json`,机器在下一次重启前看不出问题,
36
+ 重启后却会以**备用身份**连到**错误的 relay**。这种"重启后才爆"的坑按默认隔离处理。
37
+ - 控制面板 relay 折叠:`GET /api/w2m/target` 快照新增 `relays`(`relayUrls` 数组优先,兼容单
38
+ `rabbitUrl`),探测 `/healthz` 后按 RTT 排序、标注 primary/standby/offline;超过 3 个节点时只显示
39
+ 3 个并给出 "+N 折叠" 提示,展开后显示全部(含离线节点,不隐藏故障)。
40
+ - 探针失败保留在列表末尾(`ok:false`),网络异常不会让节点从视图中消失,也不会抛进快照路径。
41
+ - 测试:secondary-link 7/7、fleet-target 49/49(新增 relay pool 四个用例);门禁 GREEN ×2;
42
+ 证据 `docs/acceptance/1.5.1/IDENTITY-ISOLATION-RELAY-FOLD.json`(真实 fleet task `01M5ANYMVKS3OQZ`)。
43
+
10
44
  ## [1.5.0] — 2026-10-23
11
45
 
12
46
  **多 relay 双通道(active-passive)+ relay 健康看门狗。**
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * w2m-bootstrap-sign (v1.5.2) — produce one signed relay-list answer for static hosting.
4
+ *
5
+ * The bootstrap endpoint serves a document; its only trust anchor is the Ed25519 signature the
6
+ * installed plugin verifies against its pinned operator key. Signing is therefore an operator
7
+ * action, run with the operator private key, and the output is a plain JSON file a static server
8
+ * can serve. `expires_at` is part of the signed payload, so a stale file stops being accepted
9
+ * without anyone having to remember to delete it.
10
+ *
11
+ * usage:
12
+ * w2m-bootstrap-sign.mjs --relays '[{"relays":["https://a:8787","https://b:8787"]}]' ... * --relays <json|file> --fleet-key <key> --operator-key <private.pem> [--ttl-hours 24] [--out file]
13
+ */
14
+ import { readFileSync, writeFileSync } from 'node:fs';
15
+ import { createPrivateKey, sign as signBytes } from 'node:crypto';
16
+ import { canonicalBootstrapPayload, BOOTSTRAP_PROTOCOL, fleetIdOf } from '../src/plugin/bootstrap.mjs';
17
+ import { decodeFleetKey } from '../src/chatops/fleet-join.mjs';
18
+
19
+ function arg(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; }
20
+
21
+ const relaysArg = arg(process.argv, '--relays');
22
+ const fleetKey = arg(process.argv, '--fleet-key');
23
+ const operatorKeyPath = arg(process.argv, '--operator-key');
24
+ const ttlHours = Number(arg(process.argv, '--ttl-hours') ?? '24');
25
+ const outPath = arg(process.argv, '--out');
26
+ if (!relaysArg || !fleetKey || !operatorKeyPath) {
27
+ process.stderr.write('usage: w2m-bootstrap-sign.mjs --relays <json|file> --fleet-key <key> --operator-key <private.pem> [--ttl-hours 24] [--out file]\n');
28
+ process.exit(2);
29
+ }
30
+ if (decodeFleetKey(fleetKey) === null) { process.stderr.write('w2m-bootstrap-sign: --fleet-key is not a 32-byte base64url key\n'); process.exit(2); }
31
+ let document;
32
+ try { document = JSON.parse(relaysArg.trim().startsWith('{') || relaysArg.trim().startsWith('[') ? relaysArg : readFileSync(relaysArg, 'utf8')); }
33
+ catch (error) { process.stderr.write('w2m-bootstrap-sign: cannot parse --relays: ' + error.message + '\n'); process.exit(2); }
34
+ const relayList = Array.isArray(document) ? document : document.relays;
35
+ const relays = [...new Set((Array.isArray(relayList) ? relayList : []).filter((u) => typeof u === 'string' && u.trim() !== '').map((u) => u.trim().replace(/\/+$/, '')))];
36
+ if (relays.length === 0) { process.stderr.write('w2m-bootstrap-sign: no relay URLs in --relays\n'); process.exit(2); }
37
+ const fleetId = fleetIdOf(fleetKey);
38
+ const now = Date.now();
39
+ const issuedAt = new Date(now).toISOString();
40
+ const expiresAt = new Date(now + Math.max(1, Math.round(ttlHours * 3600_000))).toISOString();
41
+ const payload = canonicalBootstrapPayload({ fleet_id: fleetId, relays, issued_at: issuedAt, expires_at: expiresAt });
42
+ const key = createPrivateKey(readFileSync(operatorKeyPath));
43
+ const signature = signBytes(null, Buffer.from(payload, 'utf8'), key).toString('base64');
44
+ const answer = { protocol: BOOTSTRAP_PROTOCOL, fleet_id: fleetId, relays, issued_at: issuedAt, expires_at: expiresAt, signature: { alg: 'ed25519', value: signature } };
45
+ const text = JSON.stringify(answer, null, 2) + '\n';
46
+ if (outPath) { writeFileSync(outPath, text, 'utf8'); process.stdout.write('w2m-bootstrap-sign: wrote ' + outPath + ' (fleet ' + fleetId.slice(0, 16) + '…, expires ' + expiresAt + ')\n'); }
47
+ else process.stdout.write(text);
@@ -0,0 +1,303 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * w2m-bootstrap — one command to point a freshly installed plugin at the right relays.
4
+ *
5
+ * ## Why a tool instead of hand-editing the profile
6
+ *
7
+ * The profile patch is YAML that the loader merges into a bundle row, and three mistakes are easy
8
+ * and silent: a `name:` assertion that skips the whole patch, an `insert:` that duplicates the row,
9
+ * and an `autoStartAgent: false` that leaves the agent never starting. This tool writes the small,
10
+ * known-good shape and refuses the inputs it cannot honour, so the Nth machine costs one command
11
+ * instead of one careful copy-paste.
12
+ *
13
+ * ## Two sources, one destination
14
+ *
15
+ * `--bootstrap` accepts either
16
+ * * an **http(s) URL** — fetched and (when `--operator-key` is given) verified with the same
17
+ * signature check the runtime bootstrap uses, or
18
+ * * a **JSON document / file path** — `{"relays": ["https://…", …]}` (a signed answer is accepted
19
+ * only together with `--operator-key`; without a pin the document is used as operator-supplied
20
+ * configuration, which is the normal case for an install script that already carries the list).
21
+ *
22
+ * Nothing is written until every input parses and validates, and the existing patch is backed up.
23
+ *
24
+ * ## The temporary marker
25
+ *
26
+ * A machine that cannot reach its own fleet may be pointed at a fallback relay list so it is not
27
+ * stranded. That is honest only if it is visible: `--temporary` writes `relayTemporary: true`, and
28
+ * the fleet panel renders the relay section in red as **临时 Relay 节点**. Removing the marker (by a
29
+ * later run without the flag) is what turns the fallback into a settled configuration.
30
+ *
31
+ * ## Usage
32
+ *
33
+ * node bin/w2m-bootstrap.mjs \
34
+ * --bootstrap https://relays.example/bootstrap \
35
+ * --fleet-key <account fleet key> \
36
+ * [--operator-key <operator public key PEM file>] \
37
+ * [--profile desktop] [--name <machine name>] [--temporary] [--dry-run]
38
+ */
39
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
40
+ import { join, resolve } from 'node:path';
41
+ import { homedir } from 'node:os';
42
+ import { fleetIdOf, resolveBootstrap } from '../src/plugin/bootstrap.mjs';
43
+ import { decodeFleetKey } from '../src/chatops/fleet-join.mjs';
44
+
45
+ export const CLI_REFUSAL = Object.freeze({
46
+ BAD_BOOTSTRAP: 'BOOTSTRAP_CLI_BAD_SOURCE',
47
+ BAD_FLEET_KEY: 'BOOTSTRAP_CLI_BAD_FLEET_KEY',
48
+ BAD_RELAYS: 'BOOTSTRAP_CLI_BAD_RELAYS',
49
+ PATCH_MISSING_ROW: 'BOOTSTRAP_CLI_PATCH_MISSING_ROW',
50
+ PATCH_MISSING_CONFIG: 'BOOTSTRAP_CLI_PATCH_MISSING_CONFIG',
51
+ REFUSED: 'BOOTSTRAP_CLI_REFUSED',
52
+ });
53
+
54
+ /**
55
+ * Parse the command line. Every flag has one spelling; an unknown flag is a refusal, not a silent
56
+ * miss — a bootstrap that quietly ignored `--fleet-key` would write a config that cannot join.
57
+ *
58
+ * @param {string[]} argv
59
+ * @returns {object}
60
+ */
61
+ export function parseBootstrapCliArgs(argv) {
62
+ const out = {
63
+ bootstrap: null,
64
+ fleetKey: null,
65
+ operatorKeyPath: null,
66
+ profile: 'desktop',
67
+ name: null,
68
+ temporary: false,
69
+ dryRun: false,
70
+ help: false,
71
+ };
72
+ for (let i = 0; i < argv.length; i += 1) {
73
+ const flag = argv[i];
74
+ const value = () => {
75
+ const next = argv[i + 1];
76
+ if (typeof next !== 'string' || next.startsWith('--')) throw new Error(`${flag} needs a value`);
77
+ i += 1;
78
+ return next;
79
+ };
80
+ switch (flag) {
81
+ case '--bootstrap': out.bootstrap = value(); break;
82
+ case '--fleet-key': out.fleetKey = value(); break;
83
+ case '--operator-key': out.operatorKeyPath = value(); break;
84
+ case '--profile': out.profile = value(); break;
85
+ case '--name': out.name = value(); break;
86
+ case '--temporary': out.temporary = true; break;
87
+ case '--dry-run': out.dryRun = true; break;
88
+ case '--help':
89
+ case '-h': out.help = true; break;
90
+ default: throw new Error(`unknown flag: ${flag}`);
91
+ }
92
+ }
93
+ if (out.help === true) return out;
94
+ if (typeof out.bootstrap !== 'string' || out.bootstrap.trim() === '') {
95
+ throw new Error('--bootstrap <json|url> is required');
96
+ }
97
+ if (typeof out.fleetKey !== 'string' || out.fleetKey.trim() === '') {
98
+ throw new Error('--fleet-key <key> is required');
99
+ }
100
+ if (decodeFleetKey(out.fleetKey.trim()) === null) {
101
+ throw Object.assign(new Error('--fleet-key is not a 32-byte base64url fleet key'), { reason_code: CLI_REFUSAL.BAD_FLEET_KEY });
102
+ }
103
+ return out;
104
+ }
105
+
106
+ /**
107
+ * Turn the `--bootstrap` value into a relay list.
108
+ *
109
+ * @param {object} args Parsed CLI args.
110
+ * @param {{fetchImpl?: Function, nowMs?: number}} [deps]
111
+ * @returns {Promise<{ok: true, relays: string[], source: 'url'|'json'|'file'}|{ok: false, reason_code: string, detail: string}>}
112
+ */
113
+ export async function resolveBootstrapSource(args, deps = {}) {
114
+ const raw = String(args.bootstrap).trim();
115
+ const fleetKey = args.fleetKey.trim();
116
+ if (/^https?:\/\//.test(raw)) {
117
+ const operatorPublicKeyPem = args.operatorKeyPath === null
118
+ ? ''
119
+ : readFileSync(resolve(args.operatorKeyPath), 'utf8');
120
+ if (operatorPublicKeyPem === '') {
121
+ // A URL without a pin cannot be verified; fetch it as operator-supplied JSON instead and say
122
+ // so in the returned source, so the caller can warn rather than pretend it was verified.
123
+ try {
124
+ const response = await (deps.fetchImpl ?? globalThis.fetch)(raw, { method: 'GET' });
125
+ if (!response.ok) return { ok: false, reason_code: CLI_REFUSAL.BAD_BOOTSTRAP, detail: `HTTP ${response.status}` };
126
+ const body = await response.json();
127
+ return relaysFromDocument(body);
128
+ } catch (error) {
129
+ return { ok: false, reason_code: CLI_REFUSAL.BAD_BOOTSTRAP, detail: error instanceof Error ? error.message : String(error) };
130
+ }
131
+ }
132
+ const resolved = await resolveBootstrap({
133
+ url: raw,
134
+ fleetKey,
135
+ operatorPublicKeyPem,
136
+ cacheFile: '',
137
+ fetchImpl: deps.fetchImpl,
138
+ nowMs: deps.nowMs,
139
+ });
140
+ return resolved.ok === true
141
+ ? { ok: true, relays: resolved.relays, source: 'url' }
142
+ : { ok: false, reason_code: resolved.reason_code, detail: resolved.detail };
143
+ }
144
+ // Inline JSON may be an object or a bare array; anything else is a path. The distinction is the
145
+ // first non-space character, not "does it parse", so a typo'd path fails as a path (a named error)
146
+ // rather than as a JSON parse error that hides which input was actually read.
147
+ const trimmed = raw.trim();
148
+ const text = (trimmed.startsWith('{') || trimmed.startsWith('[')) ? trimmed : readFileSync(resolve(trimmed), 'utf8');
149
+ try {
150
+ return relaysFromDocument(JSON.parse(text));
151
+ } catch (error) {
152
+ return { ok: false, reason_code: CLI_REFUSAL.BAD_BOOTSTRAP, detail: error instanceof Error ? error.message : String(error) };
153
+ }
154
+ }
155
+
156
+ /** Accept `{relays:[…]}` (or an array of URLs) and normalize it. */
157
+ export function relaysFromDocument(document) {
158
+ const list = Array.isArray(document) ? document
159
+ : (document !== null && typeof document === 'object' && Array.isArray(document.relays) ? document.relays : null);
160
+ if (list === null) {
161
+ return { ok: false, reason_code: CLI_REFUSAL.BAD_RELAYS, detail: 'the bootstrap document must be {"relays": [...]} or an array of relay URLs' };
162
+ }
163
+ const relays = [...new Set(list
164
+ .filter((url) => typeof url === 'string' && url.trim() !== '')
165
+ .map((url) => url.trim().replace(/\/+$/, '')))];
166
+ if (relays.length === 0) return { ok: false, reason_code: CLI_REFUSAL.BAD_RELAYS, detail: 'the document carries no relay URLs' };
167
+ for (const url of relays) {
168
+ if (!/^https?:\/\/[^\s]+$/.test(url)) return { ok: false, reason_code: CLI_REFUSAL.BAD_RELAYS, detail: `${JSON.stringify(url)} is not an http(s) base URL` };
169
+ }
170
+ return { ok: true, relays, source: 'json' };
171
+ }
172
+
173
+ /**
174
+ * Rewrite the `- id: w2m` block's config with the resolved values, in place and with a backup.
175
+ *
176
+ * Deliberately line-based: this file is edited by humans and must stay readable, and a YAML emitter
177
+ * would rewrite comments the operator relies on. The block is found by its exact id row, the config
178
+ * block by its `config:` child; only the keys this tool owns are replaced or appended.
179
+ *
180
+ * @param {string} file
181
+ * @param {{relays: string[], fleetKey: string, relayUrl: null|string, temporary: boolean, machineName: string|null}} values
182
+ * @returns {{ok: true, file: string, backup: string}|{ok: false, reason_code: string, detail: string}}
183
+ */
184
+ export function writeBootstrapPatch(file, values) {
185
+ if (!existsSync(file)) {
186
+ return { ok: false, reason_code: CLI_REFUSAL.PATCH_MISSING_ROW, detail: `profile patch not found at ${file}` };
187
+ }
188
+ // Normalize here as well as in the source resolver: callers that pass a document straight through
189
+ // must not be able to write a trailing-slash base URL that later compares unequal to every other
190
+ // use of the same relay.
191
+ const relays = [...new Set((Array.isArray(values.relays) ? values.relays : [])
192
+ .filter((url) => typeof url === 'string' && url.trim() !== '')
193
+ .map((url) => url.trim().replace(/\/+$/, '')))];
194
+ if (relays.length === 0) {
195
+ return { ok: false, reason_code: CLI_REFUSAL.BAD_RELAYS, detail: 'no relay URLs were supplied' };
196
+ }
197
+ const lines = readFileSync(file, 'utf8').split('\n');
198
+ const start = lines.findIndex((line) => /^-\s*id:\s*w2m\s*$/.test(line.trim()));
199
+ if (start < 0) {
200
+ return { ok: false, reason_code: CLI_REFUSAL.PATCH_MISSING_ROW, detail: 'no `- id: w2m` row exists in the patch; the bundle should have inserted it' };
201
+ }
202
+ let end = lines.length;
203
+ for (let i = start + 1; i < lines.length; i += 1) {
204
+ if (/^-\s*id:\s*\S+/.test(lines[i].trim())) { end = i; break; }
205
+ }
206
+ let configIndex = -1;
207
+ for (let i = start + 1; i < end; i += 1) {
208
+ if (/^\s*config:\s*$/.test(lines[i])) { configIndex = i; break; }
209
+ }
210
+ if (configIndex < 0) {
211
+ return { ok: false, reason_code: CLI_REFUSAL.PATCH_MISSING_CONFIG, detail: 'the `- id: w2m` row has no `config:` block to write into' };
212
+ }
213
+ const indent = `${(lines[configIndex].match(/^\s*/) ?? [''])[0]} `;
214
+ const managed = {
215
+ flock: undefined,
216
+ rabbitUrl: relays[0],
217
+ fleetKey: `'${values.fleetKey}'`,
218
+ autoStartAgent: 'true',
219
+ relayTemporary: values.temporary === true ? 'true' : null,
220
+ machineName: values.machineName === null ? null : values.machineName,
221
+ };
222
+ let block = lines.slice(configIndex + 1, end);
223
+ // Drop managed keys from the block (they are re-emitted below), keep everything else untouched.
224
+ block = block.filter((line) => {
225
+ const key = (line.match(/^\s*([A-Za-z][\w]*)\s*:/) ?? [])[1];
226
+ return key === undefined || !(key in managed);
227
+ });
228
+ const emitted = [];
229
+ if (managed.machineName !== null) emitted.push(`${indent}machineName: ${managed.machineName}`);
230
+ emitted.push(`${indent}rabbitUrl: ${managed.rabbitUrl}`);
231
+ emitted.push(`${indent}relayUrls:`);
232
+ for (const relay of relays) emitted.push(`${indent} - ${relay}`);
233
+ emitted.push(`${indent}fleetKey: ${managed.fleetKey}`);
234
+ emitted.push(`${indent}autoStartAgent: true`);
235
+ if (values.temporary === true) emitted.push(`${indent}relayTemporary: true`);
236
+ const next = [...lines.slice(0, configIndex + 1), ...emitted, ...block, ...lines.slice(end)];
237
+ const text = `${next.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n*$/, '')}\n`;
238
+ const backup = `${file}.bak-${Date.now()}`;
239
+ writeFileSync(backup, lines.join('\n'), 'utf8');
240
+ const tmp = `${file}.tmp-${process.pid}`;
241
+ writeFileSync(tmp, text, 'utf8');
242
+ renameSync(tmp, file);
243
+ return { ok: true, file, backup };
244
+ }
245
+
246
+ /** Default patch path for a profile, resolved the same way the loader does. */
247
+ export function defaultPatchPath(profile) {
248
+ const home = process.env.DSH_HOME && process.env.DSH_HOME.trim() !== '' ? process.env.DSH_HOME : join(homedir(), '.dsh');
249
+ return join(home, 'profiles', profile, 'cordis.patch.yml');
250
+ }
251
+
252
+ async function main() {
253
+ let args;
254
+ try {
255
+ args = parseBootstrapCliArgs(process.argv.slice(2));
256
+ } catch (error) {
257
+ process.stderr.write(`w2m-bootstrap: ${error.message}\n`);
258
+ process.exit(2);
259
+ }
260
+ if (args.help === true) {
261
+ process.stdout.write('usage: w2m-bootstrap.mjs --bootstrap <json|url> --fleet-key <key> [--operator-key <pem>] [--profile desktop] [--name <machine>] [--temporary] [--dry-run]\n\n'
262
+ + 'Writes relayUrls (first = primary), fleetKey and autoStartAgent: true into the profile patch.\n'
263
+ + '--temporary marks the relay list as a fallback; the fleet panel then renders it as 临时 Relay 节点.\n');
264
+ return;
265
+ }
266
+ const resolved = await resolveBootstrapSource(args);
267
+ if (resolved.ok !== true) {
268
+ process.stderr.write(`w2m-bootstrap: ${resolved.reason_code}: ${resolved.detail}\n`);
269
+ process.exit(1);
270
+ }
271
+ const patch = defaultPatchPath(args.profile);
272
+ if (args.dryRun === true) {
273
+ process.stdout.write(`w2m-bootstrap: dry run — would write to ${patch}\n relays: ${resolved.relays.join(', ')}\n fleet: ${fleetIdOf(args.fleetKey)?.slice(0, 16)}…\n temporary: ${args.temporary}\n`);
274
+ return;
275
+ }
276
+ const written = writeBootstrapPatch(patch, {
277
+ relays: resolved.relays,
278
+ fleetKey: args.fleetKey.trim(),
279
+ relayUrl: resolved.relays[0],
280
+ temporary: args.temporary,
281
+ machineName: args.name,
282
+ });
283
+ if (written.ok !== true) {
284
+ process.stderr.write(`w2m-bootstrap: ${written.reason_code}: ${written.detail}\n`);
285
+ process.exit(1);
286
+ }
287
+ process.stdout.write([
288
+ `w2m-bootstrap: wrote ${patch}`,
289
+ ` relays (primary first): ${resolved.relays.join(', ')}`,
290
+ ` fleet: ${fleetIdOf(args.fleetKey)?.slice(0, 16)}…`,
291
+ args.temporary === true ? ' relayTemporary: true — this machine uses a fallback relay list (临时 Relay 节点)' : ' relayTemporary: off',
292
+ ` backup: ${written.backup}`,
293
+ 'Restart DSH (quick-reload does not re-read the profile), then check w2m_status:',
294
+ ' rabbitUrl should name the first relay, and the fleet panel should list the relay pool.',
295
+ ].join('\n') + '\n');
296
+ }
297
+
298
+ if (process.argv[1] !== undefined && import.meta.url === new URL(`file://${process.argv[1].replace(/\\/g, '/')}`).href) {
299
+ main().catch((error) => {
300
+ process.stderr.write(`w2m-bootstrap: ${error?.stack ?? error}\n`);
301
+ process.exit(1);
302
+ });
303
+ }
@@ -47,6 +47,7 @@ const HERE = dirname(fileURLToPath(import.meta.url));
47
47
  const DEFAULTS = Object.freeze({
48
48
  project: process.cwd(),
49
49
  stateDir: null,
50
+ identityHome: null,
50
51
  logFile: null,
51
52
  allowedCommands: '[]',
52
53
  restartDelayMs: 3000,
@@ -68,6 +69,7 @@ export function parseSecondaryArgs(argv) {
68
69
  fleetKey: null,
69
70
  name: null,
70
71
  stateDir: null,
72
+ identityHome: null,
71
73
  logFile: null,
72
74
  project: DEFAULTS.project,
73
75
  operatorToken: null,
@@ -86,6 +88,7 @@ export function parseSecondaryArgs(argv) {
86
88
  case '--fleet-key': out.fleetKey = value(); break;
87
89
  case '--name': out.name = value(); break;
88
90
  case '--state': out.stateDir = value(); break;
91
+ case '--identity-home': out.identityHome = value(); break;
89
92
  case '--log': out.logFile = value(); break;
90
93
  case '--project': out.project = value(); break;
91
94
  case '--operator-token': out.operatorToken = value(); break;
@@ -104,6 +107,18 @@ export function parseSecondaryArgs(argv) {
104
107
  }
105
108
  const suffix = out.name ?? 'standby';
106
109
  out.stateDir = out.stateDir ?? join(process.cwd(), '.w2m-secondary', suffix);
110
+ /**
111
+ * v1.5.1: **the standby gets its own identity home.**
112
+ *
113
+ * Measured in the field: the first v1.5.0 standby overwrote `~/.dsh/xclient/device.json` -- the
114
+ * primary link's identity -- because `w2m-localside` derives that path from `DSH_HOME`. The
115
+ * machine kept working (the running agent holds its token in memory) until its next restart, at
116
+ * which point it would have come up as the *standby* identity on the wrong relay. A link that is
117
+ * only correct until the next restart is a trap, so isolation is the default rather than an
118
+ * option: the child runs with `DSH_HOME` (and the platforms' home variables) pointed at this
119
+ * directory, and `--identity-home` exists for operators who need a specific path.
120
+ */
121
+ out.identityHome = out.identityHome ?? join(out.stateDir, 'dsh-home');
107
122
  out.logFile = out.logFile ?? join(out.stateDir, 'link.log');
108
123
  return out;
109
124
  }
@@ -158,10 +173,25 @@ export async function runSupervisor(options, deps) {
158
173
  const stopSignal = deps.stopSignal ?? { stopped: false };
159
174
  const args = buildLocalsideArgs(options);
160
175
  let delay = DEFAULTS.restartDelayMs;
161
- log(`supervising secondary link relay=${options.relay} state=${options.stateDir}`);
176
+ // v1.5.1: the child must not see the primary link's identity. Both spellings are set because
177
+ // Windows resolves the home directory from USERPROFILE while POSIX uses HOME, and `DSH_HOME` is
178
+ // what `src/agent/identity.mjs` actually reads for `<DSH_HOME>/xclient/device.json`.
179
+ const childEnv = {
180
+ ...process.env,
181
+ DSH_HOME: options.identityHome,
182
+ USERPROFILE: options.identityHome,
183
+ HOME: options.identityHome,
184
+ };
185
+ try {
186
+ mkdirSync(options.identityHome, { recursive: true, mode: 0o700 });
187
+ } catch (error) {
188
+ log(`cannot create identity home ${options.identityHome}: ${error?.message ?? error}`);
189
+ throw error;
190
+ }
191
+ log(`supervising secondary link relay=${options.relay} state=${options.stateDir} identityHome=${options.identityHome}`);
162
192
  while (!stopSignal.stopped) {
163
193
  const startedAt = Date.now();
164
- const child = spawnFn(process.execPath, args, { stdio: 'ignore', windowsHide: true });
194
+ const child = spawnFn(process.execPath, args, { stdio: 'ignore', windowsHide: true, env: childEnv });
165
195
  log(`child started pid=${child.pid ?? '?'}`);
166
196
  const exit = await new Promise((resolve) => {
167
197
  child.once('exit', (code, signal) => resolve({ code, signal }));
@@ -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.5.0", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.5.2", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
@@ -0,0 +1,22 @@
1
+ {
2
+ "version": "1.5.1",
3
+ "case": "IDENTITY-ISOLATION-RELAY-FOLD",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-23T09:40:01.304Z",
9
+ "executor": "lead",
10
+ "requirement": "the standby link isolates its identity home from the primary link (DSH_HOME/USERPROFILE/HOME) and the composer control folds a relay pool of more than three nodes",
11
+ "task_id": "01M5ANYMVKS3OQZ",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "secondary_link_suite": "7/7 pass",
16
+ "fleet_target_suite": "49/49 pass",
17
+ "identity_home_default": "isolated",
18
+ "relay_fold": "shown+hidden counted"
19
+ }
20
+ },
21
+ "note": "both suites were executed as one real fleet task on the Tencent relay before packaging"
22
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "version": "1.5.2",
3
+ "case": "VERIFIED-BOOTSTRAP",
4
+ "kind": "positive",
5
+ "machines": [
6
+ "b53a37f4-1464-4512-9259-33a7df091e3e"
7
+ ],
8
+ "executed_at": "2026-10-23T12:22:38.268Z",
9
+ "executor": "lead",
10
+ "requirement": "a fresh installation obtains its relay list from a signed bootstrap answer: HMAC proof, pinned Ed25519 verification over canonical bytes, expiry enforcement, verified cache; and w2m-bootstrap writes the profile patch with the fallback marker",
11
+ "task_id": "01M5ANYMVKXNU8E",
12
+ "result": {
13
+ "outcome": "ok",
14
+ "assertions": {
15
+ "bootstrap_suite": "5/5 pass",
16
+ "bootstrap_cli_suite": "6/6 pass",
17
+ "fleet_target_suite": "49/49 pass"
18
+ }
19
+ },
20
+ "note": "all three suites ran as one real fleet task on the Tencent relay before packaging"
21
+ }
package/lib/client.js CHANGED
@@ -105,6 +105,13 @@ window.__ModuleLoader__.load({
105
105
  unreachable: '无法连接插件',
106
106
  routeMissing: '这个 Harness 没有提供插件目标路由',
107
107
  fleetUnreadable: '机器列表读取失败',
108
+ relays: 'Relay 节点',
109
+ relayPrimary: '主 relay',
110
+ relayStandby: '备用',
111
+ relayOffline: '离线',
112
+ relayFold: '另有 {count} 个已折叠,点击展开',
113
+ relayUnfold: '收起折叠的 relay',
114
+ relayTemporary: '临时 Relay 节点(回退配置)',
108
115
  capLabel: 'Node 执行:{state}',
109
116
  capDenied: '拒绝',
110
117
  capTemporary: '临时 24 小时',
@@ -129,6 +136,13 @@ window.__ModuleLoader__.load({
129
136
  unreachable: 'the plugin could not be reached',
130
137
  routeMissing: 'this host does not serve the W2M target route',
131
138
  fleetUnreadable: 'the fleet could not be read',
139
+ relays: 'Relay nodes',
140
+ relayPrimary: 'primary',
141
+ relayStandby: 'standby',
142
+ relayOffline: 'offline',
143
+ relayFold: '{count} more folded — expand',
144
+ relayUnfold: 'collapse the folded relays',
145
+ relayTemporary: 'temporary relay nodes (fallback config)',
132
146
  capLabel: 'Node exec: {state}',
133
147
  capDenied: 'denied',
134
148
  capTemporary: 'temporary 24 h',
@@ -311,6 +325,15 @@ window.__ModuleLoader__.load({
311
325
  'background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft);',
312
326
  'font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary)}',
313
327
  '.w2mTarget_note{padding:6px 8px;color:var(--dsw-alias-label-tertiary);font-weight:400}',
328
+ '.w2mTarget_relayFold{margin-top:2px;padding:4px 0;border-top:.5px solid var(--dsw-alias-border-l2)}',
329
+ '.w2mTarget_relayToggle{display:block;width:100%;box-sizing:border-box;padding:4px 8px;border:none;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:11px;text-align:left;cursor:pointer}',
330
+ '.w2mTarget_relayToggle[data-temporary="true"]{color:var(--dsw-alias-state-error-primary);font-weight:600}',
331
+ '.w2mTarget_relayFold[data-temporary="true"]{border-top-color:var(--dsw-alias-state-error-primary)}',
332
+ '.w2mTarget_relayRow{display:flex;align-items:center;gap:6px;padding:2px 8px;font-size:11px;color:var(--dsw-alias-label-secondary)}',
333
+ '.w2mTarget_relayRole{flex:none;min-width:44px;color:var(--dsw-alias-label-tertiary)}',
334
+ '.w2mTarget_relayUrl{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
335
+ '.w2mTarget_relayRtt{flex:none;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary)}',
336
+ '.w2mTarget_relayHint{padding:2px 8px;font-size:11px;color:var(--dsw-alias-label-tertiary)}',
314
337
  '.w2mTarget_option{display:block;width:100%;box-sizing:border-box;padding:6px 8px;border:none;border-radius:var(--dsw-radius-sm);',
315
338
  'background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}',
316
339
  '.w2mTarget_option:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
@@ -416,6 +439,9 @@ window.__ModuleLoader__.load({
416
439
  */
417
440
  function TargetSelect({ sessionId, locked: _locked, t }) {
418
441
  const [open, setOpen] = React.useState(false);
442
+ // v1.5.1: relay folding. The snapshot carries the pool; when it holds more than three nodes
443
+ // the menu shows a summary and this toggle reveals the folded ones.
444
+ const [relayOpen, setRelayOpen] = React.useState(false);
419
445
  const [reloadSeq, setReloadSeq] = React.useState(0);
420
446
  const [state, setState] = React.useState(null);
421
447
  const [error, setError] = React.useState(null);
@@ -493,6 +519,8 @@ window.__ModuleLoader__.load({
493
519
 
494
520
  const chosen = state?.selected?.machine_id ?? null;
495
521
  const machines = Array.isArray(state?.machines) ? state.machines : [];
522
+ // v1.5.1: the relay pool the host probed, already folded. `null` means a single-relay host.
523
+ const relayFold = state?.relays ?? null;
496
524
  const problem = error ?? fleetProblem(state, t);
497
525
  const disabled = typeof sessionId !== 'string' || sessionId === '';
498
526
  const label = state === null ? (provisionalLabel(state) ?? t('machine')) : labelOf(state, t);
@@ -576,6 +604,37 @@ window.__ModuleLoader__.load({
576
604
  ? t('local')
577
605
  : (machine.online === true ? (machine.os ?? t('online')) : machine.state)),
578
606
  ]))),
607
+ relayFold === null ? null : h('div', {
608
+ key: '__relays__',
609
+ className: cls('relayFold'),
610
+ role: 'group',
611
+ 'aria-label': t('relays'),
612
+ 'data-temporary': relayFold.temporary === true ? 'true' : 'false',
613
+ }, [
614
+ h('button', {
615
+ key: 'toggle',
616
+ type: 'button',
617
+ className: cls('relayToggle'),
618
+ 'aria-expanded': relayOpen,
619
+ 'data-temporary': relayFold.temporary === true ? 'true' : 'false',
620
+ onClick: (event) => { event.stopPropagation(); setRelayOpen((wasOpen) => !wasOpen); },
621
+ title: relayOpen === true ? t('relayUnfold') : t('relayFold', { count: relayFold.hidden_count }),
622
+ }, `${t('relays')}: ${relayFold.online}/${relayFold.total} ${t('online')}${relayFold.temporary === true ? ` · ${t('relayTemporary')}` : ''}`),
623
+ ...(relayOpen === true ? [...relayFold.shown, ...relayFold.hidden] : relayFold.shown).map((item, index) => h('div', {
624
+ key: `relay-${index}`,
625
+ className: cls('relayRow'),
626
+ 'data-role': item.role,
627
+ }, [
628
+ h('span', { key: 'role', className: cls('relayRole') }, item.role === 'primary'
629
+ ? t('relayPrimary')
630
+ : (item.role === 'standby' ? t('relayStandby') : t('relayOffline'))),
631
+ h('span', { key: 'url', className: cls('relayUrl') }, String(item.url ?? '').replace(/^https?:\/\//, '').replace(/\/$/, '')),
632
+ h('span', { key: 'rtt', className: cls('relayRtt') }, item.rtt_ms === null || item.rtt_ms === undefined ? '—' : `${item.rtt_ms} ms`),
633
+ ])),
634
+ relayFold.hidden_count > 0 && relayOpen === false
635
+ ? h('div', { key: 'hint', className: cls('relayHint') }, t('relayFold', { count: relayFold.hidden_count }))
636
+ : null,
637
+ ]),
579
638
  ])
580
639
  : null;
581
640
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "1.5.0",
3
+ "version": "1.5.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 test/exec-capability.test.mjs test/secondary-link.test.mjs",
67
+ "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs test/exec-capability.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs",
68
68
  "test:acceptance-doc": "node --test --test-force-exit test/acceptance-doc.test.mjs",
69
69
  "test:account-identity": "node --test --test-force-exit test/account-identity.test.mjs",
70
70
  "test:account-join": "node --test --test-force-exit test/account-join.test.mjs",
@@ -82,7 +82,7 @@
82
82
  "test:council-session": "node --test --test-force-exit test/council-session.test.mjs",
83
83
  "test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
84
84
  "test:crypto-blake2s": "node --test --test-force-exit test/crypto-blake2s.test.mjs",
85
- "test:e2e": "node --test --test-force-exit test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/p2p-agent.test.mjs test/secondary-link.test.mjs",
85
+ "test:e2e": "node --test --test-force-exit test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/p2p-agent.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs",
86
86
  "test:exec-env": "node --test --test-force-exit test/exec-env-injection.test.mjs",
87
87
  "test:exit-channel": "node --test --test-force-exit test/exit-channel.test.mjs",
88
88
  "test:field-authority": "node --test --test-force-exit test/field-authority.test.mjs",
@@ -201,7 +201,9 @@
201
201
  "w2m-rabbit": "bin/w2m-rabbit.mjs",
202
202
  "w2m-stun": "bin/w2m-stun.mjs",
203
203
  "w2m-grant": "./bin/w2m-grant.mjs",
204
- "w2m-secondary-link": "bin/w2m-secondary-link.mjs"
204
+ "w2m-secondary-link": "bin/w2m-secondary-link.mjs",
205
+ "w2m-bootstrap": "bin/w2m-bootstrap.mjs",
206
+ "w2m-bootstrap-sign": "bin/w2m-bootstrap-sign.mjs"
205
207
  },
206
208
  "disclosure": {
207
209
  "cloud": true,
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.5.0",
4
+ "version": "1.5.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": [
@@ -0,0 +1,295 @@
1
+ /**
2
+ * w2m relay bootstrap (v1.5.2) — verified relay assignment for a fresh installation.
3
+ *
4
+ * ## The problem
5
+ *
6
+ * A new install has no idea which relay its fleet uses: `rabbitUrl` falls back to the hard-coded
7
+ * shared server, so a machine can look connected while belonging to somebody else's data plane.
8
+ * Carrying a full `relayUrls` list by hand works, but it is easy to get wrong on the Nth machine.
9
+ *
10
+ * ## The shape of the answer
11
+ *
12
+ * The machine asks a **bootstrap endpoint** which relays its fleet uses, proves which fleet it is
13
+ * (an HMAC derived from the account's fleet key), and accepts the answer only if it is **signed by
14
+ * the operator's pinned Ed25519 key**. Four properties matter, and each has a test:
15
+ *
16
+ * 1. **No silent misdirection.** Every failure keeps the existing configuration: the plugin never
17
+ * "helpfully" switches a machine to a relay whose answer it could not verify.
18
+ * 2. **The signature is over a canonical payload** (fleet id + relay list + validity window), so a
19
+ * response cannot be replayed for another fleet or trimmed to one relay without detection.
20
+ * 3. **Replays are bounded by time.** `expires_at` is enforced against the local clock; an expired
21
+ * answer is refused even when its signature is valid.
22
+ * 4. **A cache never outlives the answer.** The cached file is re-verified on every read (signature
23
+ * and expiry), so editing it by hand cannot pin the machine to an unverified relay list.
24
+ *
25
+ * The request proof is HMAC-SHA256 over the fleet id, timestamp and nonce, keyed by the decoded
26
+ * fleet key. A static file server can ignore it; a real bootstrap service can rate-limit and scope
27
+ * by fleet without ever receiving the key itself.
28
+ */
29
+ import { createHash, createHmac, createPublicKey, verify as verifySignature } from 'node:crypto';
30
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
31
+ import { dirname } from 'node:path';
32
+ /**
33
+ * Fleet-key helpers, implemented here rather than imported: `plugin` may not depend on `chatops`
34
+ * (the layer model refuses the upward edge), and these two functions are the whole of what this
35
+ * module needs. Keeping the dependency out is cheaper than an allow-list entry that would have
36
+ * to be justified forever.
37
+ */
38
+ function decodeFleetKey(encoded) {
39
+ if (typeof encoded !== 'string' || encoded === '') return null;
40
+ try {
41
+ const buf = Buffer.from(encoded, 'base64url');
42
+ return buf.length === 32 ? buf : null;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+ function fleetIdFromKey(key) {
48
+ return createHash('sha256').update(key).digest('hex');
49
+ }
50
+
51
+
52
+
53
+ export const BOOTSTRAP_PROTOCOL = 'w2m-bootstrap/v1';
54
+ export const BOOTSTRAP_PROOF_PROTOCOL = 'w2m-bootstrap-proof/v1';
55
+ /** Refusals are values with stable names, because the status surface reports them verbatim. */
56
+ export const BOOTSTRAP_REFUSAL = Object.freeze({
57
+ NOT_CONFIGURED: 'BOOTSTRAP_NOT_CONFIGURED',
58
+ NO_FLEET_KEY: 'BOOTSTRAP_NO_FLEET_KEY',
59
+ NO_OPERATOR_PIN: 'BOOTSTRAP_NO_OPERATOR_PIN',
60
+ UNREACHABLE: 'BOOTSTRAP_UNREACHABLE',
61
+ BAD_JSON: 'BOOTSTRAP_BAD_JSON',
62
+ BAD_PROTOCOL: 'BOOTSTRAP_BAD_PROTOCOL',
63
+ WRONG_FLEET: 'BOOTSTRAP_WRONG_FLEET',
64
+ EXPIRED: 'BOOTSTRAP_EXPIRED',
65
+ BAD_SIGNATURE: 'BOOTSTRAP_BAD_SIGNATURE',
66
+ BAD_RELAYS: 'BOOTSTRAP_BAD_RELAYS',
67
+ });
68
+
69
+ const MAX_RELAYS = 16;
70
+
71
+ /**
72
+ * The fleet id a bootstrap request proves. Derived from the key, never sent as a raw claim.
73
+ *
74
+ * @param {string} fleetKeyEncoded base64url 32-byte fleet key
75
+ * @returns {string|null}
76
+ */
77
+ export function fleetIdOf(fleetKeyEncoded) {
78
+ const key = decodeFleetKey(fleetKeyEncoded);
79
+ return key === null ? null : fleetIdFromKey(key);
80
+ }
81
+
82
+ /**
83
+ * The canonical bytes the operator signs. Sorted, newline-joined, no JSON encoder ambiguity.
84
+ *
85
+ * @param {{fleet_id: string, relays: string[], issued_at: string, expires_at: string}} answer
86
+ * @returns {string}
87
+ */
88
+ export function canonicalBootstrapPayload(answer) {
89
+ const relays = (Array.isArray(answer.relays) ? answer.relays : [])
90
+ .map((url) => String(url).trim().replace(/\/+$/, ''))
91
+ .sort();
92
+ return [
93
+ BOOTSTRAP_PROTOCOL,
94
+ String(answer.fleet_id ?? ''),
95
+ relays.join(','),
96
+ String(answer.issued_at ?? ''),
97
+ String(answer.expires_at ?? ''),
98
+ ].join('\n');
99
+ }
100
+
101
+ /**
102
+ * Build the request proof for one bootstrap call.
103
+ *
104
+ * @param {{fleetKey: string, fleetId: string, issuedAtMs?: number, nonce: string}} input
105
+ * @returns {{protocol: string, fleet_id: string, issued_at_ms: number, nonce: string, alg: string, proof: string}}
106
+ */
107
+ export function buildBootstrapProof(input) {
108
+ const key = decodeFleetKey(input.fleetKey);
109
+ if (key === null) throw new TypeError('fleetKey must be a 32-byte base64url key');
110
+ const issuedAtMs = Number.isInteger(input.issuedAtMs) ? input.issuedAtMs : Date.now();
111
+ const nonce = String(input.nonce ?? '');
112
+ const message = [BOOTSTRAP_PROOF_PROTOCOL, input.fleetId, String(issuedAtMs), nonce].join('\n');
113
+ const proof = createHmac('sha256', key).update(message, 'utf8').digest('hex');
114
+ return {
115
+ protocol: BOOTSTRAP_PROOF_PROTOCOL,
116
+ fleet_id: input.fleetId,
117
+ issued_at_ms: issuedAtMs,
118
+ nonce,
119
+ alg: 'hmac-sha256',
120
+ proof,
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Verify one signed bootstrap answer against the pinned operator key and this machine's fleet.
126
+ *
127
+ * @param {object} answer Parsed JSON body from the bootstrap endpoint.
128
+ * @param {{operatorPublicKeyPem: string, fleetId: string, nowMs?: number}} input
129
+ * @returns {{ok: true, relays: string[], issued_at: string, expires_at: string}
130
+ * |{ok: false, reason_code: string, detail: string}}
131
+ */
132
+ export function verifyBootstrapAnswer(answer, input) {
133
+ const refuse = (reason_code, detail) => ({ ok: false, reason_code, detail });
134
+ if (answer === null || typeof answer !== 'object' || Array.isArray(answer)) {
135
+ return refuse(BOOTSTRAP_REFUSAL.BAD_JSON, 'the bootstrap answer is not a JSON object');
136
+ }
137
+ if (answer.protocol !== BOOTSTRAP_PROTOCOL) {
138
+ return refuse(BOOTSTRAP_REFUSAL.BAD_PROTOCOL, `expected protocol ${BOOTSTRAP_PROTOCOL}, got ${JSON.stringify(answer.protocol ?? null)}`);
139
+ }
140
+ if (typeof answer.fleet_id !== 'string' || answer.fleet_id !== input.fleetId) {
141
+ return refuse(BOOTSTRAP_REFUSAL.WRONG_FLEET, 'the answer names a different fleet; it is not for this machine');
142
+ }
143
+ const relays = Array.isArray(answer.relays)
144
+ ? [...new Set(answer.relays.filter((url) => typeof url === 'string' && url.trim() !== '').map((url) => url.trim().replace(/\/+$/, '')))]
145
+ : [];
146
+ if (relays.length === 0) return refuse(BOOTSTRAP_REFUSAL.BAD_RELAYS, 'the answer carries no relay URLs');
147
+ if (relays.length > MAX_RELAYS) return refuse(BOOTSTRAP_REFUSAL.BAD_RELAYS, `the answer carries ${relays.length} relays, more than ${MAX_RELAYS}`);
148
+ for (const url of relays) {
149
+ if (!/^https?:\/\/[^\s]+$/.test(url)) return refuse(BOOTSTRAP_REFUSAL.BAD_RELAYS, `${JSON.stringify(url)} is not an http(s) base URL`);
150
+ }
151
+ const nowMs = Number.isInteger(input.nowMs) ? input.nowMs : Date.now();
152
+ const expiresAt = Date.parse(String(answer.expires_at ?? ''));
153
+ if (!Number.isFinite(expiresAt)) return refuse(BOOTSTRAP_REFUSAL.BAD_JSON, '`expires_at` is not a timestamp');
154
+ if (expiresAt <= nowMs) return refuse(BOOTSTRAP_REFUSAL.EXPIRED, `the answer expired at ${answer.expires_at}`);
155
+ const issuedAt = Date.parse(String(answer.issued_at ?? ''));
156
+ if (!Number.isFinite(issuedAt)) return refuse(BOOTSTRAP_REFUSAL.BAD_JSON, '`issued_at` is not a timestamp');
157
+ const signature = answer.signature;
158
+ const signatureB64 = typeof signature === 'string' ? signature
159
+ : (signature !== null && typeof signature === 'object' ? signature.value : undefined);
160
+ if (typeof signatureB64 !== 'string' || signatureB64 === '') {
161
+ return refuse(BOOTSTRAP_REFUSAL.BAD_SIGNATURE, 'the answer carries no signature');
162
+ }
163
+ let publicKey;
164
+ try {
165
+ publicKey = createPublicKey(input.operatorPublicKeyPem);
166
+ } catch (error) {
167
+ return refuse(BOOTSTRAP_REFUSAL.NO_OPERATOR_PIN, `the pinned operator key is not a usable PEM: ${error instanceof Error ? error.message : String(error)}`);
168
+ }
169
+ const signed = canonicalBootstrapPayload({ fleet_id: answer.fleet_id, relays, issued_at: answer.issued_at, expires_at: answer.expires_at });
170
+ let signatureOk = false;
171
+ try {
172
+ signatureOk = verifySignature(null, Buffer.from(signed, 'utf8'), publicKey, Buffer.from(signatureB64, 'base64'));
173
+ } catch {
174
+ signatureOk = false;
175
+ }
176
+ if (!verifiedSignatureOk(signatureOk)) {
177
+ return refuse(BOOTSTRAP_REFUSAL.BAD_SIGNATURE, 'the answer signature does not match the pinned operator key and the canonical payload');
178
+ }
179
+ return { ok: true, relays, issued_at: answer.issued_at, expires_at: answer.expires_at };
180
+ }
181
+
182
+ /** Kept separate so a test can assert the failure branch without a real crypto fault. */
183
+ function verifiedSignatureOk(value) {
184
+ return value === true;
185
+ }
186
+
187
+ /**
188
+ * Fetch, verify and cache one bootstrap answer.
189
+ *
190
+ * A cached file that still verifies is preferred (no network on every start); any verification
191
+ * failure falls through to a fresh fetch. Every failure keeps the caller's configuration untouched
192
+ * and returns a named reason for `w2m_status`.
193
+ *
194
+ * @param {object} deps
195
+ * @param {string} deps.url
196
+ * @param {string} deps.fleetKey
197
+ * @param {string} deps.operatorPublicKeyPem
198
+ * @param {string} deps.cacheFile
199
+ * @param {Function} [deps.fetchImpl]
200
+ * @param {number} [deps.timeoutMs]
201
+ * @param {number} [deps.nowMs]
202
+ * @param {(line: string) => void} [deps.log]
203
+ * @returns {Promise<{ok: true, relays: string[], source: 'cache'|'network', expires_at: string}
204
+ * |{ok: false, reason_code: string, detail: string}>}
205
+ */
206
+ export async function resolveBootstrap(deps) {
207
+ const log = deps.log ?? (() => {});
208
+ const fleetId = fleetIdOf(deps.fleetKey);
209
+ if (fleetId === null) {
210
+ return { ok: false, reason_code: BOOTSTRAP_REFUSAL.NO_FLEET_KEY, detail: 'no usable fleet key is configured, so no bootstrap proof can be made' };
211
+ }
212
+ const pinned = readBootstrapCache(deps.cacheFile, { operatorPublicKeyPem: deps.operatorPublicKeyPem, fleetId, nowMs: deps.nowMs });
213
+ if (pinned.ok === true) {
214
+ log(`w2m: bootstrap cache is valid (${pinned.relays.join(', ')})`);
215
+ return { ok: true, relays: pinned.relays, source: 'cache', expires_at: pinned.expires_at };
216
+ }
217
+ const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
218
+ const timeoutMs = Number.isInteger(deps.timeoutMs) ? deps.timeoutMs : 4000;
219
+ const nonce = createHash('sha256').update(`${fleetId}:${Date.now()}:${Math.random()}`).digest('hex').slice(0, 32);
220
+ let proof;
221
+ try {
222
+ proof = buildBootstrapProof({ fleetKey: deps.fleetKey, fleetId, nonce });
223
+ } catch (error) {
224
+ return { ok: false, reason_code: BOOTSTRAP_REFUSAL.NO_FLEET_KEY, detail: error instanceof Error ? error.message : String(error) };
225
+ }
226
+ let answer;
227
+ try {
228
+ const response = await fetchImpl(deps.url, {
229
+ method: 'POST',
230
+ headers: { 'content-type': 'application/json' },
231
+ body: JSON.stringify({ protocol: BOOTSTRAP_PROOF_PROTOCOL, proof }),
232
+ signal: typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
233
+ ? AbortSignal.timeout(timeoutMs) : undefined,
234
+ });
235
+ if (!response.ok) {
236
+ return { ok: false, reason_code: BOOTSTRAP_REFUSAL.UNREACHABLE, detail: `the bootstrap endpoint answered HTTP ${response.status}` };
237
+ }
238
+ answer = await response.json();
239
+ } catch (error) {
240
+ return { ok: false, reason_code: BOOTSTRAP_REFUSAL.UNREACHABLE, detail: error instanceof Error ? error.message : String(error) };
241
+ }
242
+ const verified = verifyBootstrapAnswer(answer, {
243
+ operatorPublicKeyPem: deps.operatorPublicKeyPem,
244
+ fleetId,
245
+ nowMs: deps.nowMs,
246
+ });
247
+ if (verified.ok !== true) return verified;
248
+ writeBootstrapCache(deps.cacheFile, { fleet_id: fleetId, relays: verified.relays, issued_at: verified.issued_at, expires_at: verified.expires_at, signature: answer.signature });
249
+ log(`w2m: bootstrap assigned relays (${verified.relays.join(', ')}, expires ${verified.expires_at})`);
250
+ return { ok: true, relays: verified.relays, source: 'network', expires_at: verified.expires_at };
251
+ }
252
+
253
+ /** Write a verified answer so the next start does not depend on the bootstrap endpoint being up. */
254
+ export function writeBootstrapCache(file, entry) {
255
+ if (typeof file !== 'string' || file === '') return false;
256
+ try {
257
+ mkdirSync(dirname(file), { recursive: true });
258
+ const tmp = `${file}.tmp-${process.pid}`;
259
+ writeFileSync(tmp, `${JSON.stringify({ protocol: 'w2m-bootstrap-cache/v1', ...entry }, null, 2)}\n`, 'utf8');
260
+ renameSync(tmp, file);
261
+ return true;
262
+ } catch {
263
+ return false;
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Read the cache and re-verify it. A hand-edited cache is not trusted: the same signature check the
269
+ * network answer goes through runs here, so a cache cannot pin an unverified relay list.
270
+ *
271
+ * @param {string} file
272
+ * @param {{operatorPublicKeyPem: string, fleetId: string, nowMs?: number}} input
273
+ * @returns {{ok: true, relays: string[], expires_at: string}|{ok: false, reason_code: string, detail: string}}
274
+ */
275
+ export function readBootstrapCache(file, input) {
276
+ const refuse = (reason_code, detail) => ({ ok: false, reason_code, detail });
277
+ if (typeof file !== 'string' || file === '' || !existsSync(file)) {
278
+ return refuse(BOOTSTRAP_REFUSAL.NOT_CONFIGURED, 'no bootstrap cache exists yet');
279
+ }
280
+ let parsed;
281
+ try {
282
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
283
+ } catch (error) {
284
+ return refuse(BOOTSTRAP_REFUSAL.BAD_JSON, `the bootstrap cache is not JSON: ${error instanceof Error ? error.message : String(error)}`);
285
+ }
286
+ const answer = {
287
+ protocol: BOOTSTRAP_PROTOCOL,
288
+ fleet_id: parsed.fleet_id,
289
+ relays: parsed.relays,
290
+ issued_at: parsed.issued_at,
291
+ expires_at: parsed.expires_at,
292
+ signature: parsed.signature,
293
+ };
294
+ return verifyBootstrapAnswer(answer, input);
295
+ }
@@ -434,6 +434,85 @@ export async function readFleet(deps) {
434
434
  };
435
435
  }
436
436
 
437
+ /**
438
+ * v1.5.1: the relay pool the client folds, derived from configuration.
439
+ *
440
+ * `relayUrls` (array) is the multi-relay form; the single `rabbitUrl` stays valid and is returned
441
+ * as a one-element pool, so a host that never configured a pool behaves exactly as it did.
442
+ *
443
+ * @param {{relayUrls?: unknown, rabbitUrl?: string|null}|null|undefined} cfg
444
+ * @returns {string[]} Normalized base URLs, order preserved, duplicates removed.
445
+ */
446
+ export function relayUrlsFromConfig(cfg) {
447
+ const raw = Array.isArray(cfg?.relayUrls) ? cfg.relayUrls : [];
448
+ const urls = raw
449
+ .filter((entry) => typeof entry === 'string' && entry.trim() !== '')
450
+ .map((entry) => entry.trim().replace(/\/+$/, ''));
451
+ if (urls.length > 0) return [...new Set(urls)];
452
+ if (typeof cfg?.rabbitUrl === 'string' && cfg.rabbitUrl.trim() !== '') {
453
+ return [cfg.rabbitUrl.trim().replace(/\/+$/, '')];
454
+ }
455
+ return [];
456
+ }
457
+
458
+ /**
459
+ * Fold a relay pool for display: at most `limit` nodes are shown, the rest are counted.
460
+ *
461
+ * The client is expected to render `shown` and `hidden` exactly as given: a view that silently
462
+ * shows every node when there are dozens is the failure this helper exists to prevent.
463
+ *
464
+ * @param {object[]} items Probe results, best first.
465
+ * @param {number} [limit]
466
+ * @returns {{shown: object[], hidden: object[], hidden_count: number, total: number, online: number}}
467
+ */
468
+ export function foldRelayItems(items, limit = 3) {
469
+ const list = Array.isArray(items) ? items : [];
470
+ const shown = list.slice(0, Math.max(0, limit));
471
+ const hidden = list.slice(Math.max(0, limit));
472
+ return {
473
+ shown,
474
+ hidden,
475
+ hidden_count: hidden.length,
476
+ total: list.length,
477
+ online: list.filter((item) => item?.ok === true).length,
478
+ };
479
+ }
480
+
481
+ /**
482
+ * Probe each relay's `/healthz` and return the pool sorted by round-trip time (fastest first).
483
+ * A relay that fails is kept and sorted last with `ok:false` — disappearing from the list would
484
+ * hide exactly the outage the operator needs to see.
485
+ *
486
+ * @param {string[]} urls
487
+ * @param {{timeoutMs?: number, fetchImpl?: Function}} [options]
488
+ * @returns {Promise<object[]>}
489
+ */
490
+ export async function probeRelays(urls, options = {}) {
491
+ const timeoutMs = Number.isInteger(options.timeoutMs) ? options.timeoutMs : 1200;
492
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
493
+ const list = Array.isArray(urls) ? urls : [];
494
+ const results = await Promise.all(list.map(async (url) => {
495
+ const started = Date.now();
496
+ try {
497
+ const response = await fetchImpl(`${url}/healthz`, {
498
+ signal: typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
499
+ ? AbortSignal.timeout(timeoutMs)
500
+ : undefined,
501
+ });
502
+ const body = await response.json().catch(() => ({}));
503
+ if (!response.ok || body?.ok !== true) {
504
+ return { url, ok: false, rtt_ms: null, error: `HTTP ${response.status}` };
505
+ }
506
+ return { url, ok: true, rtt_ms: Date.now() - started, relay_id: body.relay_id ?? null };
507
+ } catch (error) {
508
+ return { url, ok: false, rtt_ms: null, error: error instanceof Error ? error.message : String(error) };
509
+ }
510
+ }));
511
+ const reachable = results.filter((item) => item.ok === true).sort((a, b) => (a.rtt_ms ?? 0) - (b.rtt_ms ?? 0));
512
+ const unreachable = results.filter((item) => item.ok !== true);
513
+ return [...reachable, ...unreachable];
514
+ }
515
+
437
516
  /**
438
517
  * Build the snapshot the browser renders.
439
518
  *
@@ -447,7 +526,7 @@ export async function readFleet(deps) {
447
526
  * @returns {object}
448
527
  */
449
528
  export function buildUiState(deps) {
450
- const { store, sessionId, fleet, localMachineName = null, detailUnavailable = false } = deps;
529
+ const { store, sessionId, fleet, localMachineName = null, detailUnavailable = false, relays = null } = deps;
451
530
  const selection = store.get(sessionId);
452
531
  const machines = Array.isArray(fleet?.machines) ? fleet.machines : [];
453
532
  const selected = selection === null ? null : machines.find((machine) => machine.machine_id === selection.machine_id) ?? null;
@@ -479,6 +558,22 @@ export function buildUiState(deps) {
479
558
  // but cannot reach this half, and the honest answer is to say so rather than to look empty.
480
559
  detail_unavailable: detailUnavailable === true,
481
560
  },
561
+ // v1.5.1: the relay pool, folded. `null` means the host did not probe (single-relay host or
562
+ // probe disabled); the control then renders no relay section instead of inventing one.
563
+ relays: relays === null
564
+ ? null
565
+ : {
566
+ total: relays.total,
567
+ online: relays.online,
568
+ hidden_count: relays.hidden_count,
569
+ shown: relays.shown,
570
+ hidden: relays.hidden,
571
+ // Fastest reachable relay is the one a dispatch would prefer; naming it here keeps the
572
+ // fold honest when it hides nodes that the router would still consider.
573
+ primary_url: relays.shown.find((item) => item.ok === true)?.url ?? null,
574
+ // v1.5.2: a fallback relay list is never presented as settled; the panel renders it red.
575
+ temporary: relays.temporary === true,
576
+ },
482
577
  };
483
578
  }
484
579
 
@@ -680,12 +775,40 @@ function mountTargetRoutes(connection, deps, status, logger) {
680
775
  return value;
681
776
  };
682
777
 
778
+ /** v1.5.1: relay probing is cached like the fleet: opening the menu must not turn into a burst. */
779
+ let relayCache = { at: 0, value: null };
780
+ const relayPoolFor = async () => {
781
+ const now = Date.now();
782
+ if (relayCache.value !== null && now - relayCache.at < FLEET_CACHE_MS) return relayCache.value;
783
+ const urls = relayUrlsFromConfig(deps.cfg());
784
+ if (urls.length === 0) {
785
+ relayCache = { at: now, value: null };
786
+ return null;
787
+ }
788
+ const probed = await probeRelays(urls);
789
+ // The fold's primary is the fastest reachable node; every other reachable node is standby, and
790
+ // an unreachable one keeps its place at the end as `offline` rather than disappearing.
791
+ let reachableSeen = 0;
792
+ const items = probed.map((item) => {
793
+ if (item.ok !== true) return { ...item, role: 'offline' };
794
+ reachableSeen += 1;
795
+ return { ...item, role: reachableSeen === 1 ? 'primary' : 'standby' };
796
+ });
797
+ const folded = foldRelayItems(items, 3);
798
+ // v1.5.2: a fallback list is marked so the panel can render it in red, not as a settled config.
799
+ const withMark = { ...folded, temporary: deps.cfg()?.relayTemporary === true };
800
+ relayCache = { at: now, value: withMark };
801
+ return withMark;
802
+ };
803
+
683
804
  const snapshot = async (sessionId, signal) => {
684
805
  const cfg = deps.cfg();
685
806
  const identity = await deps.localIdentity();
686
807
  const fleet = await fleetFor(signal);
808
+ const relays = await relayPoolFor();
687
809
  return buildUiState({
688
810
  store: deps.store,
811
+ relays,
689
812
  sessionId,
690
813
  fleet,
691
814
  localMachineName: identity.machine_name ?? cfg.machineName ?? null,
@@ -276,7 +276,7 @@ const MAX_STAGES = 16;
276
276
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
277
277
  * overrides it at runtime for tests and packagers.
278
278
  */
279
- const PLUGIN_VERSION = '1.5.0';
279
+ const PLUGIN_VERSION = '1.5.2';
280
280
 
281
281
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
282
282
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -885,6 +885,19 @@ function readConfig(config = {}) {
885
885
  rabbitUrl,
886
886
  rabbitSource,
887
887
  rabbitUrlOverride,
888
+ // v1.5.2: the relay pool and the bootstrap inputs. `relayUrls` is what `w2m-bootstrap` writes;
889
+ // `relayTemporary` marks a fallback list so the fleet panel can say "临时 Relay 节点" out loud
890
+ // instead of presenting a stranded machine's fallback as a settled configuration.
891
+ relayUrls: Array.isArray(config.relayUrls)
892
+ ? config.relayUrls.filter((url) => typeof url === 'string' && url.trim() !== '').map((url) => url.trim().replace(/\/+$/, ''))
893
+ : [],
894
+ relayTemporary: config.relayTemporary === true,
895
+ bootstrapUrl: typeof config.bootstrapUrl === 'string' && config.bootstrapUrl.trim() !== ''
896
+ ? config.bootstrapUrl.trim()
897
+ : (typeof process.env.W2M_BOOTSTRAP_URL === 'string' && process.env.W2M_BOOTSTRAP_URL.trim() !== '' ? process.env.W2M_BOOTSTRAP_URL.trim() : null),
898
+ bootstrapOperatorKeyPem: typeof config.bootstrapOperatorKey === 'string' && config.bootstrapOperatorKey.trim() !== ''
899
+ ? config.bootstrapOperatorKey
900
+ : (typeof process.env.W2M_BOOTSTRAP_OPERATOR_KEY === 'string' && process.env.W2M_BOOTSTRAP_OPERATOR_KEY.trim() !== '' ? process.env.W2M_BOOTSTRAP_OPERATOR_KEY : null),
888
901
  p2pMode,
889
902
  stunServers: stunList,
890
903
  };
@@ -2176,6 +2189,7 @@ async function readP2PStatus(cfg, runtime, peersAnnounced = null) {
2176
2189
  device_error: runtime.identityError,
2177
2190
  local: status?.local ?? null,
2178
2191
  rabbit_url: status?.rabbit_url ?? cfg.rabbitUrl,
2192
+ bootstrap: cfg.bootstrap ?? null,
2179
2193
  // How many machines the relay holds a live announcement for. `w2m_devices` says which machines
2180
2194
  // paired; this says which of them can be punched at, and the two differ constantly.
2181
2195
  peers_announced: peersAnnounced,
@@ -7354,6 +7368,46 @@ function sleepWithSignal(ms, signal) {
7354
7368
  const resolvedFleetKey = { value: null, source: null, at: 0 };
7355
7369
 
7356
7370
  async function startLocalStack(ctx, cfg) {
7371
+
7372
+ /**
7373
+ * v1.5.2: bootstrap relay assignment. A fresh install has no relay list; without this it falls
7374
+ * back to the compiled shared default, which is how a machine ends up looking connected on somebody
7375
+ * else side. The answer is used only when it verified against the pinned operator key and is for
7376
+ * this machine fleet; every failure leaves the configured relay untouched and is recorded in
7377
+ * status, because a bootstrap that silently failed is indistinguishable from one never configured.
7378
+ */
7379
+ if ((Array.isArray(cfg.relayUrls) ? cfg.relayUrls.length : 0) === 0
7380
+ && typeof cfg.bootstrapUrl === 'string' && typeof cfg.bootstrapOperatorKeyPem === 'string'
7381
+ && typeof cfg.fleetKey === 'string') {
7382
+ try {
7383
+ const { resolveBootstrap } = await import(new URL('./bootstrap.mjs', import.meta.url).href);
7384
+ const { homedir } = await import('node:os');
7385
+ const { join } = await import('node:path');
7386
+ const cacheFile = join(cfg.stateDir ?? join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'xclient'), 'bootstrap.json');
7387
+ const outcome = await resolveBootstrap({
7388
+ url: cfg.bootstrapUrl,
7389
+ fleetKey: cfg.fleetKey,
7390
+ operatorPublicKeyPem: cfg.bootstrapOperatorKeyPem,
7391
+ cacheFile,
7392
+ log: (line) => ctx.logger?.info?.(line),
7393
+ });
7394
+ if (outcome.ok === true) {
7395
+ cfg.relayUrls = outcome.relays;
7396
+ if (cfg.rabbitSource === 'shared-default' || !cfg.rabbitUrl) { cfg.rabbitUrl = outcome.relays[0]; cfg.rabbitSource = 'bootstrap'; }
7397
+ cfg.bootstrap = { configured: true, url: cfg.bootstrapUrl, source: outcome.source, relays: outcome.relays, expires_at: outcome.expires_at, last_error: null };
7398
+ ctx.logger?.info?.('w2m: bootstrap assigned relays (' + outcome.relays.join(', ') + ')');
7399
+ } else {
7400
+ cfg.bootstrap = { configured: true, url: cfg.bootstrapUrl, source: null, relays: [], expires_at: null, last_error: { reason_code: outcome.reason_code, detail: outcome.detail } };
7401
+ ctx.logger?.warn?.('w2m: bootstrap kept the configured relay (' + outcome.reason_code + ': ' + outcome.detail + ')');
7402
+ }
7403
+ } catch (error) {
7404
+ cfg.bootstrap = { configured: true, url: cfg.bootstrapUrl, source: null, relays: [], expires_at: null, last_error: { reason_code: 'BOOTSTRAP_ERROR', detail: error instanceof Error ? error.message : String(error) } };
7405
+ ctx.logger?.warn?.('w2m: bootstrap failed (' + (error instanceof Error ? error.message : String(error)) + ')');
7406
+ }
7407
+ } else if (cfg.bootstrap === undefined) {
7408
+ cfg.bootstrap = { configured: typeof cfg.bootstrapUrl === 'string', url: cfg.bootstrapUrl ?? null, source: null, relays: [], expires_at: null, last_error: null };
7409
+ }
7410
+
7357
7411
  const relayUrl = new URL('../relay/server.mjs', import.meta.url);
7358
7412
  const agentUrl = new URL('../agent/agent.mjs', import.meta.url);
7359
7413