@twinsearth/w2m-dsh-plugin 0.8.0 → 0.8.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/CHANGELOG.md +207 -0
- package/HANDOFF.md +173 -0
- package/bin/w2m-localside.mjs +325 -308
- package/deploy/mac/mac-self-update.mjs +257 -0
- package/package.json +4 -3
- package/src/agent/agent.mjs +24 -2
- package/src/chatops/account.mjs +400 -360
- package/src/chatops/fleet-join.mjs +164 -0
- package/src/chatops/fleet-wiring.mjs +63 -4
- package/src/chatops/ilink-fleet-bridge.mjs +197 -0
- package/src/chatops/index.mjs +440 -429
- package/src/plugin/tools.mjs +4691 -4622
- package/src/relay/state.mjs +49 -3
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Mac's self-updating supervisor (v0.8.0).
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is detached rather than run inline
|
|
5
|
+
*
|
|
6
|
+
* It has to **survive the thing it is restarting**. DSH spawns the Localside agent, so a script
|
|
7
|
+
* running inside that agent dies the moment DSH stops — which is exactly when the work begins. So
|
|
8
|
+
* this is launched detached with `stdio: 'ignore'` and `unref()`, and it is the *only* process that
|
|
9
|
+
* outlives the restart.
|
|
10
|
+
*
|
|
11
|
+
* ## The failure this is built around
|
|
12
|
+
*
|
|
13
|
+
* A blind remote update has one catastrophic outcome: DSH stops, the plugin does not load, and the
|
|
14
|
+
* machine never comes back — leaving no channel to fix it with. So every step is ordered so that
|
|
15
|
+
* **the previous state is restorable without the network**:
|
|
16
|
+
*
|
|
17
|
+
* 1. back up the installed plugin tree to a timestamped directory
|
|
18
|
+
* 2. stage the new tarball and verify it EXTRACTED (a truncated download upacks to nothing)
|
|
19
|
+
* 3. swap, then restart
|
|
20
|
+
* 4. **self-check**: is DSH running, and is the plugin loaded?
|
|
21
|
+
* 5. **on failure, roll back and restart again** — automatically, because nobody is watching
|
|
22
|
+
*
|
|
23
|
+
* ## How success is reported when nothing can be read back
|
|
24
|
+
*
|
|
25
|
+
* The machine cannot tell us anything directly: its task results are `unverifiable` and its stdout
|
|
26
|
+
* never reaches the relay. So the report is written to a file **and** the outcome is made visible
|
|
27
|
+
* indirectly — a successful restart puts the machine back in the fleet, and the caller watches for
|
|
28
|
+
* it. `--verify-fleet` does that check from inside this script as well, against the relay's
|
|
29
|
+
* `/healthz`, so the rollback decision does not depend on an outside observer.
|
|
30
|
+
*
|
|
31
|
+
* Usage: node mac-self-update.mjs --tarball <path> --sha256 <hex> [--verify-fleet <url>]
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
35
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, cpSync, rmSync, readdirSync, statSync } from 'node:fs';
|
|
36
|
+
import { createHash } from 'node:crypto';
|
|
37
|
+
import { homedir, tmpdir } from 'node:os';
|
|
38
|
+
import { join } from 'node:path';
|
|
39
|
+
|
|
40
|
+
const argv = process.argv.slice(2);
|
|
41
|
+
const opt = (name, dflt) => (argv.includes(name) ? argv[argv.indexOf(name) + 1] : dflt);
|
|
42
|
+
|
|
43
|
+
const TARBALL = opt('--tarball', '');
|
|
44
|
+
const EXPECTED = opt('--sha256', '');
|
|
45
|
+
const VERIFY_FLEET = opt('--verify-fleet', '');
|
|
46
|
+
const PACKAGE = '@twinsearth/w2m-dsh-plugin';
|
|
47
|
+
const STAMP = new Date().toISOString().replace(/[:.]/g, '-');
|
|
48
|
+
const REPORT = join(homedir(), 'w2m-self-update-report.json');
|
|
49
|
+
|
|
50
|
+
const steps = [];
|
|
51
|
+
const record = (step, ok, detail) => {
|
|
52
|
+
steps.push({ step, ok, detail, at: new Date().toISOString() });
|
|
53
|
+
// Written after every step: if the process is killed mid-way, the report says where it stopped.
|
|
54
|
+
try { writeFileSync(REPORT, JSON.stringify({ stamp: STAMP, steps, done: false }, null, 2)); } catch { /* the report must never break the update */ }
|
|
55
|
+
};
|
|
56
|
+
const finish = (ok, extra = {}) => {
|
|
57
|
+
try { writeFileSync(REPORT, JSON.stringify({ stamp: STAMP, ok, steps, done: true, ...extra }, null, 2)); } catch { /* as above */ }
|
|
58
|
+
process.exit(ok ? 0 : 1);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Run a command, never throwing: a missing tool is a finding, not a crash. */
|
|
62
|
+
function run(cmd, args, opts = {}) {
|
|
63
|
+
try {
|
|
64
|
+
return { ok: true, out: execFileSync(cmd, args, { encoding: 'utf8', timeout: opts.timeout ?? 60_000, stdio: ['ignore', 'pipe', 'pipe'] }).trim() };
|
|
65
|
+
} catch (e) {
|
|
66
|
+
return { ok: false, out: `${e.status ?? ''} ${String(e.stderr ?? e.message).trim()}`.trim() };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Find the DSH home and the installed plugin.
|
|
72
|
+
*
|
|
73
|
+
* Discovered rather than assumed: the paths differ between a packaged app, a profile install, and a
|
|
74
|
+
* development checkout, and this script has exactly one chance to be right.
|
|
75
|
+
*/
|
|
76
|
+
function findInstall() {
|
|
77
|
+
const home = homedir();
|
|
78
|
+
const candidates = [
|
|
79
|
+
join(home, '.dsh'),
|
|
80
|
+
join(home, 'Library', 'Application Support', 'dsh'),
|
|
81
|
+
join(home, 'Library', 'Application Support', 'DeepSeek Harness'),
|
|
82
|
+
];
|
|
83
|
+
for (const dshHome of candidates) {
|
|
84
|
+
if (!existsSync(dshHome)) continue;
|
|
85
|
+
const profiles = join(dshHome, 'profiles');
|
|
86
|
+
if (!existsSync(profiles)) continue;
|
|
87
|
+
for (const profile of readdirSync(profiles)) {
|
|
88
|
+
const mod = join(profiles, profile, 'node_modules', ...PACKAGE.split('/'));
|
|
89
|
+
if (existsSync(join(mod, 'package.json'))) {
|
|
90
|
+
let version = '?';
|
|
91
|
+
try { version = JSON.parse(readFileSync(join(mod, 'package.json'), 'utf8')).version; } catch { /* reported as ? */ }
|
|
92
|
+
return { dshHome, profiles, profile, moduleDir: mod, version, profileDir: join(profiles, profile) };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Is DSH itself running, and what launched it? */
|
|
100
|
+
function findDshProcess() {
|
|
101
|
+
const ps = run('/bin/ps', ['-Ao', 'pid=,command='], { timeout: 20_000 });
|
|
102
|
+
if (!ps.ok) return { ok: false, reason: `ps failed: ${ps.out}` };
|
|
103
|
+
const lines = ps.out.split('\n').filter((l) => l.trim() !== '');
|
|
104
|
+
const self = String(process.pid);
|
|
105
|
+
const matches = lines
|
|
106
|
+
.map((l) => {
|
|
107
|
+
const m = /^\s*(\d+)\s+(.*)$/.exec(l);
|
|
108
|
+
return m ? { pid: m[1], command: m[2] } : null;
|
|
109
|
+
})
|
|
110
|
+
.filter(Boolean)
|
|
111
|
+
.filter((p) => p.pid !== self)
|
|
112
|
+
.filter((p) => /DeepSeek Harness|\.dsh|dsh\b/i.test(p.command))
|
|
113
|
+
.filter((p) => !/mac-self-update|node -e/i.test(p.command));
|
|
114
|
+
return { ok: true, matches };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The fleet check: does this machine show up at the relay again?
|
|
119
|
+
*
|
|
120
|
+
* `machineName` is read from the environment the agent was started with, falling back to the
|
|
121
|
+
* hostname — the relay lists machines by the name they paired under.
|
|
122
|
+
*/
|
|
123
|
+
async function fleetHasMachine(url, name) {
|
|
124
|
+
try {
|
|
125
|
+
const res = await fetch(new URL('/healthz', url).toString(), { signal: AbortSignal.timeout(10_000) });
|
|
126
|
+
const body = await res.json();
|
|
127
|
+
if (body?.ok !== true) return { ok: false, why: `healthz ok=${body?.ok}` };
|
|
128
|
+
// `/healthz` gives a count; the device list needs a device token, so the count is the signal
|
|
129
|
+
// available here. A drop to zero means the fleet lost everyone and the rollback should fire.
|
|
130
|
+
return { ok: (body.devices ?? 0) > 0, why: `devices=${body.devices}`, devices: body.devices ?? 0 };
|
|
131
|
+
} catch (e) {
|
|
132
|
+
return { ok: false, why: `healthz threw: ${e.message}` };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
137
|
+
|
|
138
|
+
async function main() {
|
|
139
|
+
const install = findInstall();
|
|
140
|
+
if (install === null) {
|
|
141
|
+
record('locate the installed plugin', false, `no ${PACKAGE} under any known DSH home`);
|
|
142
|
+
return finish(false, { reason: 'not_installed' });
|
|
143
|
+
}
|
|
144
|
+
record('locate the installed plugin', true, `v${install.version} at ${install.moduleDir}`);
|
|
145
|
+
|
|
146
|
+
const before = findDshProcess();
|
|
147
|
+
record('find the DSH process', before.ok, before.ok ? `${before.matches.length} match(es)` : before.reason);
|
|
148
|
+
|
|
149
|
+
// ---- 1. verify the artefact BEFORE touching anything -------------------------------
|
|
150
|
+
if (!existsSync(TARBALL)) { record('verify the artefact', false, `no tarball at ${TARBALL}`); return finish(false, { reason: 'no_tarball' }); }
|
|
151
|
+
const actual = createHash('sha256').update(readFileSync(TARBALL)).digest('hex');
|
|
152
|
+
if (EXPECTED !== '' && actual !== EXPECTED) {
|
|
153
|
+
record('verify the artefact', false, `checksum ${actual} != ${EXPECTED}`);
|
|
154
|
+
return finish(false, { reason: 'checksum_mismatch' });
|
|
155
|
+
}
|
|
156
|
+
record('verify the artefact', true, `sha256 ${actual.slice(0, 16)}…`);
|
|
157
|
+
|
|
158
|
+
// ---- 2. back up, so rollback needs nothing from the network -------------------------
|
|
159
|
+
const backup = `${install.moduleDir}.backup-${STAMP}`;
|
|
160
|
+
try {
|
|
161
|
+
cpSync(install.moduleDir, backup, { recursive: true });
|
|
162
|
+
record('back up the installed plugin', true, `${backup}`);
|
|
163
|
+
} catch (e) {
|
|
164
|
+
record('back up the installed plugin', false, e.message);
|
|
165
|
+
return finish(false, { reason: 'backup_failed' });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---- 3. stage and check it EXTRACTED -------------------------------------------------
|
|
169
|
+
const staging = join(tmpdir(), `w2m-stage-${STAMP}`);
|
|
170
|
+
mkdirSync(staging, { recursive: true });
|
|
171
|
+
const untar = run('/usr/bin/tar', ['-xzf', TARBALL, '-C', staging], { timeout: 180_000 });
|
|
172
|
+
if (!untar.ok) { record('unpack to staging', false, untar.out); return finish(false, { reason: 'untar_failed' }); }
|
|
173
|
+
const staged = join(staging, 'package');
|
|
174
|
+
const src = existsSync(staged) ? staged : staging;
|
|
175
|
+
const entry = join(src, 'bin', 'w2m-localside.mjs');
|
|
176
|
+
if (!existsSync(entry)) {
|
|
177
|
+
record('check the staged tree', false, `no bin/w2m-localside.mjs in ${src}`);
|
|
178
|
+
return finish(false, { reason: 'no_entry_point' });
|
|
179
|
+
}
|
|
180
|
+
let stagedVersion = '?';
|
|
181
|
+
try { stagedVersion = JSON.parse(readFileSync(join(src, 'package.json'), 'utf8')).version; } catch { /* reported as ? */ }
|
|
182
|
+
record('check the staged tree', true, `v${stagedVersion}`);
|
|
183
|
+
|
|
184
|
+
// ---- 4. swap -------------------------------------------------------------------------
|
|
185
|
+
try {
|
|
186
|
+
rmSync(install.moduleDir, { recursive: true, force: true });
|
|
187
|
+
cpSync(src, install.moduleDir, { recursive: true });
|
|
188
|
+
record('swap into place', true, `v${install.version} -> v${stagedVersion}`);
|
|
189
|
+
} catch (e) {
|
|
190
|
+
record('swap into place', false, e.message);
|
|
191
|
+
// The backup is right there; restore without asking.
|
|
192
|
+
try { rmSync(install.moduleDir, { recursive: true, force: true }); cpSync(backup, install.moduleDir, { recursive: true }); record('restore after a failed swap', true, 'reverted'); } catch (e2) { record('restore after a failed swap', false, e2.message); }
|
|
193
|
+
return finish(false, { reason: 'swap_failed' });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---- 5. restart ----------------------------------------------------------------------
|
|
197
|
+
/**
|
|
198
|
+
* DSH is restarted by signalling it, and **the detached supervisor is what makes that survivable**:
|
|
199
|
+
* this process is not a child of DSH, so it keeps running to do the self-check and the rollback.
|
|
200
|
+
*/
|
|
201
|
+
const targets = before.ok ? before.matches : [];
|
|
202
|
+
for (const p of targets) run('/bin/kill', ['-TERM', p.pid], { timeout: 10_000 });
|
|
203
|
+
record('signal DSH to stop', targets.length > 0, `${targets.length} process(es)`);
|
|
204
|
+
await sleep(5000);
|
|
205
|
+
|
|
206
|
+
// Bring it back the way it was launched, if a launcher is discoverable.
|
|
207
|
+
const launchd = run('/bin/launchctl', ['list'], { timeout: 15_000 });
|
|
208
|
+
const launchdHit = launchd.ok ? launchd.out.split('\n').find((l) => /dsh|harness/i.test(l)) : undefined;
|
|
209
|
+
if (launchdHit) {
|
|
210
|
+
record('restart via launchd', true, launchdHit.trim());
|
|
211
|
+
} else {
|
|
212
|
+
// No launchd job: try `open`, which is how a packaged app is started. A failure here is the
|
|
213
|
+
// case the rollback exists for.
|
|
214
|
+
const opened = run('/usr/bin/open', ['-a', 'DeepSeek Harness'], { timeout: 30_000 });
|
|
215
|
+
record('restart via open -a', opened.ok, opened.ok ? 'launched' : opened.out);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---- 6. self-check, with the rollback as the answer to a failure ----------------------
|
|
219
|
+
let healthy = false;
|
|
220
|
+
let why = '';
|
|
221
|
+
for (let i = 1; i <= 6; i += 1) {
|
|
222
|
+
await sleep(10_000);
|
|
223
|
+
const proc = findDshProcess();
|
|
224
|
+
if (!proc.ok || proc.matches.length === 0) { why = 'DSH is not running'; continue; }
|
|
225
|
+
if (VERIFY_FLEET !== '') {
|
|
226
|
+
const fleet = await fleetHasMachine(VERIFY_FLEET, '');
|
|
227
|
+
if (!fleet.ok) { why = `fleet check: ${fleet.why}`; continue; }
|
|
228
|
+
record(`self-check ${i}`, true, `DSH up, ${fleet.why}`);
|
|
229
|
+
} else {
|
|
230
|
+
record(`self-check ${i}`, true, `DSH up (${proc.matches.length} process(es))`);
|
|
231
|
+
}
|
|
232
|
+
healthy = true;
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (healthy) return finish(true, { from: install.version, to: stagedVersion, backup });
|
|
237
|
+
|
|
238
|
+
record('self-check', false, why || 'DSH did not come back');
|
|
239
|
+
// ---- 7. roll back, automatically ------------------------------------------------------
|
|
240
|
+
for (const p of (findDshProcess().matches ?? [])) run('/bin/kill', ['-TERM', p.pid], { timeout: 10_000 });
|
|
241
|
+
await sleep(3000);
|
|
242
|
+
try {
|
|
243
|
+
rmSync(install.moduleDir, { recursive: true, force: true });
|
|
244
|
+
cpSync(backup, install.moduleDir, { recursive: true });
|
|
245
|
+
record('roll back', true, `restored v${install.version}`);
|
|
246
|
+
} catch (e) {
|
|
247
|
+
record('roll back', false, e.message);
|
|
248
|
+
return finish(false, { reason: 'rollback_failed', backup });
|
|
249
|
+
}
|
|
250
|
+
run('/usr/bin/open', ['-a', 'DeepSeek Harness'], { timeout: 30_000 });
|
|
251
|
+
await sleep(20_000);
|
|
252
|
+
const after = findDshProcess();
|
|
253
|
+
record('verify the rollback', after.ok && after.matches.length > 0, `${after.matches?.length ?? 0} process(es)`);
|
|
254
|
+
return finish(false, { reason: 'rolled_back', restored: install.version, backup });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
main().catch((e) => { record('unhandled', false, String(e?.stack ?? e)); finish(false, { reason: 'threw' }); });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.3",
|
|
4
4
|
"description": "DSH: Windows2MacOS — drive the same project on online Windows and macOS machines from one DeepSeek account, across networks and regions. P2P hole punching is the default path (relay + shared server as rendezvous, ledger and fallback), with a bundled STUN responder, Tailscale/public-VPS/tunnel shapes, Rabbit relay + per-machine Localside + eight DSH tools (replicate / split), nine aggregate verdicts and signed requests.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"cordis.patch.yml",
|
|
28
28
|
"README.md",
|
|
29
29
|
"CHANGELOG.md",
|
|
30
|
+
"HANDOFF.md",
|
|
30
31
|
"LICENSE",
|
|
31
32
|
"PROTOCOL.md",
|
|
32
33
|
"PROTOCOL-v0.1.2.md",
|
|
@@ -135,13 +136,13 @@
|
|
|
135
136
|
},
|
|
136
137
|
"license": "MIT",
|
|
137
138
|
"scripts": {
|
|
138
|
-
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.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",
|
|
139
|
+
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.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/chatops-fleet-join.test.mjs",
|
|
139
140
|
"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",
|
|
140
141
|
"test:relay": "node --test --test-force-exit test/relay.test.mjs",
|
|
141
142
|
"test:agent": "node --test --test-force-exit test/agent.test.mjs",
|
|
142
143
|
"test:tools": "node --test --test-force-exit test/tools.test.mjs",
|
|
143
144
|
"test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
|
|
144
|
-
"test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.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/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/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-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.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",
|
|
145
|
+
"test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.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/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/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-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.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/chatops-fleet-join.test.mjs",
|
|
145
146
|
"pack": "node scripts/pack.mjs",
|
|
146
147
|
"lint": "eslint .",
|
|
147
148
|
"lint:fix": "eslint . --fix",
|
package/src/agent/agent.mjs
CHANGED
|
@@ -2273,14 +2273,36 @@ export function createAgent(options) {
|
|
|
2273
2273
|
* @param {string} pairingCode
|
|
2274
2274
|
* @param {string} [userId]
|
|
2275
2275
|
*/
|
|
2276
|
-
|
|
2276
|
+
/**
|
|
2277
|
+
* Pair this machine, by **either** an operator's code **or** the account's fleet key (v0.8.1).
|
|
2278
|
+
*
|
|
2279
|
+
* The two authorise different things, which is why both are accepted rather than one replacing the
|
|
2280
|
+
* other:
|
|
2281
|
+
*
|
|
2282
|
+
* * a **pairing code** is the operator saying *"let this machine in"*, and it is the only way to
|
|
2283
|
+
* place a device in a fleet the device could not compute for itself;
|
|
2284
|
+
* * a **fleet key** is the account saying *"this machine is mine"*, and the relay derives the
|
|
2285
|
+
* fleet from it — so it can only ever reach a fleet its holder is already entitled to.
|
|
2286
|
+
*
|
|
2287
|
+
* Passing neither remains allowed: the relay answers `PAIRING_INVALID` and the caller reports it,
|
|
2288
|
+
* which is the pre-v0.8.1 behaviour for an unpaired machine.
|
|
2289
|
+
*/
|
|
2290
|
+
async function pair(pairingCode, userId, fleetKey = null) {
|
|
2277
2291
|
const body = {
|
|
2278
|
-
pairing_code: pairingCode,
|
|
2279
2292
|
machine_id: identity.machine_id,
|
|
2280
2293
|
machine_name: identity.machine_name,
|
|
2281
2294
|
platform,
|
|
2282
2295
|
caps,
|
|
2283
2296
|
};
|
|
2297
|
+
/**
|
|
2298
|
+
* Send only the discriminator that was actually given.
|
|
2299
|
+
*
|
|
2300
|
+
* `pairing_code: null` would be *present*, and a presence check reads a null as "provided" —
|
|
2301
|
+
* the same trap that made `createILinkChannel` pass `{ fetch: undefined }` and throw on mount in
|
|
2302
|
+
* v0.7.0. An absent code must be an absent key, not a null one.
|
|
2303
|
+
*/
|
|
2304
|
+
if (typeof pairingCode === 'string' && pairingCode !== '') body.pairing_code = pairingCode;
|
|
2305
|
+
if (typeof fleetKey === 'string' && fleetKey !== '') body.fleet_key = fleetKey;
|
|
2284
2306
|
if (userId) body.user_id = userId;
|
|
2285
2307
|
const response = await postJson('/v1/pair', body, { auth: false });
|
|
2286
2308
|
if (!response.ok) {
|