@twinsearth/w2m-dsh-plugin 1.5.1 → 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.1"
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.1",
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.1"
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.1",
4
+ "version": "1.5.2",
5
5
  "author": {
6
6
  "name": "TwinsEarth"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -7,6 +7,25 @@
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`)。
10
29
  ## [1.5.1] — 2026-10-23
11
30
 
12
31
  **备用链路的身份隔离 + 控制面板的 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
+ }
@@ -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.1", "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,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
@@ -111,6 +111,7 @@ window.__ModuleLoader__.load({
111
111
  relayOffline: '离线',
112
112
  relayFold: '另有 {count} 个已折叠,点击展开',
113
113
  relayUnfold: '收起折叠的 relay',
114
+ relayTemporary: '临时 Relay 节点(回退配置)',
114
115
  capLabel: 'Node 执行:{state}',
115
116
  capDenied: '拒绝',
116
117
  capTemporary: '临时 24 小时',
@@ -141,6 +142,7 @@ window.__ModuleLoader__.load({
141
142
  relayOffline: 'offline',
142
143
  relayFold: '{count} more folded — expand',
143
144
  relayUnfold: 'collapse the folded relays',
145
+ relayTemporary: 'temporary relay nodes (fallback config)',
144
146
  capLabel: 'Node exec: {state}',
145
147
  capDenied: 'denied',
146
148
  capTemporary: 'temporary 24 h',
@@ -325,6 +327,8 @@ window.__ModuleLoader__.load({
325
327
  '.w2mTarget_note{padding:6px 8px;color:var(--dsw-alias-label-tertiary);font-weight:400}',
326
328
  '.w2mTarget_relayFold{margin-top:2px;padding:4px 0;border-top:.5px solid var(--dsw-alias-border-l2)}',
327
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)}',
328
332
  '.w2mTarget_relayRow{display:flex;align-items:center;gap:6px;padding:2px 8px;font-size:11px;color:var(--dsw-alias-label-secondary)}',
329
333
  '.w2mTarget_relayRole{flex:none;min-width:44px;color:var(--dsw-alias-label-tertiary)}',
330
334
  '.w2mTarget_relayUrl{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
@@ -605,15 +609,17 @@ window.__ModuleLoader__.load({
605
609
  className: cls('relayFold'),
606
610
  role: 'group',
607
611
  'aria-label': t('relays'),
612
+ 'data-temporary': relayFold.temporary === true ? 'true' : 'false',
608
613
  }, [
609
614
  h('button', {
610
615
  key: 'toggle',
611
616
  type: 'button',
612
617
  className: cls('relayToggle'),
613
618
  'aria-expanded': relayOpen,
619
+ 'data-temporary': relayFold.temporary === true ? 'true' : 'false',
614
620
  onClick: (event) => { event.stopPropagation(); setRelayOpen((wasOpen) => !wasOpen); },
615
621
  title: relayOpen === true ? t('relayUnfold') : t('relayFold', { count: relayFold.hidden_count }),
616
- }, `${t('relays')}: ${relayFold.online}/${relayFold.total} ${t('online')}`),
622
+ }, `${t('relays')}: ${relayFold.online}/${relayFold.total} ${t('online')}${relayFold.temporary === true ? ` · ${t('relayTemporary')}` : ''}`),
617
623
  ...(relayOpen === true ? [...relayFold.shown, ...relayFold.hidden] : relayFold.shown).map((item, index) => h('div', {
618
624
  key: `relay-${index}`,
619
625
  className: cls('relayRow'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "1.5.1",
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.1",
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
+ }
@@ -571,6 +571,8 @@ export function buildUiState(deps) {
571
571
  // Fastest reachable relay is the one a dispatch would prefer; naming it here keeps the
572
572
  // fold honest when it hides nodes that the router would still consider.
573
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,
574
576
  },
575
577
  };
576
578
  }
@@ -793,8 +795,10 @@ function mountTargetRoutes(connection, deps, status, logger) {
793
795
  return { ...item, role: reachableSeen === 1 ? 'primary' : 'standby' };
794
796
  });
795
797
  const folded = foldRelayItems(items, 3);
796
- relayCache = { at: now, value: folded };
797
- return folded;
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;
798
802
  };
799
803
 
800
804
  const snapshot = async (sessionId, signal) => {
@@ -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.1';
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