@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.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +34 -0
- package/bin/w2m-bootstrap-sign.mjs +47 -0
- package/bin/w2m-bootstrap.mjs +303 -0
- package/bin/w2m-secondary-link.mjs +32 -2
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/1.5.1/IDENTITY-ISOLATION-RELAY-FOLD.json +22 -0
- package/docs/acceptance/1.5.2/VERIFIED-BOOTSTRAP.json +21 -0
- package/lib/client.js +59 -0
- package/package.json +6 -4
- package/plugin.json +1 -1
- package/src/plugin/bootstrap.mjs +295 -0
- package/src/plugin/fleet-target.mjs +124 -1
- package/src/plugin/tools.mjs +55 -1
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 }));
|
package/codex/mcp-servers.toml
CHANGED
|
@@ -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.
|
|
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.
|
|
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,
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -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.
|
|
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
|
|