@twinsearth/w2m-dsh-plugin 1.5.1 → 1.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +35 -0
- package/bin/w2m-bootstrap-sign.mjs +47 -0
- package/bin/w2m-bootstrap.mjs +303 -0
- package/codex/mcp-servers.toml +1 -1
- package/cordis.patch.yml +1 -1
- package/docs/acceptance/1.5.2/VERIFIED-BOOTSTRAP.json +21 -0
- package/docs/acceptance/1.5.3/ZERO-TOUCH-BOOTSTRAP.json +22 -0
- package/lib/client.js +7 -1
- package/package.json +6 -4
- package/plugin.json +1 -1
- package/src/plugin/bootstrap.mjs +311 -0
- package/src/plugin/fleet-target.mjs +6 -2
- package/src/plugin/tools.mjs +63 -2
|
@@ -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.3"
|
|
9
9
|
},
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "w2m-fleet",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"description": "Read fleet state and dispatch commands across the machines registered to one account. Read-only MCP surface: the five exposed tools cannot change anything.",
|
|
15
|
-
"version": "1.5.
|
|
15
|
+
"version": "1.5.3",
|
|
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.3"
|
|
30
30
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "w2m-fleet",
|
|
3
3
|
"description": "Drive the same project on online Windows, macOS and Linux machines from one account: read fleet state, dispatch commands and read results. The MCP surface is deliberately read-only.",
|
|
4
|
-
"version": "1.5.
|
|
4
|
+
"version": "1.5.3",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TwinsEarth"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,41 @@
|
|
|
7
7
|
|
|
8
8
|
All notable changes to this project are documented here.
|
|
9
9
|
|
|
10
|
+
## [1.5.3] — 2026-10-23
|
|
11
|
+
|
|
12
|
+
**零接触自动入队:只能升级插件的机器也能自己找到编队。**
|
|
13
|
+
|
|
14
|
+
v1.5.2 的验签 bootstrap 需要一个 URL、一个 operator 公钥和 fleetKey 才能触发——新装机器三样都没有,
|
|
15
|
+
于是仍然回落到编译期默认 relay 并报 W2M_UNREACHABLE。v1.5.3 把触发条件做进包里:
|
|
16
|
+
|
|
17
|
+
- 包内置默认 bootstrap 端点与 **operator 公钥 pin**(只含公钥;端点无法签发,私钥不出运维机)。
|
|
18
|
+
- bootstrap 证明改为可选:机器还没拿到 fleetKey 时,仍可凭"签名 + 有效期 + relay 形状"采用
|
|
19
|
+
operator 声明的 relay 池(relay 列表是公开路由信息;加入编队仍需账号派生密钥,是独立一步)。
|
|
20
|
+
- `startLocalStack` 在无 relayUrls 时始终尝试 bootstrap(默认值生效),成功即用池(首个 primary),
|
|
21
|
+
失败保持回落并写入 `w2m_status.bootstrap.last_error`。
|
|
22
|
+
- 包内默认 patch:`autoStartAgent: true`(本 fork),新装即启动 agent。
|
|
23
|
+
- 因此只有"升级插件"这一条通道的机器:升级 → 重启 → 自动取 relay 池 → 账号密钥就绪后自动配对入队。
|
|
24
|
+
- 测试:bootstrap 6/6(含 keyless)、bootstrap-cli 6/6、fleet-target 49/49;层模型 clean;
|
|
25
|
+
证据 `docs/acceptance/1.5.3/ZERO-TOUCH-BOOTSTRAP.json`(真实 fleet task `01M5ANYMVKZ7DHH`)。
|
|
26
|
+
## [1.5.2] — 2026-10-23
|
|
27
|
+
|
|
28
|
+
**新机器自动获得 relay 归属(验签 bootstrap)+ 一次性配置工具。**
|
|
29
|
+
|
|
30
|
+
- 新增 `src/plugin/bootstrap.mjs`:机器用 fleetKey 派生 HMAC 证明请求 relay 列表;响应必须由
|
|
31
|
+
operator 公钥(pin)对 canonical payload(fleet_id + 排序后的 relays + issued_at + expires_at)
|
|
32
|
+
验签;过期/错 fleet/被篡改/坏 JSON 全部命名拒绝。缓存每次读取都重验签名与有效期,手改缓存无效。
|
|
33
|
+
**任何失败都不改现有 relay 配置**,原因写入 `w2m_status.bootstrap.last_error`。
|
|
34
|
+
- `startLocalStack` 接线:无 `relayUrls` 且配置 `bootstrapUrl`+`fleetKey`+operator 公钥时启动即解析
|
|
35
|
+
(缓存优先,其次网络),成功则采用该 relay 池(首个为 primary);`w2m_status` 新增
|
|
36
|
+
`bootstrap {configured, url, source, relays, expires_at, last_error}`。
|
|
37
|
+
- 新增 `bin/w2m-bootstrap.mjs`:一条命令写 `relayUrls`/`fleetKey`/`autoStartAgent` 到 profile patch,
|
|
38
|
+
只改受管键、写前备份、所有输入先校验;`--temporary` 写 `relayTemporary: true`,面板以
|
|
39
|
+
**红色“临时 Relay 节点(回退配置)”**显示。
|
|
40
|
+
- 新增 `bin/w2m-bootstrap-sign.mjs`:运维侧用 operator 私钥签名 relay 列表,输出可静态托管。
|
|
41
|
+
- 控制面板 relay 折叠区支持 temporary 红色标记(仅主题 token,无硬编码颜色)。
|
|
42
|
+
- 层模型修复:bootstrap 模块不依赖 chatops 层(layer-check 保持 clean)。
|
|
43
|
+
- 测试:bootstrap 5/5、bootstrap-cli 6/6、fleet-target 49/49、client-bundle 26/26;
|
|
44
|
+
证据 `docs/acceptance/1.5.2/VERIFIED-BOOTSTRAP.json`(真实 fleet task `01M5ANYMVKXNU8E`)。
|
|
10
45
|
## [1.5.1] — 2026-10-23
|
|
11
46
|
|
|
12
47
|
**备用链路的身份隔离 + 控制面板的 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
|
+
}
|
package/codex/mcp-servers.toml
CHANGED
package/cordis.patch.yml
CHANGED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.5.3",
|
|
3
|
+
"case": "ZERO-TOUCH-BOOTSTRAP",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-23T12:58:49.026Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"requirement": "a machine whose only capability is upgrading the plugin obtains its relay pool without any local command: built-in bootstrap URL and operator public key, signature-verified keyless assignment, autoStartAgent on by default",
|
|
11
|
+
"task_id": "01M5ANYMVKZ7DHH",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"assertions": {
|
|
15
|
+
"bootstrap_suite": "6/6 pass incl keyless",
|
|
16
|
+
"bootstrap_cli_suite": "6/6 pass",
|
|
17
|
+
"builtin_defaults": "endpoint+pin shipped",
|
|
18
|
+
"autoStartAgent_default": "true"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"note": "suites ran as a real fleet task on the Tencent relay; keyless assignment verified by unit test"
|
|
22
|
+
}
|
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.
|
|
3
|
+
"version": "1.5.3",
|
|
4
4
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"pack": "node scripts/pack.mjs",
|
|
65
65
|
"reachability": "node scripts/reachability-check.mjs",
|
|
66
66
|
"reachability:classify": "node scripts/reachability-classify.mjs",
|
|
67
|
-
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs 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.3",
|
|
5
5
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
@@ -0,0 +1,311 @@
|
|
|
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
|
+
/**
|
|
55
|
+
* v1.5.3 built-in bootstrap defaults.
|
|
56
|
+
*
|
|
57
|
+
* A machine whose only capability is "upgrade the plugin" cannot be told a URL or a pin by hand,
|
|
58
|
+
* so the operator's endpoint and public key ship inside the package. Only the PUBLIC key is here:
|
|
59
|
+
* the endpoint cannot mint answers, and the private key never leaves the operator's machine. A signed
|
|
60
|
+
* answer still has to verify and be unexpired, so the built-in values are a discovery hint, not trust.
|
|
61
|
+
*/
|
|
62
|
+
export const DEFAULT_BOOTSTRAP_URL = 'http://202.182.123.154:8911/bootstrap';
|
|
63
|
+
export const DEFAULT_BOOTSTRAP_OPERATOR_KEY_PEM = [
|
|
64
|
+
'-----BEGIN PUBLIC KEY-----',
|
|
65
|
+
'MCowBQYDK2VwAyEAQdjWX9TggM/S1Bm/OKUI3/Q2IL20Le354AXwiMtua+0=',
|
|
66
|
+
'-----END PUBLIC KEY-----',
|
|
67
|
+
'',
|
|
68
|
+
].join(String.fromCharCode(10));
|
|
69
|
+
export const BOOTSTRAP_PROOF_PROTOCOL = 'w2m-bootstrap-proof/v1';
|
|
70
|
+
/** Refusals are values with stable names, because the status surface reports them verbatim. */
|
|
71
|
+
export const BOOTSTRAP_REFUSAL = Object.freeze({
|
|
72
|
+
NOT_CONFIGURED: 'BOOTSTRAP_NOT_CONFIGURED',
|
|
73
|
+
NO_FLEET_KEY: 'BOOTSTRAP_NO_FLEET_KEY',
|
|
74
|
+
NO_OPERATOR_PIN: 'BOOTSTRAP_NO_OPERATOR_PIN',
|
|
75
|
+
UNREACHABLE: 'BOOTSTRAP_UNREACHABLE',
|
|
76
|
+
BAD_JSON: 'BOOTSTRAP_BAD_JSON',
|
|
77
|
+
BAD_PROTOCOL: 'BOOTSTRAP_BAD_PROTOCOL',
|
|
78
|
+
WRONG_FLEET: 'BOOTSTRAP_WRONG_FLEET',
|
|
79
|
+
EXPIRED: 'BOOTSTRAP_EXPIRED',
|
|
80
|
+
BAD_SIGNATURE: 'BOOTSTRAP_BAD_SIGNATURE',
|
|
81
|
+
BAD_RELAYS: 'BOOTSTRAP_BAD_RELAYS',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const MAX_RELAYS = 16;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The fleet id a bootstrap request proves. Derived from the key, never sent as a raw claim.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} fleetKeyEncoded base64url 32-byte fleet key
|
|
90
|
+
* @returns {string|null}
|
|
91
|
+
*/
|
|
92
|
+
export function fleetIdOf(fleetKeyEncoded) {
|
|
93
|
+
const key = decodeFleetKey(fleetKeyEncoded);
|
|
94
|
+
return key === null ? null : fleetIdFromKey(key);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The canonical bytes the operator signs. Sorted, newline-joined, no JSON encoder ambiguity.
|
|
99
|
+
*
|
|
100
|
+
* @param {{fleet_id: string, relays: string[], issued_at: string, expires_at: string}} answer
|
|
101
|
+
* @returns {string}
|
|
102
|
+
*/
|
|
103
|
+
export function canonicalBootstrapPayload(answer) {
|
|
104
|
+
const relays = (Array.isArray(answer.relays) ? answer.relays : [])
|
|
105
|
+
.map((url) => String(url).trim().replace(/\/+$/, ''))
|
|
106
|
+
.sort();
|
|
107
|
+
return [
|
|
108
|
+
BOOTSTRAP_PROTOCOL,
|
|
109
|
+
String(answer.fleet_id ?? ''),
|
|
110
|
+
relays.join(','),
|
|
111
|
+
String(answer.issued_at ?? ''),
|
|
112
|
+
String(answer.expires_at ?? ''),
|
|
113
|
+
].join('\n');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Build the request proof for one bootstrap call.
|
|
118
|
+
*
|
|
119
|
+
* @param {{fleetKey: string, fleetId: string, issuedAtMs?: number, nonce: string}} input
|
|
120
|
+
* @returns {{protocol: string, fleet_id: string, issued_at_ms: number, nonce: string, alg: string, proof: string}}
|
|
121
|
+
*/
|
|
122
|
+
export function buildBootstrapProof(input) {
|
|
123
|
+
const key = decodeFleetKey(input.fleetKey);
|
|
124
|
+
if (key === null) throw new TypeError('fleetKey must be a 32-byte base64url key');
|
|
125
|
+
const issuedAtMs = Number.isInteger(input.issuedAtMs) ? input.issuedAtMs : Date.now();
|
|
126
|
+
const nonce = String(input.nonce ?? '');
|
|
127
|
+
const message = [BOOTSTRAP_PROOF_PROTOCOL, input.fleetId, String(issuedAtMs), nonce].join('\n');
|
|
128
|
+
const proof = createHmac('sha256', key).update(message, 'utf8').digest('hex');
|
|
129
|
+
return {
|
|
130
|
+
protocol: BOOTSTRAP_PROOF_PROTOCOL,
|
|
131
|
+
fleet_id: input.fleetId,
|
|
132
|
+
issued_at_ms: issuedAtMs,
|
|
133
|
+
nonce,
|
|
134
|
+
alg: 'hmac-sha256',
|
|
135
|
+
proof,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Verify one signed bootstrap answer against the pinned operator key and this machine's fleet.
|
|
141
|
+
*
|
|
142
|
+
* @param {object} answer Parsed JSON body from the bootstrap endpoint.
|
|
143
|
+
* @param {{operatorPublicKeyPem: string, fleetId: string, nowMs?: number}} input
|
|
144
|
+
* @returns {{ok: true, relays: string[], issued_at: string, expires_at: string}
|
|
145
|
+
* |{ok: false, reason_code: string, detail: string}}
|
|
146
|
+
*/
|
|
147
|
+
export function verifyBootstrapAnswer(answer, input) {
|
|
148
|
+
const refuse = (reason_code, detail) => ({ ok: false, reason_code, detail });
|
|
149
|
+
if (answer === null || typeof answer !== 'object' || Array.isArray(answer)) {
|
|
150
|
+
return refuse(BOOTSTRAP_REFUSAL.BAD_JSON, 'the bootstrap answer is not a JSON object');
|
|
151
|
+
}
|
|
152
|
+
if (answer.protocol !== BOOTSTRAP_PROTOCOL) {
|
|
153
|
+
return refuse(BOOTSTRAP_REFUSAL.BAD_PROTOCOL, `expected protocol ${BOOTSTRAP_PROTOCOL}, got ${JSON.stringify(answer.protocol ?? null)}`);
|
|
154
|
+
}
|
|
155
|
+
// v1.5.3: when the machine cannot derive a fleet key yet, it still must not be stranded: the
|
|
156
|
+
// signed relay list is public routing information, so a signature-only answer is adopted for relay
|
|
157
|
+
// assignment. Joining the fleet still needs the account-derived key, which is a separate step.
|
|
158
|
+
if (input.fleetId !== null && input.fleetId !== undefined && (typeof answer.fleet_id !== 'string' || answer.fleet_id !== input.fleetId)) {
|
|
159
|
+
return refuse(BOOTSTRAP_REFUSAL.WRONG_FLEET, 'the answer names a different fleet; it is not for this machine');
|
|
160
|
+
}
|
|
161
|
+
const relays = Array.isArray(answer.relays)
|
|
162
|
+
? [...new Set(answer.relays.filter((url) => typeof url === 'string' && url.trim() !== '').map((url) => url.trim().replace(/\/+$/, '')))]
|
|
163
|
+
: [];
|
|
164
|
+
if (relays.length === 0) return refuse(BOOTSTRAP_REFUSAL.BAD_RELAYS, 'the answer carries no relay URLs');
|
|
165
|
+
if (relays.length > MAX_RELAYS) return refuse(BOOTSTRAP_REFUSAL.BAD_RELAYS, `the answer carries ${relays.length} relays, more than ${MAX_RELAYS}`);
|
|
166
|
+
for (const url of relays) {
|
|
167
|
+
if (!/^https?:\/\/[^\s]+$/.test(url)) return refuse(BOOTSTRAP_REFUSAL.BAD_RELAYS, `${JSON.stringify(url)} is not an http(s) base URL`);
|
|
168
|
+
}
|
|
169
|
+
const nowMs = Number.isInteger(input.nowMs) ? input.nowMs : Date.now();
|
|
170
|
+
const expiresAt = Date.parse(String(answer.expires_at ?? ''));
|
|
171
|
+
if (!Number.isFinite(expiresAt)) return refuse(BOOTSTRAP_REFUSAL.BAD_JSON, '`expires_at` is not a timestamp');
|
|
172
|
+
if (expiresAt <= nowMs) return refuse(BOOTSTRAP_REFUSAL.EXPIRED, `the answer expired at ${answer.expires_at}`);
|
|
173
|
+
const issuedAt = Date.parse(String(answer.issued_at ?? ''));
|
|
174
|
+
if (!Number.isFinite(issuedAt)) return refuse(BOOTSTRAP_REFUSAL.BAD_JSON, '`issued_at` is not a timestamp');
|
|
175
|
+
const signature = answer.signature;
|
|
176
|
+
const signatureB64 = typeof signature === 'string' ? signature
|
|
177
|
+
: (signature !== null && typeof signature === 'object' ? signature.value : undefined);
|
|
178
|
+
if (typeof signatureB64 !== 'string' || signatureB64 === '') {
|
|
179
|
+
return refuse(BOOTSTRAP_REFUSAL.BAD_SIGNATURE, 'the answer carries no signature');
|
|
180
|
+
}
|
|
181
|
+
let publicKey;
|
|
182
|
+
try {
|
|
183
|
+
publicKey = createPublicKey(input.operatorPublicKeyPem);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
return refuse(BOOTSTRAP_REFUSAL.NO_OPERATOR_PIN, `the pinned operator key is not a usable PEM: ${error instanceof Error ? error.message : String(error)}`);
|
|
186
|
+
}
|
|
187
|
+
const signed = canonicalBootstrapPayload({ fleet_id: answer.fleet_id, relays, issued_at: answer.issued_at, expires_at: answer.expires_at });
|
|
188
|
+
let signatureOk = false;
|
|
189
|
+
try {
|
|
190
|
+
signatureOk = verifySignature(null, Buffer.from(signed, 'utf8'), publicKey, Buffer.from(signatureB64, 'base64'));
|
|
191
|
+
} catch {
|
|
192
|
+
signatureOk = false;
|
|
193
|
+
}
|
|
194
|
+
if (!verifiedSignatureOk(signatureOk)) {
|
|
195
|
+
return refuse(BOOTSTRAP_REFUSAL.BAD_SIGNATURE, 'the answer signature does not match the pinned operator key and the canonical payload');
|
|
196
|
+
}
|
|
197
|
+
return { ok: true, relays, issued_at: answer.issued_at, expires_at: answer.expires_at };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Kept separate so a test can assert the failure branch without a real crypto fault. */
|
|
201
|
+
function verifiedSignatureOk(value) {
|
|
202
|
+
return value === true;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Fetch, verify and cache one bootstrap answer.
|
|
207
|
+
*
|
|
208
|
+
* A cached file that still verifies is preferred (no network on every start); any verification
|
|
209
|
+
* failure falls through to a fresh fetch. Every failure keeps the caller's configuration untouched
|
|
210
|
+
* and returns a named reason for `w2m_status`.
|
|
211
|
+
*
|
|
212
|
+
* @param {object} deps
|
|
213
|
+
* @param {string} deps.url
|
|
214
|
+
* @param {string} deps.fleetKey
|
|
215
|
+
* @param {string} deps.operatorPublicKeyPem
|
|
216
|
+
* @param {string} deps.cacheFile
|
|
217
|
+
* @param {Function} [deps.fetchImpl]
|
|
218
|
+
* @param {number} [deps.timeoutMs]
|
|
219
|
+
* @param {number} [deps.nowMs]
|
|
220
|
+
* @param {(line: string) => void} [deps.log]
|
|
221
|
+
* @returns {Promise<{ok: true, relays: string[], source: 'cache'|'network', expires_at: string}
|
|
222
|
+
* |{ok: false, reason_code: string, detail: string}>}
|
|
223
|
+
*/
|
|
224
|
+
export async function resolveBootstrap(deps) {
|
|
225
|
+
const log = deps.log ?? (() => {});
|
|
226
|
+
const fleetId = deps.fleetKey === null || deps.fleetKey === undefined ? null : fleetIdOf(deps.fleetKey);
|
|
227
|
+
if (deps.fleetKey !== null && deps.fleetKey !== undefined && fleetId === null) {
|
|
228
|
+
return { ok: false, reason_code: BOOTSTRAP_REFUSAL.NO_FLEET_KEY, detail: 'the configured fleet key is not a 32-byte base64url key' };
|
|
229
|
+
}
|
|
230
|
+
const pinned = readBootstrapCache(deps.cacheFile, { operatorPublicKeyPem: deps.operatorPublicKeyPem, fleetId, nowMs: deps.nowMs });
|
|
231
|
+
if (pinned.ok === true) {
|
|
232
|
+
log(`w2m: bootstrap cache is valid (${pinned.relays.join(', ')})`);
|
|
233
|
+
return { ok: true, relays: pinned.relays, source: 'cache', expires_at: pinned.expires_at };
|
|
234
|
+
}
|
|
235
|
+
const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
|
|
236
|
+
const timeoutMs = Number.isInteger(deps.timeoutMs) ? deps.timeoutMs : 4000;
|
|
237
|
+
const nonce = createHash('sha256').update(`${fleetId}:${Date.now()}:${Math.random()}`).digest('hex').slice(0, 32);
|
|
238
|
+
// v1.5.3: the proof is optional. Without a fleet key the request cannot prove which fleet it is,
|
|
239
|
+
// but the ANSWER is still verified by signature, expiry and relay shape -- so relay assignment can
|
|
240
|
+
// be automatic even before an account-derived key is available.
|
|
241
|
+
const proof = deps.fleetKey === null || deps.fleetKey === undefined ? null : buildBootstrapProof({ fleetKey: deps.fleetKey, fleetId, nonce });
|
|
242
|
+
let answer;
|
|
243
|
+
try {
|
|
244
|
+
const response = await fetchImpl(deps.url, {
|
|
245
|
+
method: 'POST',
|
|
246
|
+
headers: { 'content-type': 'application/json' },
|
|
247
|
+
body: JSON.stringify({ protocol: BOOTSTRAP_PROOF_PROTOCOL, proof }),
|
|
248
|
+
signal: typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
|
|
249
|
+
? AbortSignal.timeout(timeoutMs) : undefined,
|
|
250
|
+
});
|
|
251
|
+
if (!response.ok) {
|
|
252
|
+
return { ok: false, reason_code: BOOTSTRAP_REFUSAL.UNREACHABLE, detail: `the bootstrap endpoint answered HTTP ${response.status}` };
|
|
253
|
+
}
|
|
254
|
+
answer = await response.json();
|
|
255
|
+
} catch (error) {
|
|
256
|
+
return { ok: false, reason_code: BOOTSTRAP_REFUSAL.UNREACHABLE, detail: error instanceof Error ? error.message : String(error) };
|
|
257
|
+
}
|
|
258
|
+
const verified = verifyBootstrapAnswer(answer, {
|
|
259
|
+
operatorPublicKeyPem: deps.operatorPublicKeyPem,
|
|
260
|
+
fleetId,
|
|
261
|
+
nowMs: deps.nowMs,
|
|
262
|
+
});
|
|
263
|
+
if (verified.ok !== true) return verified;
|
|
264
|
+
writeBootstrapCache(deps.cacheFile, { fleet_id: fleetId, relays: verified.relays, issued_at: verified.issued_at, expires_at: verified.expires_at, signature: answer.signature });
|
|
265
|
+
log(`w2m: bootstrap assigned relays (${verified.relays.join(', ')}, expires ${verified.expires_at})`);
|
|
266
|
+
return { ok: true, relays: verified.relays, source: 'network', expires_at: verified.expires_at };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Write a verified answer so the next start does not depend on the bootstrap endpoint being up. */
|
|
270
|
+
export function writeBootstrapCache(file, entry) {
|
|
271
|
+
if (typeof file !== 'string' || file === '') return false;
|
|
272
|
+
try {
|
|
273
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
274
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
275
|
+
writeFileSync(tmp, `${JSON.stringify({ protocol: 'w2m-bootstrap-cache/v1', ...entry }, null, 2)}\n`, 'utf8');
|
|
276
|
+
renameSync(tmp, file);
|
|
277
|
+
return true;
|
|
278
|
+
} catch {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Read the cache and re-verify it. A hand-edited cache is not trusted: the same signature check the
|
|
285
|
+
* network answer goes through runs here, so a cache cannot pin an unverified relay list.
|
|
286
|
+
*
|
|
287
|
+
* @param {string} file
|
|
288
|
+
* @param {{operatorPublicKeyPem: string, fleetId: string, nowMs?: number}} input
|
|
289
|
+
* @returns {{ok: true, relays: string[], expires_at: string}|{ok: false, reason_code: string, detail: string}}
|
|
290
|
+
*/
|
|
291
|
+
export function readBootstrapCache(file, input) {
|
|
292
|
+
const refuse = (reason_code, detail) => ({ ok: false, reason_code, detail });
|
|
293
|
+
if (typeof file !== 'string' || file === '' || !existsSync(file)) {
|
|
294
|
+
return refuse(BOOTSTRAP_REFUSAL.NOT_CONFIGURED, 'no bootstrap cache exists yet');
|
|
295
|
+
}
|
|
296
|
+
let parsed;
|
|
297
|
+
try {
|
|
298
|
+
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
299
|
+
} catch (error) {
|
|
300
|
+
return refuse(BOOTSTRAP_REFUSAL.BAD_JSON, `the bootstrap cache is not JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
301
|
+
}
|
|
302
|
+
const answer = {
|
|
303
|
+
protocol: BOOTSTRAP_PROTOCOL,
|
|
304
|
+
fleet_id: parsed.fleet_id,
|
|
305
|
+
relays: parsed.relays,
|
|
306
|
+
issued_at: parsed.issued_at,
|
|
307
|
+
expires_at: parsed.expires_at,
|
|
308
|
+
signature: parsed.signature,
|
|
309
|
+
};
|
|
310
|
+
return verifyBootstrapAnswer(answer, input);
|
|
311
|
+
}
|
|
@@ -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
|
-
|
|
797
|
-
|
|
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) => {
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DEFAULT_BOOTSTRAP_URL, DEFAULT_BOOTSTRAP_OPERATOR_KEY_PEM } from './bootstrap.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* DSH: Windows2MacO - the DSH Cordis plugin.
|
|
3
4
|
*
|
|
@@ -276,7 +277,7 @@ const MAX_STAGES = 16;
|
|
|
276
277
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
277
278
|
* overrides it at runtime for tests and packagers.
|
|
278
279
|
*/
|
|
279
|
-
const PLUGIN_VERSION = '1.5.
|
|
280
|
+
const PLUGIN_VERSION = '1.5.3';
|
|
280
281
|
|
|
281
282
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
282
283
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -885,6 +886,21 @@ function readConfig(config = {}) {
|
|
|
885
886
|
rabbitUrl,
|
|
886
887
|
rabbitSource,
|
|
887
888
|
rabbitUrlOverride,
|
|
889
|
+
// v1.5.2: the relay pool and the bootstrap inputs. `relayUrls` is what `w2m-bootstrap` writes;
|
|
890
|
+
// `relayTemporary` marks a fallback list so the fleet panel can say "临时 Relay 节点" out loud
|
|
891
|
+
// instead of presenting a stranded machine's fallback as a settled configuration.
|
|
892
|
+
relayUrls: Array.isArray(config.relayUrls)
|
|
893
|
+
? config.relayUrls.filter((url) => typeof url === 'string' && url.trim() !== '').map((url) => url.trim().replace(/\/+$/, ''))
|
|
894
|
+
: [],
|
|
895
|
+
relayTemporary: config.relayTemporary === true,
|
|
896
|
+
// v1.5.3: the operator endpoint and its public pin ship with the package, so a machine whose only
|
|
897
|
+
// capability is upgrading the plugin still finds its fleet. Explicit config/env still wins.
|
|
898
|
+
bootstrapUrl: typeof config.bootstrapUrl === 'string' && config.bootstrapUrl.trim() !== ''
|
|
899
|
+
? config.bootstrapUrl.trim()
|
|
900
|
+
: (typeof process.env.W2M_BOOTSTRAP_URL === 'string' && process.env.W2M_BOOTSTRAP_URL.trim() !== '' ? process.env.W2M_BOOTSTRAP_URL.trim() : DEFAULT_BOOTSTRAP_URL),
|
|
901
|
+
bootstrapOperatorKeyPem: typeof config.bootstrapOperatorKey === 'string' && config.bootstrapOperatorKey.trim() !== ''
|
|
902
|
+
? config.bootstrapOperatorKey
|
|
903
|
+
: (typeof process.env.W2M_BOOTSTRAP_OPERATOR_KEY === 'string' && process.env.W2M_BOOTSTRAP_OPERATOR_KEY.trim() !== '' ? process.env.W2M_BOOTSTRAP_OPERATOR_KEY : DEFAULT_BOOTSTRAP_OPERATOR_KEY_PEM),
|
|
888
904
|
p2pMode,
|
|
889
905
|
stunServers: stunList,
|
|
890
906
|
};
|
|
@@ -2176,6 +2192,7 @@ async function readP2PStatus(cfg, runtime, peersAnnounced = null) {
|
|
|
2176
2192
|
device_error: runtime.identityError,
|
|
2177
2193
|
local: status?.local ?? null,
|
|
2178
2194
|
rabbit_url: status?.rabbit_url ?? cfg.rabbitUrl,
|
|
2195
|
+
bootstrap: cfg.bootstrap ?? null,
|
|
2179
2196
|
// How many machines the relay holds a live announcement for. `w2m_devices` says which machines
|
|
2180
2197
|
// paired; this says which of them can be punched at, and the two differ constantly.
|
|
2181
2198
|
peers_announced: peersAnnounced,
|
|
@@ -4936,7 +4953,12 @@ export async function apply(ctx, config = {}) {
|
|
|
4936
4953
|
// Optional: bring up relay + agent in this process
|
|
4937
4954
|
// -------------------------------------------------------------------------------------------
|
|
4938
4955
|
if (cfg.autoStartAgent) {
|
|
4939
|
-
|
|
4956
|
+
// v1.5.3: a failed auto-start must not surface as an unhandled rejection. The bundled patch now
|
|
4957
|
+
// starts the agent by default, and a host whose plugin wiring forbids that (a restricted test
|
|
4958
|
+
// harness, a missing relay module) would otherwise reject where nothing can catch it.
|
|
4959
|
+
ctx.effect?.(() => startLocalStack(ctx, cfg).catch((error) => {
|
|
4960
|
+
ctx.logger?.warn?.('w2m: auto-start did not complete: ' + (error instanceof Error ? error.message : String(error)));
|
|
4961
|
+
}));
|
|
4940
4962
|
} else {
|
|
4941
4963
|
/**
|
|
4942
4964
|
* v0.8.5: **say it out loud.**
|
|
@@ -7354,6 +7376,45 @@ function sleepWithSignal(ms, signal) {
|
|
|
7354
7376
|
const resolvedFleetKey = { value: null, source: null, at: 0 };
|
|
7355
7377
|
|
|
7356
7378
|
async function startLocalStack(ctx, cfg) {
|
|
7379
|
+
|
|
7380
|
+
/**
|
|
7381
|
+
* v1.5.2: bootstrap relay assignment. A fresh install has no relay list; without this it falls
|
|
7382
|
+
* back to the compiled shared default, which is how a machine ends up looking connected on somebody
|
|
7383
|
+
* else side. The answer is used only when it verified against the pinned operator key and is for
|
|
7384
|
+
* this machine fleet; every failure leaves the configured relay untouched and is recorded in
|
|
7385
|
+
* status, because a bootstrap that silently failed is indistinguishable from one never configured.
|
|
7386
|
+
*/
|
|
7387
|
+
if ((Array.isArray(cfg.relayUrls) ? cfg.relayUrls.length : 0) === 0
|
|
7388
|
+
&& typeof cfg.bootstrapUrl === 'string' && typeof cfg.bootstrapOperatorKeyPem === 'string') {
|
|
7389
|
+
try {
|
|
7390
|
+
const { resolveBootstrap } = await import(new URL('./bootstrap.mjs', import.meta.url).href);
|
|
7391
|
+
const { homedir } = await import('node:os');
|
|
7392
|
+
const { join } = await import('node:path');
|
|
7393
|
+
const cacheFile = join(cfg.stateDir ?? join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'xclient'), 'bootstrap.json');
|
|
7394
|
+
const outcome = await resolveBootstrap({
|
|
7395
|
+
url: cfg.bootstrapUrl,
|
|
7396
|
+
fleetKey: cfg.fleetKey ?? null,
|
|
7397
|
+
operatorPublicKeyPem: cfg.bootstrapOperatorKeyPem,
|
|
7398
|
+
cacheFile,
|
|
7399
|
+
log: (line) => ctx.logger?.info?.(line),
|
|
7400
|
+
});
|
|
7401
|
+
if (outcome.ok === true) {
|
|
7402
|
+
cfg.relayUrls = outcome.relays;
|
|
7403
|
+
if (cfg.rabbitSource === 'shared-default' || !cfg.rabbitUrl) { cfg.rabbitUrl = outcome.relays[0]; cfg.rabbitSource = 'bootstrap'; }
|
|
7404
|
+
cfg.bootstrap = { configured: true, url: cfg.bootstrapUrl, source: outcome.source, relays: outcome.relays, expires_at: outcome.expires_at, last_error: null };
|
|
7405
|
+
ctx.logger?.info?.('w2m: bootstrap assigned relays (' + outcome.relays.join(', ') + ')');
|
|
7406
|
+
} else {
|
|
7407
|
+
cfg.bootstrap = { configured: true, url: cfg.bootstrapUrl, source: null, relays: [], expires_at: null, last_error: { reason_code: outcome.reason_code, detail: outcome.detail } };
|
|
7408
|
+
ctx.logger?.warn?.('w2m: bootstrap kept the configured relay (' + outcome.reason_code + ': ' + outcome.detail + ')');
|
|
7409
|
+
}
|
|
7410
|
+
} catch (error) {
|
|
7411
|
+
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) } };
|
|
7412
|
+
ctx.logger?.warn?.('w2m: bootstrap failed (' + (error instanceof Error ? error.message : String(error)) + ')');
|
|
7413
|
+
}
|
|
7414
|
+
} else if (cfg.bootstrap === undefined) {
|
|
7415
|
+
cfg.bootstrap = { configured: typeof cfg.bootstrapUrl === 'string', url: cfg.bootstrapUrl ?? null, source: null, relays: [], expires_at: null, last_error: null };
|
|
7416
|
+
}
|
|
7417
|
+
|
|
7357
7418
|
const relayUrl = new URL('../relay/server.mjs', import.meta.url);
|
|
7358
7419
|
const agentUrl = new URL('../agent/agent.mjs', import.meta.url);
|
|
7359
7420
|
|