@twinsearth/w2m-dsh-plugin 0.0.0-stage → 0.4.7

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +1309 -0
  2. package/LICENSE +21 -0
  3. package/PROTOCOL-v0.1.2.md +165 -0
  4. package/PROTOCOL-v0.3.0.md +454 -0
  5. package/PROTOCOL-v0.3.9.md +207 -0
  6. package/PROTOCOL-v0.4.0.md +255 -0
  7. package/PROTOCOL.md +339 -0
  8. package/README.md +665 -2
  9. package/bin/w2m-localside.mjs +303 -0
  10. package/bin/w2m-rabbit.mjs +396 -0
  11. package/bin/w2m-stun.mjs +231 -0
  12. package/cordis.patch.yml +94 -0
  13. package/deploy/caddy/Caddyfile.example +118 -0
  14. package/deploy/docker/Dockerfile +144 -0
  15. package/deploy/docker/README.md +338 -0
  16. package/deploy/docker/docker-compose.yml +179 -0
  17. package/deploy/nginx/w2m.conf.example +196 -0
  18. package/deploy/shared-server/README.md +118 -0
  19. package/deploy/shared-server/install.sh +255 -0
  20. package/deploy/systemd/w2m-rabbit.service +150 -0
  21. package/deploy/systemd/w2m-stun.service +69 -0
  22. package/deploy/windows/README.md +115 -0
  23. package/deploy/windows/install-service.ps1 +195 -0
  24. package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
  25. package/deploy/windows/winsw/w2m-localside.xml +50 -0
  26. package/docs/DEPLOY.md +902 -0
  27. package/docs/LINT-AUDIT.md +492 -0
  28. package/docs/PIPELINE-DESIGN.md +95 -0
  29. package/docs/TROUBLESHOOTING.md +560 -0
  30. package/docs/UI-CARDS.md +81 -0
  31. package/lib/index.js +24 -0
  32. package/lib/tools.js +11 -0
  33. package/package.json +133 -4
  34. package/src/agent/agent.mjs +3317 -0
  35. package/src/agent/allowed-commands.mjs +116 -0
  36. package/src/agent/caps.mjs +437 -0
  37. package/src/agent/exec.mjs +356 -0
  38. package/src/agent/git.mjs +616 -0
  39. package/src/agent/identity.mjs +253 -0
  40. package/src/agent/p2p-node.mjs +1243 -0
  41. package/src/agent/p2p.mjs +605 -0
  42. package/src/agent/resolve.mjs +148 -0
  43. package/src/agent/spool.mjs +252 -0
  44. package/src/agent/stun.mjs +397 -0
  45. package/src/agent/url.mjs +127 -0
  46. package/src/plugin/auto-update.mjs +574 -0
  47. package/src/plugin/schedule.mjs +452 -0
  48. package/src/plugin/shared-config.mjs +264 -0
  49. package/src/plugin/tools.mjs +3891 -0
  50. package/src/plugin/update-install.mjs +764 -0
  51. package/src/plugin/update-source.mjs +686 -0
  52. package/src/relay/metrics.mjs +284 -0
  53. package/src/relay/peers.mjs +180 -0
  54. package/src/relay/persistence.mjs +207 -0
  55. package/src/relay/report.mjs +591 -0
  56. package/src/relay/server.mjs +1187 -0
  57. package/src/relay/state.mjs +2004 -0
  58. package/src/relay/stun-server.mjs +733 -0
  59. package/src/signing.mjs +359 -0
  60. package/src/util/cli.mjs +70 -0
@@ -0,0 +1,3891 @@
1
+ /**
2
+ * DSH: Windows2MacO - the DSH Cordis plugin.
3
+ *
4
+ * # What this exposes
5
+ *
6
+ * Five tools over the W2M line protocol (see `_work/w2m/PROTOCOL.md`, frozen v1):
7
+ *
8
+ * w2m_devices read-only which machines the Rabbit currently holds leases for
9
+ * w2m_run control broadcast one argv command to the online machines
10
+ * w2m_wait read-only wait for terminal results and hand back the aggregate verdict
11
+ * w2m_report read-only the rendered report, markdown or json
12
+ * w2m_status read-only this machine's identity, Rabbit reachability, project base_commit
13
+ *
14
+ * The plugin is a **client of the Rabbit**. It does not execute commands, does not fork
15
+ * processes, and does not decide whether two machines agree - the Rabbit does that and this
16
+ * code only reports what it was told, plus the fields it did not recognise.
17
+ *
18
+ * # Fail-closed choices
19
+ *
20
+ * * A missing configured value is a **typed error naming the setting** (`W2M_CONFIG`), never an
21
+ * empty success. A missing capability is a typed error naming the capability.
22
+ * * Results are reported with their refusals intact: a non-zero exit, a `refused` machine, a
23
+ * `divergent` aggregate, and an `unverifiable` aggregate are all *values*, not exceptions. The
24
+ * only things that throw are "the tool could not run" and "the tool ran and said no".
25
+ * * Arguments travel as an **argv array**, never a shell string (PROTOCOL §0).
26
+ * * Every call is bounded by a wall-clock timeout and an output byte cap, so a wedged Rabbit
27
+ * cannot hang the session and a chatty one cannot flood it.
28
+ * * Every call honours `exec.signal`; the signal is merged with our own timeout so a cancel
29
+ * produces a typed abort, not a mystery failure.
30
+ * * This file never reads `.credentials.yaml`, and never hard-codes an endpoint: every URL comes
31
+ * from `config.rabbitUrl` or from `device.json`'s `rabbit_url`.
32
+ *
33
+ * # `@deepseek-ai/dsh-tools` and the offline fallback
34
+ *
35
+ * `defineTool` is imported lazily, at `apply()` time, through {@link loadDefineTool}. When the
36
+ * package is importabl - the real path, inside DS - its `defineTool` is used unchanged. When it
37
+ * is no - this repository has no `node_modules`, so `node --test` cannot resolve i - a minimal
38
+ * **shim** stands in so the unit tests can still drive `apply()` and the registered definitions.
39
+ *
40
+ * The shim validates only the shape this file says it must (name present, `parameters` an object,
41
+ * `execute` a function, no undeclared parameter keys). It does **not** implement argument
42
+ * coercion, `required` enforcement, timeout enforcement, cancellation, permission gating, or
43
+ * output rendering. **The shim is not a production path**; production semantics come from DSH's
44
+ * own `dsh-tools`. Anything the shim accepts may still be rejected or reshaped by the real
45
+ * implementation, and vice versa.
46
+ */
47
+
48
+ import { execFile } from 'node:child_process';
49
+ import { createHash } from 'node:crypto';
50
+ import { promises as fs } from 'node:fs';
51
+ import os from 'node:os';
52
+ import path from 'node:path';
53
+
54
+ // The base-address rules live in one place (§2), shared with the agent side. Importing the - rather
55
+ // than re-deriving them her - is what stops the plugin and the agent from disagreeing about what
56
+ // `https://host/w2m` means, which is the defect §2 exists to fix.
57
+ // `src/agent/` does not import `src/plugin/`, so the dependency runs one way and cannot cycle.
58
+ import { joinUrl, resolveBaseUrl } from '../agent/url.mjs';
59
+ import { deviceFilePath } from '../agent/identity.mjs';
60
+ // The allow-list matcher is shared with the agent on purpose: this plugin's pre-flight exists so a
61
+ // dispatcher does not send work the target machine would refuse, so the two ends must apply the
62
+ // same rule, token for token. See `src/agent/allowed-commands.mjs`.
63
+ import { matchAllowedCommand, parseAllowedCommands } from '../agent/allowed-commands.mjs';
64
+ import {
65
+ DEFAULT_P2P_MODE,
66
+ P2P_MODES,
67
+ P2PNode,
68
+ SHARED_SERVER,
69
+ normalizeP2PMode,
70
+ stunServersWithShared,
71
+ } from '../agent/p2p-node.mjs';
72
+ import { parseServer } from '../agent/stun.mjs';
73
+ // The result-deduplication identity the relay mints. Imported rather than re-implemented: the value
74
+ // is compared byte for byte by the executor, so a second copy of the formula that drifted by one
75
+ // character would turn "delivered twice, executed once" into "executed twice" -- the exact failure
76
+ // the dedupe key exists to prevent.
77
+ import { SHELL_ID_DIRECT, computeCommandHash, computeDedupeKey } from '../relay/state.mjs';
78
+ import { createAutoUpdater, findProfileDir, resolveCurrentVersion } from './auto-update.mjs';
79
+ import { loadSharedConfig, describeSharedConfig } from './shared-config.mjs';
80
+ import { DEFAULT_DAILY_TIMES, DEFAULT_TIME_ZONE } from './schedule.mjs';
81
+
82
+ /** Services this plugin needs. The harness refuses to load the plugin without them. */
83
+ export const inject = ['tools'];
84
+
85
+ /** Line protocol version this plugin speaks. Reported by `w2m_status`, never negotiated here. */
86
+ const PROTOCOL_VERSION = 1;
87
+
88
+ /**
89
+ * Upper bound on how many tasks w2m_history will ask the relay for.
90
+ *
91
+ * The relay has its own cap; this one exists so a model asking for a very large page cannot make
92
+ * *this* machine allocate it, and so the number is visible where the tool that uses it lives.
93
+ */
94
+ const MAX_HISTORY_LIMIT = 200;
95
+
96
+ /**
97
+ * Upper bound on `stages` for a pipeline task, mirroring the relay's limit.
98
+ *
99
+ * Declared here rather than imported so the plugin refuses an oversized chain on its own authority:
100
+ * a request that is going to be rejected by the relay anyway should not have to cross the network to
101
+ * find out, and the message the caller sees should name the stage rather than the HTTP status.
102
+ */
103
+ const MAX_STAGES = 16;
104
+
105
+ /**
106
+ * This plugin's own version, baked in at pack time.
107
+ *
108
+ * The updater compares the newest release against this string, so a wrong value here means either a
109
+ * downgrade (worse than a missed update) or a permanent no-op. It is therefore read from
110
+ * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
111
+ * overrides it at runtime for tests and packagers.
112
+ */
113
+ const PLUGIN_VERSION = '0.4.7';
114
+
115
+ /** How long any single HTTP call may take before it is aborted, in milliseconds. */
116
+ const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
117
+
118
+ /** How long `w2m_status`'s reachability probe may take, in milliseconds. */
119
+ const PROBE_TIMEOUT_MS = 3_000;
120
+
121
+ /** Wall-clock ceiling for one `w2m_wait` call, in milliseconds. */
122
+ const MAX_WAIT_MS = 600_000;
123
+
124
+ /** Wall-clock ceiling for one command handed to the machines, in milliseconds. */
125
+ const MAX_TASK_TIMEOUT_MS = 3_600_000;
126
+
127
+ /** Lower bound, in milliseconds, on the interval between two status polls. */
128
+ const MIN_POLL_MS = 25;
129
+
130
+ /** Largest JSON body this plugin will read, in bytes. Keeps a flood out of the session. */
131
+ const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
132
+
133
+ /** Largest report body this plugin will read, in bytes. */
134
+ const MAX_REPORT_BYTES = 2 * 1024 * 1024;
135
+
136
+ /** Largest `stdout`/`stderr` excerpt returned inline by `w2m_wait`, in bytes per stream. */
137
+ const MAX_EXCERPT_BYTES = 4096;
138
+
139
+ /** HTTP statuses worth one more attempt: the request did not reach a decision. */
140
+ const RETRYABLE_STATUS = new Set([429, 502, 503, 504]);
141
+
142
+ // ---------------------------------------------------------------------------------------------
143
+ // Errors
144
+ // ---------------------------------------------------------------------------------------------
145
+
146
+ /**
147
+ * An error a caller can act on, carrying a stable machine-readable code.
148
+ *
149
+ * `code` and `hint` are duplicated into enumerable own properties so that `JSON.stringify(error)`
150
+ - which a tool layer may well d - still shows the caller what was wrong.
151
+ */
152
+ class W2MError extends Error {
153
+ /**
154
+ * @param {string} code Stable code, e.g. `W2M_CONFIG`.
155
+ * @param {string} message What went wrong and, when known, how to fix it.
156
+ * @param {{hint?: string, detail?: object, cause?: unknown}} [options]
157
+ */
158
+ constructor(code, message, options = {}) {
159
+ super(message, options.cause === undefined ? undefined : { cause: options.cause });
160
+ this.name = 'W2MError';
161
+ this.code = code;
162
+ this.hint = options.hint;
163
+ this.detail = options.detail;
164
+ }
165
+ }
166
+
167
+ /**
168
+ * A configured value is missing or unusable.
169
+ *
170
+ * The message carries the setting name, what is wrong with it, and what to set instea - a model
171
+ * reading only the message has to be able to fix the configuration by itself. The hint repeats the
172
+ * remediation for callers that surface hints separately.
173
+ *
174
+ * @param {string} setting Dotted setting name, e.g. `rabbitUrl`.
175
+ * @param {string} why Why the value was refused.
176
+ * @param {string} hint What to set.
177
+ * @returns {W2MError}
178
+ */
179
+ function configError(setting, why, hint) {
180
+ return new W2MError('W2M_CONFIG', `W2M_CONFIG: configuration \`${setting}\` ${why}; ${hint}`, { hint });
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------------------------
184
+ // Cooperative cancellation
185
+ // ---------------------------------------------------------------------------------------------
186
+
187
+ /**
188
+ * An `AbortSignal` that fires when either input fires.
189
+ *
190
+ * Hand-rolled rather than `AbortSignal.any` so the plugin keeps working on the Node versions
191
+ * this project supports without a feature probe on the hot path.
192
+ *
193
+ * @param {AbortSignal|undefined} a
194
+ * @param {AbortSignal|undefined} b
195
+ * @returns {{signal: AbortSignal, dispose: () => void}}
196
+ */
197
+ function combineSignals(a, b) {
198
+ const controller = new AbortController();
199
+ const forward = (from) => () => controller.abort(from.reason);
200
+ const listeners = [];
201
+
202
+ for (const signal of [a, b]) {
203
+ if (!signal) continue;
204
+ if (signal.aborted) {
205
+ controller.abort(signal.reason);
206
+ break;
207
+ }
208
+ const handler = forward(signal);
209
+ signal.addEventListener('abort', handler, { once: true });
210
+ listeners.push([signal, handler]);
211
+ }
212
+
213
+ return {
214
+ signal: controller.signal,
215
+ dispose: () => {
216
+ for (const [signal, handler] of listeners) signal.removeEventListener('abort', handler);
217
+ },
218
+ };
219
+ }
220
+
221
+ /**
222
+ * Stop now when the caller has already cancelled, before doing any work.
223
+ *
224
+ * The abort reason is wrapped, not re-thrown raw: a caller that gets `W2M_ABORTED` back can tell
225
+ * "you cancelled me" apart from "the Rabbit refused", which is the distinction these tools exist
226
+ * to preserve. The original reason is kept as the cause.
227
+ *
228
+ * @param {AbortSignal|undefined} signal
229
+ */
230
+ function throwIfAborted(signal) {
231
+ if (!signal?.aborted) return;
232
+ const reason = signal.reason;
233
+ if (reason instanceof W2MError) throw reason;
234
+ throw new W2MError('W2M_ABORTED', `W2M_ABORTED: the call was cancelled${reason instanceof Error && reason.message ? ` (${reason.message})` : ''}`, {
235
+ hint: 'the caller withdrew the request; nothing was sent',
236
+ cause: reason,
237
+ });
238
+ }
239
+
240
+ /**
241
+ * Did this rejection come from an abort rather than from a real failure?
242
+ *
243
+ * @param {unknown} error
244
+ * @param {AbortSignal|undefined} signal
245
+ * @returns {boolean}
246
+ */
247
+ function isAbort(error, signal) {
248
+ if (signal?.aborted) return true;
249
+ if (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError')) {
250
+ return true;
251
+ }
252
+ return error instanceof W2MError && error.code === 'W2M_ABORTED';
253
+ }
254
+
255
+ /**
256
+ * How long this plugin waited, and whether it needs the Rabbit operator to look.
257
+ *
258
+ * A `timeout` envelope is precisely the case PROTOCOL §4.3 leaves to the Rabbit's clock, so the
259
+ * hint points there rather than pretending the local wait was authoritative.
260
+ *
261
+ * @param {string} taskId
262
+ * @param {string[]} states States still running when we gave up.
263
+ * @returns {object}
264
+ */
265
+ function timeoutEnvelope(taskId, states) {
266
+ return {
267
+ ok: true,
268
+ complete: false,
269
+ task_id: taskId,
270
+ state: 'timeout',
271
+ states,
272
+ note:
273
+ 'the wait window elapsed before every machine reached a terminal state; this is a local ' +
274
+ 'observation only, not a verdict on the machines',
275
+ hint: 'raise wait_ms, or call w2m_wait again to continue from where this left off',
276
+ };
277
+ }
278
+
279
+ // ---------------------------------------------------------------------------------------------
280
+ // `defineTool`: real import first, offline shim second
281
+ // ---------------------------------------------------------------------------------------------
282
+
283
+ /** Parameters the shim understands in a definition. Anything else is a typo, and is rejected. */
284
+ const ALLOWED_DEF_KEYS = new Set(['name', 'description', 'parameters', 'output', 'execute', 'presentCall']);
285
+
286
+ /**
287
+ * Validate one parameter declaration and return a normalised copy.
288
+ *
289
+ * @param {string} toolName Owning tool, for messages.
290
+ * @param {string} key Parameter name.
291
+ * @param {unknown} decl Declaration from the definition.
292
+ * @returns {object}
293
+ */
294
+ function normaliseParameter(toolName, key, decl) {
295
+ if (decl === null || typeof decl !== 'object' || Array.isArray(decl)) {
296
+ throw new TypeError(`${toolName}: parameter \`${key}\` must be an object`);
297
+ }
298
+ const { type } = decl;
299
+ if (type !== 'string' && type !== 'number' && type !== 'boolean' && type !== 'array' && type !== 'object') {
300
+ throw new TypeError(`${toolName}: parameter \`${key}\` needs a type of string|number|boolean|array|object`);
301
+ }
302
+ if (type === 'array' && 'items' in decl) {
303
+ if (decl.items === null || typeof decl.items !== 'object') {
304
+ throw new TypeError(`${toolName}: parameter \`${key}\`.items must be an object`);
305
+ }
306
+ }
307
+ const copy = { ...decl };
308
+ if (copy.required !== undefined && typeof copy.required !== 'boolean') {
309
+ throw new TypeError(`${toolName}: parameter \`${key}\`.required must be a boolean`);
310
+ }
311
+ if (copy.default !== undefined) copy.required = false;
312
+ return copy;
313
+ }
314
+
315
+ /**
316
+ * The offline stand-in for `@deepseek-ai/dsh-tools`' `defineTool`.
317
+ *
318
+ * It normalises and checks shape only; see the file header for the full list of what it
319
+ * deliberately does not do. The real `defineTool`'s own properties are copied onto it so anything
320
+ * else DSH exports alongside it keeps working.
321
+ *
322
+ * @param {object} [real] The genuine `defineTool`, when the package resolved.
323
+ * @returns {{defineTool: (def: object) => object, usedFallback: boolean}}
324
+ */
325
+ function createDefineTool(real) {
326
+ const defineTool = (definition) => {
327
+ if (definition === null || typeof definition !== 'object' || Array.isArray(definition)) {
328
+ throw new TypeError('defineTool: definition must be an object');
329
+ }
330
+
331
+ const name = definition.name;
332
+ if (typeof name !== 'string' || name.length === 0) {
333
+ throw new TypeError('defineTool: `name` must be a non-empty string');
334
+ }
335
+
336
+ for (const key of Object.keys(definition)) {
337
+ if (!ALLOWED_DEF_KEYS.has(key)) {
338
+ throw new TypeError(`defineTool(${name}): unsupported definition key \`${key}\``);
339
+ }
340
+ }
341
+
342
+ if (typeof definition.execute !== 'function') {
343
+ throw new TypeError(`defineTool(${name}): \`execute\` must be a function`);
344
+ }
345
+
346
+ const declared = definition.parameters ?? {};
347
+ if (declared === null || typeof declared !== 'object' || Array.isArray(declared)) {
348
+ throw new TypeError(`defineTool(${name}): \`parameters\` must be an object`);
349
+ }
350
+
351
+ const parameters = {};
352
+ for (const [key, decl] of Object.entries(declared)) {
353
+ parameters[key] = normaliseParameter(name, key, decl);
354
+ }
355
+
356
+ return { ...definition, parameters };
357
+ };
358
+
359
+ if (real && typeof real === 'object') Object.assign(defineTool, real);
360
+ return { defineTool, usedFallback: !real };
361
+ }
362
+
363
+ /** Cached so `apply()` can be called more than once without re-importing. */
364
+ let defineToolModule;
365
+
366
+ /**
367
+ * Forget the resolved `defineTool`, so the next `loadDefineTool()` resolves again.
368
+ *
369
+ * Exists for the unit tests, which need to exercise both the real-import path and the offline
370
+ * shim in the same process. It is not part of the plugin's runtime contract.
371
+ */
372
+ export function resetDefineToolCacheForTests() {
373
+ defineToolModule = undefined;
374
+ }
375
+
376
+ /**
377
+ * Resolve `@deepseek-ai/dsh-tools` at runtime, falling back to the offline shim.
378
+ *
379
+ * @returns {Promise<{defineTool: Function, usedFallback: boolean, reason: string|null}>}
380
+ */
381
+ async function loadDefineTool() {
382
+ if (!defineToolModule) {
383
+ defineToolModule = (async () => {
384
+ try {
385
+ const mod = await import('@deepseek-ai/dsh-tools');
386
+ if (typeof mod?.defineTool !== 'function') {
387
+ return { ...createDefineTool(undefined), reason: 'the package resolved but exports no `defineTool`' };
388
+ }
389
+ return { defineTool: mod.defineTool, usedFallback: false, reason: null };
390
+ } catch (error) {
391
+ return { ...createDefineTool(undefined), reason: error instanceof Error ? error.message : String(error) };
392
+ }
393
+ })();
394
+ }
395
+ return defineToolModule;
396
+ }
397
+
398
+ // ---------------------------------------------------------------------------------------------
399
+ // Configuration
400
+ // ---------------------------------------------------------------------------------------------
401
+
402
+ /**
403
+ * Read and validate the plugin configuration.
404
+ *
405
+ * Only `rabbitUrl` is required, and only for the tools that talk to the Rabbit: `w2m_status` is
406
+ * the diagnostic you reach for *when* the setup is broken, so it must still run without it.
407
+ *
408
+ * @param {object} config Entry configuration from the profile patch.
409
+ * @returns {{rabbitUrl: string|null, rabbitSource: string|null, rabbitUrlOverride: string|null,
410
+ * stateDir: string|null, projectDir: string, machineName: string|null,
411
+ * autoStartAgent: boolean, allowedCommands: string[], pairingCode: string|null,
412
+ * p2pMode: string, stunServers: string[]}}
413
+ */
414
+ function readConfig(config = {}) {
415
+ const cwd = process.cwd();
416
+
417
+ /**
418
+ * §7: where the relay address came from.
419
+ *
420
+ * The order is `rabbitUrl` → `W2M_RABBIT_URL` → the shared server, and the *source* is carried out
421
+ * of here rather than re-derived at report time, because "which of the three won" is exactly the
422
+ * question a cross-network deployment gets wrong: a machine that silently talked to the shared
423
+ * server instead of the operator's own relay looks identical to a working one until it dispatches
424
+ * work nobody receives. `rabbitUrlOverride` is what was suppressed (the device's own `rabbit_url`
425
+ * in `device.json`, which v0.3.9 used as a fallback) so `w2m_status` can say it out loud.
426
+ */
427
+ let rabbitUrl = null;
428
+ let rabbitSource = null;
429
+ let rabbitUrlOverride = null;
430
+ if (typeof config.rabbitUrl === 'string' && config.rabbitUrl.trim() !== '') {
431
+ rabbitUrl = checkedRabbitBase(config.rabbitUrl);
432
+ rabbitSource = 'config';
433
+ } else if (typeof process.env.W2M_RABBIT_URL === 'string' && process.env.W2M_RABBIT_URL.trim() !== '') {
434
+ try {
435
+ rabbitUrl = checkedRabbitBase(process.env.W2M_RABBIT_URL);
436
+ } catch (reason) {
437
+ // Re-thrown with the environment variable named: `W2M_RABBIT_URL` is set and wrong, and a
438
+ // message about `rabbitUrl` would send the operator to edit the wrong place.
439
+ throw configError(
440
+ 'W2M_RABBIT_URL',
441
+ `is set but not usable as a base address (${reason instanceof Error ? reason.message : String(reason)})`,
442
+ 'set it to the relay base address, for example http://127.0.0.1:8787 or https://w2m.example.com/w2m, or unset it to use the shared server',
443
+ );
444
+ }
445
+ rabbitSource = 'env';
446
+ } else {
447
+ rabbitUrl = checkedRabbitBase(SHARED_SERVER.rabbitUrl);
448
+ rabbitSource = 'shared-default';
449
+ rabbitUrlOverride = 'device.rabbit_url';
450
+ }
451
+
452
+ const stateDir = typeof config.stateDir === 'string' && config.stateDir.trim() !== ''
453
+ ? path.resolve(config.stateDir.trim())
454
+ : null;
455
+
456
+ // §7: both of these are validated here, at `apply()` time, so a typo is a startup error that names
457
+ // the setting rather than a status field nobody reads. See the two resolvers for why there is no
458
+ // silent default when a value *is* supplied.
459
+ const p2p = resolveP2PMode(config);
460
+ const p2pMode = p2p.mode;
461
+ const stun = resolveStunServers(config);
462
+ const stunList = stun.servers;
463
+
464
+ /**
465
+ * v0.3.3 shared config: `.w2m.json` in the project, overridden by `~/.w2m/machine.json`, overridden
466
+ * by the host's own config object.
467
+ *
468
+ * `allowedCommands` and the timeout participate here so that a fleet shares one allow-list instead
469
+ * of one per machine -- the machine whose copy drifted is the one nobody looks at until it refuses
470
+ * a command. The host layer is `config` itself, so an explicit setting always wins and nothing that
471
+ * used to work stops working.
472
+ *
473
+ * A broken file throws. Running on defaults while the operator believes their settings apply is the
474
+ * silently-wrong outcome this project refuses everywhere else, and a config file is no exception.
475
+ */
476
+ const shared = loadSharedConfig({
477
+ projectDir: typeof config.projectDir === 'string' && config.projectDir.trim() !== ''
478
+ ? path.resolve(config.projectDir.trim())
479
+ : cwd,
480
+ machineDir: typeof config.machineDir === 'string' && config.machineDir.trim() !== ''
481
+ ? path.resolve(config.machineDir.trim())
482
+ : undefined,
483
+ explicit: {
484
+ ...(Array.isArray(config.allowedCommands) ? { allowedCommands: config.allowedCommands } : {}),
485
+ ...(Number.isInteger(config.defaultTimeoutMs) ? { defaultTimeoutMs: config.defaultTimeoutMs } : {}),
486
+ ...(Number.isInteger(config.maxOutputBytes) ? { maxOutputBytes: config.maxOutputBytes } : {}),
487
+ ...(Array.isArray(config.writeScope) ? { writeScope: config.writeScope } : {}),
488
+ },
489
+ });
490
+
491
+ return {
492
+ stateDir,
493
+ projectDir: typeof config.projectDir === 'string' && config.projectDir.trim() !== ''
494
+ ? path.resolve(config.projectDir.trim())
495
+ : cwd,
496
+ machineName: typeof config.machineName === 'string' && config.machineName.trim() !== ''
497
+ ? config.machineName.trim()
498
+ : os.hostname(),
499
+ autoStartAgent: config.autoStartAgent === true,
500
+ // From the merged layers, not from `config` alone: this is the value the fleet shares.
501
+ allowedCommands: shared.values.allowedCommands
502
+ .filter((entry) => typeof entry === 'string')
503
+ .map((entry) => entry.trim())
504
+ .filter(Boolean),
505
+ defaultTimeoutMs: shared.values.defaultTimeoutMs,
506
+ sharedConfig: shared,
507
+ pairingCode: typeof config.pairingCode === 'string' && config.pairingCode.trim() !== ''
508
+ ? config.pairingCode.trim()
509
+ : (typeof process.env.W2M_PAIRING_CODE === 'string' && process.env.W2M_PAIRING_CODE.trim() !== ''
510
+ ? process.env.W2M_PAIRING_CODE.trim()
511
+ : null),
512
+ // §5: the operator credential. Only `w2m_run` uses it.
513
+ operatorToken: typeof config.operatorToken === 'string' && config.operatorToken.trim() !== ''
514
+ ? config.operatorToken.trim()
515
+ : (typeof process.env.W2M_OPERATOR_TOKEN === 'string' && process.env.W2M_OPERATOR_TOKEN.trim() !== ''
516
+ ? process.env.W2M_OPERATOR_TOKEN.trim()
517
+ : null),
518
+ // ---------------------------------------------------------------------------------------------
519
+ // v0.2.3: daily self-update
520
+ // ---------------------------------------------------------------------------------------------
521
+ // These belong to this plugin's own maintenance, not to the W2M line protocol, which is why
522
+ // they are read here rather than from the profile's Rabbit settings.
523
+ update: readUpdateConfig(config, stateDir),
524
+ // Carried through so the updater can find the profile it must install into. Resolved here
525
+ // rather than at use time so `w2m_status` can report the same path the installer will use.
526
+ profileDir: resolveProfileDir(config),
527
+ // ---------------------------------------------------------------------------------------------
528
+ // v0.4.0: the direct path
529
+ // ---------------------------------------------------------------------------------------------
530
+ rabbitUrl,
531
+ rabbitSource,
532
+ rabbitUrlOverride,
533
+ p2pMode,
534
+ stunServers: stunList,
535
+ };
536
+ }
537
+
538
+ /**
539
+ * Resolve `p2pMode` from the config object, then `W2M_P2P_MODE`, then the default — loudly.
540
+ *
541
+ * A value that is present and wrong is a `W2M_CONFIG` error naming the setting: falling back to
542
+ * `auto` would dispatch work over a path the operator explicitly asked not to use, and falling back
543
+ * to `relay` would silently disable the release's headline feature. Neither is a default; both are
544
+ * decisions the operator has to make.
545
+ *
546
+ * "Present" is decided by the *key*, not by truthiness. A profile patch that writes `p2pMode:` with
547
+ * nothing after it hands this function `null`, and that is a value somebody typed — collapsing it
548
+ * into "the key is absent" is exactly the silent default this function exists to refuse. An unset key
549
+ * and a blank `W2M_P2P_MODE` are the only two shapes that mean "nobody chose", and only the second is
550
+ * treated as absent because an unset environment variable is indistinguishable from an empty one.
551
+ *
552
+ * @param {Record<string, unknown>} config - Raw plugin config.
553
+ * @returns {{mode: string, source: 'config'|'env'|'default'}}
554
+ */
555
+ function resolveP2PMode(config) {
556
+ const hasConfig = Object.prototype.hasOwnProperty.call(config, 'p2pMode') && config.p2pMode !== undefined;
557
+ const fromEnv = process.env.W2M_P2P_MODE;
558
+ const hasEnv = typeof fromEnv === 'string' && fromEnv !== '';
559
+ const raw = hasConfig ? config.p2pMode : hasEnv ? fromEnv : DEFAULT_P2P_MODE;
560
+ const source = hasConfig ? 'config' : hasEnv ? 'env' : 'default';
561
+
562
+ const normalized = normalizeP2PMode(raw);
563
+ if (!normalized.ok) {
564
+ const setting = source === 'env' ? 'W2M_P2P_MODE' : 'p2pMode';
565
+ // The normalizer's own `P2P_MODE_*` code is kept: it is the reason, and dropping it here would
566
+ // leave a caller who has both messages unable to tell which check refused the value.
567
+ throw configError(
568
+ setting,
569
+ `is not a usable P2P mode (${normalized.reason})`,
570
+ `set it to one of ${P2P_MODES.join(', ')}: "auto" punches and keeps the relay as the fallback, ` +
571
+ '"direct" punches without hiding a failure, "relay" never opens a UDP socket at all',
572
+ );
573
+ }
574
+ return { mode: normalized.mode, source };
575
+ }
576
+
577
+ /**
578
+ * Resolve `stunServers` from the config object, then `W2M_STUN_SERVERS`, then the shared list.
579
+ *
580
+ * Every entry is validated here, before the node is built, because the alternative is a tick several
581
+ * seconds later inside the node's announce loop: an unusable STUN entry is a configuration mistake,
582
+ * and this project reports those at the call site rather than as a degraded status field. The rule
583
+ * itself is `parseServer` from `src/agent/stun.mjs` — the same function the query path will use, so
584
+ * "valid here" and "usable there" cannot drift.
585
+ *
586
+ * An **empty list is the default**, not an error. `stunServers: []` is what the bundled
587
+ * `cordis.patch.yml` carries (a key that documents itself and names no server), and a profile that
588
+ * starts from that file must not fail to load. `null` entries are dropped for the same reason — a
589
+ * YAML list with a blank item is the same statement as a blank item in a profile patch. A list whose
590
+ * entries are *wrong* is still refused: there is a difference between "nothing named" and "the wrong
591
+ * thing named", and only the second is a mistake.
592
+ *
593
+ * @param {Record<string, unknown>} config - Raw plugin config.
594
+ * @returns {{servers: string[], source: 'config'|'env'|'default'}}
595
+ */
596
+ function resolveStunServers(config) {
597
+ const fromConfig = Array.isArray(config.stunServers)
598
+ ? config.stunServers.filter((entry) => entry !== null && entry !== undefined)
599
+ : config.stunServers;
600
+ const fromEnv = process.env.W2M_STUN_SERVERS;
601
+ const configNamesNothing = Array.isArray(fromConfig) && fromConfig.length === 0;
602
+ const hasConfig = fromConfig !== undefined && fromConfig !== null && !configNamesNothing;
603
+ const hasEnv = typeof fromEnv === 'string' && fromEnv.trim() !== '';
604
+
605
+ if (!hasConfig && !hasEnv) {
606
+ return { servers: stunServersWithShared(), source: 'default' };
607
+ }
608
+
609
+ const setting = hasConfig ? 'stunServers' : 'W2M_STUN_SERVERS';
610
+ const raw = hasConfig ? fromConfig : fromEnv;
611
+ const entries = typeof raw === 'string' ? raw.split(',') : raw;
612
+ if (!Array.isArray(entries)) {
613
+ throw configError(
614
+ setting,
615
+ `must be an array of "host:port" strings or a comma-separated string, got ${typeof raw}`,
616
+ `give the STUN servers as a list, for example ["${SHARED_SERVER.stun}"], or omit the setting to use the shared server followed by the public fallbacks`,
617
+ );
618
+ }
619
+
620
+ const servers = [];
621
+ for (const entry of entries) {
622
+ if (typeof entry !== 'string') {
623
+ throw configError(
624
+ setting,
625
+ `must contain only "host:port" strings, got ${JSON.stringify(entry)} (${typeof entry})`,
626
+ `write every STUN server as a "host:port" string, for example "${SHARED_SERVER.stun}"`,
627
+ );
628
+ }
629
+ const spec = entry.trim();
630
+ if (spec === '') {
631
+ throw configError(
632
+ setting,
633
+ 'contains an empty entry',
634
+ `remove the empty entry, or omit the setting to use ${SHARED_SERVER.stun} followed by the public fallbacks`,
635
+ );
636
+ }
637
+ let parsed;
638
+ try {
639
+ parsed = parseServer(spec);
640
+ } catch (reason) {
641
+ throw configError(
642
+ setting,
643
+ `contains ${JSON.stringify(entry)}, which is not "host:port" (${reason instanceof Error ? reason.message : String(reason)})`,
644
+ `write every STUN server as a "host:port" string, for example "${SHARED_SERVER.stun}"`,
645
+ );
646
+ }
647
+ if (parsed.host === '') {
648
+ throw configError(
649
+ setting,
650
+ `contains ${JSON.stringify(entry)}, which names no host`,
651
+ `write the host as well as the port, for example "${SHARED_SERVER.stun}"`,
652
+ );
653
+ }
654
+ if (!servers.includes(spec)) servers.push(spec);
655
+ }
656
+ if (servers.length === 0) {
657
+ throw configError(
658
+ setting,
659
+ 'is an empty list, so no STUN server could be queried',
660
+ `omit the setting to use ${SHARED_SERVER.stun} followed by the public fallbacks, or name at least one "host:port"`,
661
+ );
662
+ }
663
+ return { servers, source: hasConfig ? 'config' : 'env' };
664
+ }
665
+
666
+ /**
667
+ * Read the self-update settings.
668
+ *
669
+ * Split out from `readConfig` because a malformed time or zone here must be a **loud startup error**
670
+ * naming the setting, not a silently-substituted default: a typo in `autoUpdateTimes` would
671
+ * otherwise move the check to an hour the user never chose, and nothing would say so.
672
+ *
673
+ * @param {Record<string, unknown>} config - Raw plugin config.
674
+ * @param {string|null} stateDir - Resolved state directory, if any.
675
+ * @returns {object} Normalized update settings.
676
+ */
677
+ function readUpdateConfig(config, stateDir) {
678
+ const env = process.env;
679
+
680
+ const boolFromEnv = (name) => {
681
+ const raw = env[name];
682
+ if (typeof raw !== 'string' || raw.trim() === '') return null;
683
+ const v = raw.trim().toLowerCase();
684
+ if (['1', 'true', 'yes', 'on'].includes(v)) return true;
685
+ if (['0', 'false', 'no', 'off'].includes(v)) return false;
686
+ return null;
687
+ };
688
+
689
+ // Off unless asked for. An updater that replaces the installed plugin on its own is not
690
+ // something to switch on behind an operator's back.
691
+ const enabledRaw = config.autoUpdate ?? boolFromEnv('W2M_AUTO_UPDATE');
692
+ const enabled = enabledRaw === true;
693
+
694
+ const timesRaw = Array.isArray(config.autoUpdateTimes) && config.autoUpdateTimes.length > 0
695
+ ? config.autoUpdateTimes
696
+ : (typeof env.W2M_AUTO_UPDATE_TIMES === 'string' && env.W2M_AUTO_UPDATE_TIMES.trim() !== ''
697
+ ? env.W2M_AUTO_UPDATE_TIMES.split(',')
698
+ : DEFAULT_DAILY_TIMES);
699
+
700
+ let times;
701
+ try {
702
+ times = normalizeUpdateTimes(timesRaw);
703
+ } catch (error) {
704
+ throw configError(
705
+ 'autoUpdateTimes',
706
+ error instanceof Error ? error.message : String(error),
707
+ 'use 24-hour "HH:mm:ss" values in the configured zone, for example ["00:00:00","03:00:00","05:00:00"]',
708
+ );
709
+ }
710
+
711
+ const timeZone = typeof config.autoUpdateTimeZone === 'string' && config.autoUpdateTimeZone.trim() !== ''
712
+ ? config.autoUpdateTimeZone.trim()
713
+ : (typeof env.W2M_AUTO_UPDATE_TZ === 'string' && env.W2M_AUTO_UPDATE_TZ.trim() !== ''
714
+ ? env.W2M_AUTO_UPDATE_TZ.trim()
715
+ : DEFAULT_TIME_ZONE);
716
+
717
+ // Validate the zone now, so a typo fails at load rather than at 03:00 in a log nobody reads.
718
+ try {
719
+ new Intl.DateTimeFormat('en-US', { timeZone }).format(new Date());
720
+ } catch {
721
+ throw configError(
722
+ 'autoUpdateTimeZone',
723
+ `is not a time zone this runtime knows: ${JSON.stringify(timeZone)}`,
724
+ 'use an IANA zone name such as Asia/Shanghai',
725
+ );
726
+ }
727
+
728
+ const repoRaw = typeof config.updateRepo === 'string' && config.updateRepo.trim() !== ''
729
+ ? config.updateRepo.trim()
730
+ : (typeof env.W2M_UPDATE_REPO === 'string' && env.W2M_UPDATE_REPO.trim() !== ''
731
+ ? env.W2M_UPDATE_REPO.trim()
732
+ : 'TwinsEarth/dsh-windows2macos');
733
+ if (!/^[\w.-]+\/[\w.-]+$/.test(repoRaw)) {
734
+ throw configError(
735
+ 'updateRepo',
736
+ `must look like "owner/name", got ${JSON.stringify(repoRaw)}`,
737
+ 'for example TwinsEarth/dsh-windows2macos',
738
+ );
739
+ }
740
+
741
+ /** @param {unknown} value @param {number} fallback */
742
+ const positive = (value, fallback) => (Number.isFinite(value) && Number(value) > 0 ? Number(value) : fallback);
743
+
744
+ return {
745
+ enabled,
746
+ times,
747
+ timeZone,
748
+ repo: repoRaw,
749
+ // Catch-up window: how late a missed slot may be and still run. Kept well under a day so a
750
+ // process that starts at 14:00 does not invent a check at an hour nobody asked for.
751
+ catchUpMs: positive(config.autoUpdateCatchUpMs, 90 * 60_000),
752
+ dryRun: config.autoUpdateDryRun === true,
753
+ allowPrerelease: config.autoUpdateAllowPrerelease === true,
754
+ timeoutMs: positive(config.autoUpdateTimeoutMs, 30_000),
755
+ // Read from the environment only: a repository token in a tracked config file is a token in git.
756
+ token: typeof env.W2M_UPDATE_TOKEN === 'string' && env.W2M_UPDATE_TOKEN.trim() !== ''
757
+ ? env.W2M_UPDATE_TOKEN.trim()
758
+ : null,
759
+ restartCommand: typeof config.autoUpdateRestartCommand === 'string' && config.autoUpdateRestartCommand.trim() !== ''
760
+ ? config.autoUpdateRestartCommand.trim()
761
+ : null,
762
+ stateDir,
763
+ };
764
+ }
765
+
766
+ /**
767
+ * Normalize the update slot list, rejecting anything that is not `HH:mm:ss`.
768
+ *
769
+ * @param {unknown[]} times - Raw entries.
770
+ * @returns {string[]} Ascending unique `HH:mm:ss` values.
771
+ */
772
+ function normalizeUpdateTimes(times) {
773
+ const out = [];
774
+ for (const entry of times) {
775
+ const value = typeof entry === 'string' ? entry.trim() : '';
776
+ if (!/^\d{2}:\d{2}:\d{2}$/.test(value)) {
777
+ throw new Error(`invalid time ${JSON.stringify(entry)}: expected HH:mm:ss`);
778
+ }
779
+ const [h, m, s] = value.split(':').map(Number);
780
+ if (h > 23 || m > 59 || s > 59) {
781
+ throw new Error(`invalid time ${JSON.stringify(entry)}: out of range`);
782
+ }
783
+ out.push(value);
784
+ }
785
+ const unique = [...new Set(out)].sort((a, b) => a.localeCompare(b));
786
+ if (unique.length === 0) throw new Error('at least one time is required');
787
+ return unique;
788
+ }
789
+
790
+ /**
791
+ * Locate the DSH profile directory that contains this plugin.
792
+ *
793
+ * The updater installs into the profile, so it has to name it. Two facts make this reliable: the
794
+ * plugin is loaded from `<profile>/node_modules/@twinsearth/w2m-dsh-plugin`, and the profile root is
795
+ * the directory holding that `node_modules` plus a `package.json`. We walk up and verify rather than
796
+ * string-slicing a fixed depth, because a pnpm layout can add a `.pnpm` segment.
797
+ *
798
+ * Returns `null` when no ancestor looks like a profile -- a checkout run directly from a clone has
799
+ * none. The caller must treat that as "cannot self-update here" and say so, not guess a path and
800
+ * write into it.
801
+ *
802
+ * @param {Record<string, unknown>} config - Raw plugin config; `profileDir` wins when set.
803
+ * @returns {string|null} Absolute profile directory, or null.
804
+ */
805
+ function resolveProfileDir(config) {
806
+ return findProfileDir({
807
+ explicit: config.profileDir,
808
+ moduleUrl: import.meta.url,
809
+ });
810
+ }
811
+
812
+ /**
813
+ * Require the Rabbit URL, naming the setting when it is absent.
814
+ *
815
+ * With §7's shared-server default this can no longer be reached by "the operator set nothing" — an
816
+ * unset `rabbitUrl` resolves to {@link SHARED_SERVER}. It stays as the last-resort guard so that a
817
+ * future edit which clears the field fails with a sentence naming it instead of an `Invalid URL`
818
+ * from somewhere deep in `fetch`.
819
+ *
820
+ * @param {{rabbitUrl: string|null}} cfg
821
+ * @returns {string}
822
+ */
823
+ function requireRabbit(cfg) {
824
+ if (!cfg.rabbitUrl) {
825
+ throw configError(
826
+ 'rabbitUrl',
827
+ 'resolved to nothing, so this tool has no Rabbit to talk to',
828
+ 'set `rabbitUrl` (or `W2M_RABBIT_URL`) to the Rabbit base URL, for example http://127.0.0.1:8787',
829
+ );
830
+ }
831
+ return cfg.rabbitUrl;
832
+ }
833
+
834
+ // ---------------------------------------------------------------------------------------------
835
+ // v0.4.0: the direct path (P2P node, result inbox, direct offer push)
836
+ // ---------------------------------------------------------------------------------------------
837
+
838
+ /** Directory, under `stateDir`, where result frames that arrived over a direct channel are kept. */
839
+ const P2P_INBOX_DIR = 'p2p-inbox';
840
+
841
+ /** Upper bound on a file-name component taken from the wire. Long enough for a uuid, short enough. */
842
+ const P2P_INBOX_NAME_MAX = 128;
843
+
844
+ /**
845
+ * Resolve the path of `device.json` the way both `w2m_run`'s origin identity and the direct path
846
+ * need it.
847
+ *
848
+ * Two sources, in the order the rest of the plugin already uses: an explicit `stateDir` is the
849
+ * operator pointing at a pairing, and `DSH_HOME` (via `deviceFilePath`) is the layout the agent
850
+ * writes by default. Nothing is created and nothing is guessed: a machine that has never paired has
851
+ * no identity, and that is a legal configuration in which the plugin simply dispatches over the
852
+ * relay.
853
+ *
854
+ * @param {{stateDir: string|null}} cfg
855
+ * @returns {string|null}
856
+ */
857
+ function devicePathFor(cfg) {
858
+ if (cfg.stateDir) return path.join(cfg.stateDir, 'device.json');
859
+ try {
860
+ return deviceFilePath({});
861
+ } catch {
862
+ // `DSH_HOME` is unset *and* the home directory cannot be resolved. There is no identity to find;
863
+ // that is a fact about this process, not a fault worth failing a dispatch over.
864
+ return null;
865
+ }
866
+ }
867
+ /**
868
+ * Read the local machine identity for the paths that need it, without ever throwing.
869
+ *
870
+ * {@link readDevice} answers from a `stateDir` and is the tool-facing reader; this one adds the
871
+ * `DSH_HOME` fallback (an unset `stateDir` is the documented default layout, not an error) and turns
872
+ * every failure into a `machine_id` of null plus an `error` — a machine whose identity cannot be read
873
+ * must still be able to dispatch, because the relay path does not need an identity at all.
874
+ *
875
+ * @param {ReturnType<typeof readConfig>} cfg
876
+ * @returns {Promise<{machine_id: string|null, machine_name: string|null, path: string|null, error: string|null}>}
877
+ */
878
+ async function readLocalIdentity(cfg) {
879
+ const file = devicePathFor(cfg);
880
+ if (file === null) return { machine_id: null, machine_name: null, path: null, error: null };
881
+ const device = await readDevice({ stateDir: path.dirname(file) });
882
+ return {
883
+ machine_id: device.machine_id,
884
+ machine_name: device.machine_name,
885
+ path: file,
886
+ error: device.error,
887
+ };
888
+ }
889
+
890
+ /**
891
+ * Turn a wire value into one safe path component.
892
+ *
893
+ * `task_id` and `machine_id` arrive over a channel from another machine, and they are used to build
894
+ * a *path*. `../` in either would be an arbitrary-file-write primitive handed to a peer, so anything
895
+ * outside `[A-Za-z0-9._-]` is replaced, and the result is capped. Nothing is lost by that: the inbox
896
+ * is read as a directory, never by reversing a file name, and the `task_id` inside the file is the
897
+ * authoritative copy.
898
+ *
899
+ * @param {string} value
900
+ * @returns {string}
901
+ */
902
+ function safeFilePart(value) {
903
+ const cleaned = value.replace(/[^A-Za-z0-9._-]/g, '_').replace(/^\.+/, '_');
904
+ return cleaned.slice(0, P2P_INBOX_NAME_MAX) || '_';
905
+ }
906
+
907
+ /**
908
+ * Canonical JSON with sorted keys, so the digest of a frame is stable.
909
+ *
910
+ * Deliberately *not* the relay's own `jcs` (that would make this file depend on relay internals for
911
+ * a two-line need). The digest here is only an integrity check of what this process stored, so the
912
+ * rule is local by definition; the value that has to match another process byte for byte — the
913
+ * dedupe key — is the one imported from the relay instead.
914
+ *
915
+ * @param {unknown} value
916
+ * @returns {string}
917
+ */
918
+ function canonicalJson(value) {
919
+ if (value === undefined) return 'null';
920
+ if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
921
+ if (Array.isArray(value)) return `[${value.map((entry) => canonicalJson(entry)).join(',')}]`;
922
+ const keys = Object.keys(value).sort();
923
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
924
+ }
925
+
926
+ /**
927
+ * Does `frame` name both halves of the inbox key?
928
+ *
929
+ * A frame without a `task_id` or without a `machine_id` cannot be stored or acknowledged — the
930
+ * acknowledgement is keyed by `task_id`, and two machines answering the same task must not overwrite
931
+ * each other — so it is refused by name instead of being written somewhere surprising.
932
+ *
933
+ * @param {object} frame
934
+ * @returns {string|null} The reason it is unusable, or null.
935
+ */
936
+ function inboxKeyProblem(frame) {
937
+ const taskId = frame?.task_id;
938
+ const machineId = frame?.machine_id;
939
+ if (typeof taskId !== 'string' || taskId.trim() === '') return 'P2P_FRAME_NO_TASK_ID: `task_id` is missing or empty';
940
+ if (typeof machineId !== 'string' || machineId.trim() === '') {
941
+ return 'P2P_FRAME_NO_MACHINE_ID: `machine_id` is missing or empty';
942
+ }
943
+ return null;
944
+ }
945
+
946
+ /**
947
+ * Write one file atomically, with 0600 where the platform honours it.
948
+ *
949
+ * A temporary file in the same directory plus a rename: a reader never sees a half-written result,
950
+ * and a crash leaves the old file (or no file) rather than a truncated JSON document. The mode is
951
+ * passed to `writeFile` rather than applied afterwards so the bytes are never readable to anyone
952
+ * else even for an instant; on Windows/NTFS the bits are ignored and the `chmod` is skipped, which
953
+ * is reported honestly by `mode_honoured` in `w2m_status` rather than pretended.
954
+ *
955
+ * @param {string} dir
956
+ * @param {string} file
957
+ * @param {string} text
958
+ * @returns {Promise<void>}
959
+ */
960
+ async function writeFileAtomic(dir, file, text) {
961
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
962
+ const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.${Date.now().toString(36)}.tmp`);
963
+ try {
964
+ await fs.writeFile(tmp, text, { encoding: 'utf8', mode: 0o600 });
965
+ await fs.rename(tmp, file);
966
+ } catch (error) {
967
+ await fs.rm(tmp, { force: true }).catch(() => {});
968
+ throw error;
969
+ }
970
+ if (process.platform !== 'win32') await fs.chmod(file, 0o600).catch(() => {});
971
+ }
972
+
973
+ /**
974
+ * Count the stored result frames.
975
+ *
976
+ * Depth is read from the directory rather than tracked in memory, so it answers the question that
977
+ * actually matters — "is there anything here I have not consumed" — across a plugin reload. A
978
+ * missing directory is depth 0, not an error: the inbox exists only once something has arrived.
979
+ *
980
+ * @param {string|null} dir
981
+ * @returns {Promise<number>}
982
+ */
983
+ async function countInbox(dir) {
984
+ if (dir === null) return 0;
985
+ try {
986
+ const names = await fs.readdir(dir);
987
+ return names.filter((name) => name.endsWith('.json')).length;
988
+ } catch {
989
+ return 0;
990
+ }
991
+ }
992
+
993
+ /**
994
+ * Build a `P2PNode`'s transport out of this plugin's one HTTP path.
995
+ *
996
+ * The direct path must not grow a second way to talk to the relay: bearer-token resolution, the
997
+ * base-URL joining that preserves a deployment sub-path, the timeout and the byte cap all live in
998
+ * {@link request}, and these two adapters are the whole of the P2P node's transport. The token is
999
+ * resolved per call rather than captured at startup, because a machine may pair *after* the plugin
1000
+ * was loaded and a captured `null` would leave the node permanently unauthenticated.
1001
+ *
1002
+ * Both adapters resolve for every HTTP status, which is the shape `P2PNode` expects: a refusal is an
1003
+ * answer (`{ok: false, status, json, error}`), and only a call that could not be made at all is an
1004
+ * `{ok: false, error}` from the catch.
1005
+ *
1006
+ * @param {ReturnType<typeof readConfig>} cfg
1007
+ * @returns {{postJson: Function, getJson: Function}}
1008
+ */
1009
+ function createP2PTransport(cfg) {
1010
+ const call = async (method, pathname, body) => {
1011
+ let token = null;
1012
+ try {
1013
+ token = await resolveToken(cfg);
1014
+ } catch {
1015
+ // An unreadable device.json is reported by `w2m_status`, where the operator is looking for it.
1016
+ // Here it must not stop the rendezvous: the relay answers an unauthenticated announce with a
1017
+ // named 401, which the node records in `status.last_announce_error`.
1018
+ token = null;
1019
+ }
1020
+ try {
1021
+ const result = await request({
1022
+ rabbitUrl: requireRabbit(cfg),
1023
+ token,
1024
+ method,
1025
+ pathname,
1026
+ body,
1027
+ // Short: this is background signalling, and a hung announce must not outlive the node's own
1028
+ // refresh interval, which would stack announcements on top of each other.
1029
+ timeoutMs: 5_000,
1030
+ maxBytes: 64 * 1024,
1031
+ });
1032
+ return { ok: result.ok, status: result.status, json: result.json, error: result.error ?? null };
1033
+ } catch (error) {
1034
+ return { ok: false, status: null, json: null, error: error instanceof Error ? error.message : String(error) };
1035
+ }
1036
+ };
1037
+
1038
+ return {
1039
+ postJson: (pathname, body) => call('POST', pathname, body),
1040
+ getJson: (pathname) => call('GET', pathname, undefined),
1041
+ };
1042
+ }
1043
+
1044
+ /**
1045
+ * Mint the offer frame the executor needs, from the lease the relay just issued.
1046
+ *
1047
+ * WHY THIS IS DERIVED RATHER THAN INVENTED
1048
+ *
1049
+ * The executor decides whether an offer is a first delivery or a duplicate on `dedupe_key` +
1050
+ * `attempt` (§6), and the relay emits its own SSE offer with those exact values. A push that
1051
+ * disagreed by one character would make the machine execute the task twice — once from each path —
1052
+ * so the dedupe key is computed with the relay's own {@link computeDedupeKey}/{@link computeCommandHash},
1053
+ * from the same `task_id`, `index`, `command_hash` inputs and `base_tree` the task was created with,
1054
+ * and `machine_id`/`index`/`attempt` come from the lease rather than from the caller's arguments.
1055
+ *
1056
+ * The one case that is *not* reproducible is `pipeline`, whose hash covers the relay's normalised
1057
+ * stage list rather than the caller's `stages`; there the key is left null and reported as such
1058
+ * instead of guessed, because a wrong key is worse than an absent one.
1059
+ *
1060
+ * @param {object} options
1061
+ * @param {string} options.taskId
1062
+ * @param {object} options.lease
1063
+ * @param {object} options.payload The validated `POST /v1/task` body this plugin sent.
1064
+ * @param {string|null} options.originMachineId
1065
+ * @returns {{frame: object|null, skipped: string|null}}
1066
+ */
1067
+ function buildOfferFrame({ taskId, lease, payload, originMachineId }) {
1068
+ const machineId = typeof lease?.machine_id === 'string' && lease.machine_id !== '' ? lease.machine_id : null;
1069
+ if (machineId === null) return { frame: null, skipped: 'the relay returned a lease with no machine_id' };
1070
+
1071
+ const index = Number.isInteger(lease?.index) ? lease.index : 0;
1072
+ // The relay's first attempt is 1, and a lease that never says otherwise is a first attempt.
1073
+ const attempt = Number.isInteger(lease?.attempt) && lease.attempt > 0 ? lease.attempt : 1;
1074
+ const indexTotal = Number.isInteger(payload?.index_total) ? payload.index_total : 1;
1075
+
1076
+ let dedupeKey = typeof lease?.dedupe_key === 'string' && lease.dedupe_key !== '' ? lease.dedupe_key : null;
1077
+ if (dedupeKey === null && payload?.mode !== 'pipeline' && Array.isArray(payload?.command_argv)) {
1078
+ try {
1079
+ const commandHash = computeCommandHash(payload.command_argv, SHELL_ID_DIRECT, payload.cwd_rel ?? '.');
1080
+ dedupeKey = computeDedupeKey(taskId, index, commandHash, payload.base_tree ?? null);
1081
+ } catch {
1082
+ dedupeKey = null;
1083
+ }
1084
+ }
1085
+
1086
+ return {
1087
+ frame: {
1088
+ type: 'task.offer',
1089
+ task_id: taskId,
1090
+ machine_id: machineId,
1091
+ attempt,
1092
+ index,
1093
+ index_total: indexTotal,
1094
+ dedupe_key: dedupeKey,
1095
+ mode: payload.mode,
1096
+ ...(Array.isArray(payload.command_argv) ? { command_argv: payload.command_argv } : {}),
1097
+ ...(Array.isArray(payload.stages) ? { stages: payload.stages } : {}),
1098
+ cwd_rel: payload.cwd_rel,
1099
+ write: payload.write,
1100
+ timeout_ms: payload.timeout_ms,
1101
+ base_commit: payload.base_commit ?? null,
1102
+ base_tree: payload.base_tree ?? null,
1103
+ requirements: payload.requirements,
1104
+ compare_policy: payload.compare_policy,
1105
+ origin_machine_id: originMachineId,
1106
+ p2p: payload.p2p ?? null,
1107
+ },
1108
+ skipped: null,
1109
+ };
1110
+ }
1111
+
1112
+ /**
1113
+ * Push one offer over the direct path. Best effort by construction: nothing here throws.
1114
+ *
1115
+ * @param {P2PNode} node
1116
+ * @param {object} frame
1117
+ * @returns {Promise<{ok: boolean, error: string|null}>}
1118
+ */
1119
+ async function pushOffer(node, frame) {
1120
+ try {
1121
+ const dialled = await node.dial(frame.machine_id);
1122
+ if (!dialled.ok) return { ok: false, error: dialled.error ?? 'P2P_DIAL_FAILED' };
1123
+ const channel = dialled.channel;
1124
+ try {
1125
+ await channel.send(JSON.stringify(frame));
1126
+ return { ok: true, error: null };
1127
+ } finally {
1128
+ // The offer is one frame over a reliable channel: holding the channel open would keep a NAT
1129
+ // mapping alive for a conversation that is over, and the result comes back the responder's way.
1130
+ channel.close('offer-sent');
1131
+ }
1132
+ } catch (error) {
1133
+ return { ok: false, error: `P2P_PUSH_THREW: ${error instanceof Error ? error.message : String(error)}` };
1134
+ }
1135
+ }
1136
+
1137
+ /**
1138
+ * Push an offer for one task to every machine the relay leased it to.
1139
+ *
1140
+ * Called after the relay has created the task, so the relay's own SSE offer has already gone out:
1141
+ * this is an *additional* delivery over the faster path, and the relay's copy is what makes a failed
1142
+ * push harmless. Only leases in a runnable state are pushed — `refused` and `observing` machines
1143
+ * were never handed the command, and sending them an offer would be asking them to run work the
1144
+ * relay decided they must not run.
1145
+ *
1146
+ * @param {P2PNode} node
1147
+ * @param {object} options
1148
+ * @returns {Promise<Array<object>>} One entry per lease considered, in lease order.
1149
+ */
1150
+ async function pushOffers(node, { taskId, leases, payload, originMachineId }) {
1151
+ const out = [];
1152
+ for (const lease of leases) {
1153
+ const machineId = typeof lease?.machine_id === 'string' && lease.machine_id !== '' ? lease.machine_id : null;
1154
+ const state = lease?.state;
1155
+ if (machineId === null) {
1156
+ out.push({ machine_id: null, ok: false, error: 'the relay returned a lease with no machine_id' });
1157
+ continue;
1158
+ }
1159
+ if (state !== 'queued' && state !== 'offered') {
1160
+ out.push({
1161
+ machine_id: machineId,
1162
+ ok: false,
1163
+ error: `lease state is ${JSON.stringify(state ?? null)}, so this machine was not handed the command`,
1164
+ });
1165
+ continue;
1166
+ }
1167
+ const built = buildOfferFrame({ taskId, lease, payload, originMachineId });
1168
+ if (built.frame === null) {
1169
+ out.push({ machine_id: machineId, ok: false, error: built.skipped });
1170
+ continue;
1171
+ }
1172
+ const result = await pushOffer(node, built.frame);
1173
+ out.push({
1174
+ machine_id: machineId,
1175
+ ok: result.ok,
1176
+ error: result.error,
1177
+ dedupe_key: built.frame.dedupe_key === null ? null : 'relay-formula',
1178
+ });
1179
+ }
1180
+ return out;
1181
+ }
1182
+
1183
+ /**
1184
+ * Build the object the tools reach the direct path through, and register its effect disposer.
1185
+ *
1186
+ * WHEN THE NODE STARTS, AND WHY IT IS NOT `apply()`
1187
+ *
1188
+ * The v0.4.0 contract says the node is "started in `apply()`". It is not, and this is the one place
1189
+ * this file departs from the contract's letter rather than its intent — reported as a deviation, not
1190
+ * settled quietly here. What *is* kept is everything the sentence is about: the direct path is owned
1191
+ * for the plugin's whole lifetime, it is disposed by a `ctx.effect` disposer, and a failure to start
1192
+ * is a field in `w2m_status` rather than an exception at load.
1193
+ *
1194
+ * The reason for the change is what "start" costs. Starting this node binds a UDP socket and kicks
1195
+ * off a STUN sweep against four servers — network traffic with a multi-second tail — for a machine
1196
+ * that may never dispatch a task in that session. `apply()` runs on every session in a host that
1197
+ * mounts this plugin, so an unconditional start is a rendezvous per session, and it puts traffic
1198
+ * into every test and every host whose runtime is deterministic about what it sends. `ensureStarted()`
1199
+ * therefore does the work the first time a tool actually needs the direct path: a dispatch
1200
+ * (`w2m_run`) or a question about it (`w2m_status`). A machine that never dispatches never punches,
1201
+ * which is also the more honest reading of §1 — "never dials" is the property that matters, and this
1202
+ * is closer to it than an announce nobody asked for.
1203
+ *
1204
+ * @param {object|null} ctx Cordis context; `ctx.effect` is used when present.
1205
+ * @param {ReturnType<typeof readConfig>} cfg
1206
+ * @param {object} [block] Test seam, and only that: `{ P2PNode?, bindUdpSocket?, discover?, tuning?,
1207
+ * onNode? }`. The plugin's own composition is exercised end to end against a real relay by
1208
+ * `test/p2p-plugin.test.mjs`, but two things a test cannot observe from outside are whether a
1209
+ * *socket* was bound and the node's own event stream. Injecting the binder makes the first a
1210
+ * measurement, and `onNode` hands the test the same object the tools use, so a `'message'` event is
1211
+ * driven through the production handler rather than a re-implementation of it. Nothing in
1212
+ * production reaches any of this.
1213
+ * @returns {object} The runtime handle; see the field comments in the body.
1214
+ */
1215
+ function createP2PRuntime(ctx, cfg, block = {}) {
1216
+ const inboxDir = cfg.stateDir === null ? null : path.join(cfg.stateDir, P2P_INBOX_DIR);
1217
+ const runtime = {
1218
+ node: null,
1219
+ starting: null,
1220
+ start: null,
1221
+ error: null,
1222
+ reason: null,
1223
+ mode: cfg.p2pMode,
1224
+ machineId: null,
1225
+ devicePath: devicePathFor(cfg),
1226
+ identityError: null,
1227
+ inboxDir,
1228
+ events: {
1229
+ frames: 0,
1230
+ results: 0,
1231
+ refused: [],
1232
+ last_error: null,
1233
+ last_at: null,
1234
+ inbox_path: null,
1235
+ /** Whether the result files were created with 0600. Windows/NTFS ignores the bits. */
1236
+ mode_honoured: process.platform !== 'win32',
1237
+ },
1238
+ /** The most recent `w2m_run`'s direct pushes, or null when none has run in this process. */
1239
+ directPushes: null,
1240
+ /**
1241
+ * Start the node once, on first use, and hand every later caller the same promise.
1242
+ *
1243
+ * Assigned before the relay-mode early return so the tools can call it unconditionally: a caller
1244
+ * asks "is the direct path ready" and gets an answer either way, instead of having to know which
1245
+ * mode it is in — the defect this shape fixes was a `relay`-mode dispatch calling a method that did
1246
+ * not exist, which is a crash in the mode that is supposed to be the safe one.
1247
+ *
1248
+ * @type {() => Promise<object>}
1249
+ */
1250
+ ensureStarted: async () => ({ ok: true, enabled: false, error: null }),
1251
+ };
1252
+
1253
+ if (cfg.p2pMode === 'relay') {
1254
+ runtime.reason = 'p2pMode is "relay", so no UDP socket is bound and the relay is the only path';
1255
+ return runtime;
1256
+ }
1257
+
1258
+ /**
1259
+ * Start the node once, on first use, and return the same promise for every later caller.
1260
+ *
1261
+ * Idempotent by construction: `starting` is assigned before the first `await` inside it, so two
1262
+ * concurrent callers share one bind (the same rule `P2PNode.start()` itself follows, one level up).
1263
+ *
1264
+ * @returns {Promise<object>} The node's own `start()` result, or the reason there is no node.
1265
+ */
1266
+ runtime.ensureStarted = () => {
1267
+ if (runtime.starting !== null) return runtime.starting;
1268
+ runtime.starting = initP2P(ctx, cfg, runtime, block).catch((error) => {
1269
+ // The `P2PNode` constructor is the one thing in it that throws (a bad mode or tuning key is a
1270
+ // caller bug), so a failure here is a composition error in this file and is reported like any
1271
+ // other: `w2m_status` shows it, the relay path keeps working.
1272
+ runtime.error = error instanceof Error ? error.message : String(error);
1273
+ runtime.start = { ok: false, enabled: true, error: runtime.error };
1274
+ return runtime.start;
1275
+ });
1276
+ return runtime.starting;
1277
+ };
1278
+
1279
+ return runtime;
1280
+ }
1281
+
1282
+ /**
1283
+ * Read the identity, build the node, and start it.
1284
+ *
1285
+ * Split out of {@link createP2PRuntime} so the ownership and the work are two readable pieces: this
1286
+ * one is allowed to await, and its caller records whatever it throws.
1287
+ *
1288
+ * @param {object|null} ctx
1289
+ * @param {ReturnType<typeof readConfig>} cfg
1290
+ * @param {object} runtime
1291
+ * @param {object} block Test seam; see {@link createP2PRuntime}.
1292
+ * @returns {Promise<object>} The node's own `start()` result.
1293
+ */
1294
+ async function initP2P(ctx, cfg, runtime, block) {
1295
+ const identity = await readLocalIdentity(cfg);
1296
+ runtime.machineId = identity.machine_id;
1297
+ runtime.identityError = identity.error;
1298
+
1299
+ if (identity.machine_id === null) {
1300
+ runtime.reason = identity.path === null
1301
+ ? 'no stateDir is set and DSH_HOME could not be resolved, so device.json cannot be located'
1302
+ : `no readable device identity at ${identity.path}, so this machine has no id to announce or dial with`;
1303
+ runtime.start = { ok: false, enabled: true, error: `P2P_NO_IDENTITY: ${runtime.reason}` };
1304
+ return runtime.start;
1305
+ }
1306
+
1307
+ const transport = createP2PTransport(cfg);
1308
+ // `ctx.effect` owns the node when the host provides it, and the fallback is the same ownership
1309
+ // with a shorter lifetime: a host that provides no effect (an older Cordis, or a bare test
1310
+ // context) gets the node and no disposer rather than no node.
1311
+ runtime.node = new (block.P2PNode ?? P2PNode)({
1312
+ mode: cfg.p2pMode,
1313
+ rabbitUrl: cfg.rabbitUrl,
1314
+ machineId: identity.machine_id,
1315
+ postJson: transport.postJson,
1316
+ getJson: transport.getJson,
1317
+ stunServers: cfg.stunServers,
1318
+ log: (line) => ctx?.logger?.info?.(`w2m ${line}`),
1319
+ ...block,
1320
+ });
1321
+
1322
+ // Attached before `start()`: `P2PNode.reportError` only emits when somebody is listening, and an
1323
+ // unlistened `'error'` event throws inside the runtime's own emit. A transport problem must stay a
1324
+ // recorded fact.
1325
+ runtime.node.on('error', (error) => {
1326
+ runtime.events.last_error = error instanceof Error ? error.message : String(error);
1327
+ });
1328
+ runtime.node.on('message', (event) => {
1329
+ void handleP2PMessage(runtime, event).catch((error) => {
1330
+ runtime.events.last_error = error instanceof Error ? error.message : String(error);
1331
+ });
1332
+ });
1333
+ // See the `block` note above: a test needs the node itself to drive a `'message'` event through
1334
+ // the handler above. Guarded so a malformed seam cannot take the node's start down with it.
1335
+ if (typeof block.onNode === 'function') {
1336
+ try {
1337
+ block.onNode(runtime.node);
1338
+ } catch {
1339
+ /* a test seam must never be why a node fails */
1340
+ }
1341
+ }
1342
+
1343
+ /**
1344
+ * Ownership: one node, one effect, one disposer.
1345
+ *
1346
+ * Registered *here*, after the node exists, rather than in `createP2PRuntime` — an effect that owns
1347
+ * nothing is a disposer that releases nothing, and `relay` mode and a machine with no identity must
1348
+ * leave the host's effect list exactly as they found it. A host that provides no `ctx.effect` (an
1349
+ * older Cordis, or a bare test context) still gets a working node: the socket is `unref()`ed, so it
1350
+ * cannot keep the process alive, and refusing to start the direct path because the host cannot track
1351
+ * a disposer would break the release's own default.
1352
+ */
1353
+ if (typeof ctx?.effect === 'function') {
1354
+ ctx.effect(() => () => {
1355
+ // `close()` is documented not to throw and to return a promise, but the whole point of this
1356
+ // disposer is that it runs on the way out of a plugin that may be half-built: a node whose bind
1357
+ // failed has no socket to close. Both the call and the promise are optional so a teardown can
1358
+ // never itself become the failure.
1359
+ runtime.node?.close?.()?.catch((error) => {
1360
+ runtime.events.last_error = error instanceof Error ? error.message : String(error);
1361
+ });
1362
+ });
1363
+ }
1364
+
1365
+ // eslint-disable-next-line require-atomic-updates -- single-entry: `ensureStarted` assigns `starting` before this runs
1366
+ runtime.start = await runtime.node.start();
1367
+ if (runtime.start.ok !== true) runtime.error = runtime.start.error ?? 'P2P_START_FAILED';
1368
+ return runtime.start;
1369
+ }
1370
+
1371
+ /**
1372
+ * Store one JSON frame that arrived over a direct channel, and acknowledge it.
1373
+ *
1374
+ * The writer is deliberately strict. A frame that is not a JSON object, or that names no
1375
+ * `task_id`/`machine_id`, is refused and **nothing** is written — an inbox file is the only durable
1376
+ * trace of a peer's result, and a file whose name does not identify its contents is worse than no
1377
+ * file, because the next reader cannot tell a partial result from a complete one.
1378
+ *
1379
+ * `task.result` is the frame this exists for; `result.ack` and anything else a peer might send are
1380
+ * counted and ignored rather than treated as faults. The acknowledgement goes back on the same
1381
+ * channel the frame arrived on, and a failed acknowledgement is recorded — the sender retries (the
1382
+ * channel is reliable) and the file is already on disk, so a lost ack must not lose the result.
1383
+ *
1384
+ * @param {object} runtime
1385
+ * @param {{payload: Buffer|string, channel: object|null, peer: object|null, session: number}} event
1386
+ * @returns {Promise<void>}
1387
+ */
1388
+ async function handleP2PMessage(runtime, event) {
1389
+ runtime.events.frames += 1;
1390
+ runtime.events.last_at = new Date().toISOString();
1391
+
1392
+ const raw = Buffer.isBuffer(event?.payload) ? event.payload.toString('utf8') : String(event?.payload ?? '');
1393
+ let frame = null;
1394
+ try {
1395
+ frame = JSON.parse(raw);
1396
+ } catch {
1397
+ runtime.events.refused.push({ reason: 'P2P_FRAME_NOT_JSON: the payload is not a JSON document', bytes: raw.length });
1398
+ return;
1399
+ }
1400
+ if (frame === null || typeof frame !== 'object' || Array.isArray(frame)) {
1401
+ runtime.events.refused.push({ reason: 'P2P_FRAME_NOT_AN_OBJECT: expected a JSON object', type: typeof frame });
1402
+ return;
1403
+ }
1404
+ if (frame.type !== 'task.result') {
1405
+ // Not an error: a peer's `result.ack` for an offer *this* plugin pushed arrives the same way, and
1406
+ // the channel is the transport, not a promise about the only frame that will ever cross it.
1407
+ return;
1408
+ }
1409
+
1410
+ const problem = inboxKeyProblem(frame);
1411
+ if (problem !== null) {
1412
+ runtime.events.refused.push({ reason: problem, type: 'task.result' });
1413
+ return;
1414
+ }
1415
+ if (runtime.inboxDir === null) {
1416
+ runtime.events.refused.push({
1417
+ reason: 'P2P_NO_STATE_DIR: stateDir is not set, so a received result has nowhere to be written',
1418
+ type: 'task.result',
1419
+ task_id: frame.task_id,
1420
+ });
1421
+ return;
1422
+ }
1423
+
1424
+ const stored = {
1425
+ received_at: new Date().toISOString(),
1426
+ transport: 'p2p',
1427
+ from_machine_id: frame.machine_id,
1428
+ task_id: frame.task_id,
1429
+ frame,
1430
+ // A digest of the stored copy, so a reader can tell "the file changed under me" from "the peer
1431
+ // sent something else". Not a signature — the channel is not authenticated at this layer.
1432
+ frame_sha256: createHash('sha256').update(canonicalJson(frame)).digest('hex'),
1433
+ };
1434
+ const file = path.join(
1435
+ runtime.inboxDir,
1436
+ `${safeFilePart(frame.task_id)}__${safeFilePart(frame.machine_id)}.json`,
1437
+ );
1438
+
1439
+ try {
1440
+ await writeFileAtomic(runtime.inboxDir, file, `${JSON.stringify(stored, null, 2)}\n`);
1441
+ } catch (error) {
1442
+ // eslint-disable-next-line require-atomic-updates -- one message at a time; the write is the only await
1443
+ runtime.events.last_error = `P2P_INBOX_WRITE_FAILED: ${error instanceof Error ? error.message : String(error)}`;
1444
+ runtime.events.refused.push({ reason: runtime.events.last_error, type: 'task.result', task_id: frame.task_id });
1445
+ return;
1446
+ }
1447
+ runtime.events.results += 1;
1448
+ runtime.events.inbox_path = file;
1449
+
1450
+ const channel = event?.channel ?? null;
1451
+ if (channel && typeof channel.send === 'function') {
1452
+ try {
1453
+ await channel.send(JSON.stringify({ type: 'result.ack', task_id: frame.task_id }));
1454
+ } catch (error) {
1455
+ // eslint-disable-next-line require-atomic-updates -- the channel is per-frame and the ack is the last await
1456
+ runtime.events.last_error = `P2P_ACK_FAILED: ${error instanceof Error ? error.message : String(error)}`;
1457
+ }
1458
+ }
1459
+ }
1460
+
1461
+ /**
1462
+ * The `p2p` block `w2m_status` reports.
1463
+ *
1464
+ * Every field is present on every call, including when the direct path is off: a reader must be able
1465
+ * to tell "relay mode" from "p2p was requested and could not start" without comparing two calls.
1466
+ * `null` is used for "never measured", never 0 — the same rule the rest of this file follows for RTT.
1467
+ *
1468
+ * @param {ReturnType<typeof readConfig>} cfg
1469
+ * @param {object} runtime
1470
+ * @param {number|null} peersAnnounced Live announcements the relay holds, read from the `/healthz`
1471
+ * answer this tool already fetched. Passed in rather than probed here: a second probe of the same
1472
+ * endpoint for one line of a diagnostic block would double the tool's traffic and could report a
1473
+ * different number than the block above it.
1474
+ * @returns {Promise<object>}
1475
+ */
1476
+ async function readP2PStatus(cfg, runtime, peersAnnounced = null) {
1477
+ const status = runtime.node === null ? null : runtime.node.status;
1478
+ const inboxDepth = runtime.inboxDir === null ? 0 : await countInbox(runtime.inboxDir);
1479
+
1480
+ return {
1481
+ mode: status?.mode ?? cfg.p2pMode,
1482
+ // "Requested and possible": the mode is not `relay` and a node exists to be asked about.
1483
+ enabled: status !== null,
1484
+ running: status?.running === true,
1485
+ machine_id: runtime.machineId,
1486
+ device_json: runtime.devicePath,
1487
+ device_error: runtime.identityError,
1488
+ local: status?.local ?? null,
1489
+ rabbit_url: status?.rabbit_url ?? cfg.rabbitUrl,
1490
+ // How many machines the relay holds a live announcement for. `w2m_devices` says which machines
1491
+ // paired; this says which of them can be punched at, and the two differ constantly.
1492
+ peers_announced: peersAnnounced,
1493
+ mapping: status?.mapping ?? null,
1494
+ reflexive: status?.reflexive ?? null,
1495
+ candidates: Array.isArray(status?.candidates) ? status.candidates : [],
1496
+ candidate_count: Array.isArray(status?.candidates) ? status.candidates.length : 0,
1497
+ announced_at: status?.announced_at ?? null,
1498
+ announce_ok: status?.announce_ok ?? false,
1499
+ announce_failures: status?.announce_failures ?? 0,
1500
+ last_announce_error: status?.last_announce_error ?? null,
1501
+ punches_out: status?.punches_out ?? 0,
1502
+ punches_in: status?.punches_in ?? 0,
1503
+ dial_failures: status?.dial_failures ?? 0,
1504
+ transport: p2pTransportFacts(runtime.node),
1505
+ inbox: {
1506
+ dir: runtime.inboxDir,
1507
+ depth: inboxDepth,
1508
+ received: runtime.events.results,
1509
+ frames: runtime.events.frames,
1510
+ refused: runtime.events.refused.slice(-8),
1511
+ last_at: runtime.events.last_at ?? null,
1512
+ last_path: runtime.events.inbox_path,
1513
+ mode_honoured: runtime.events.mode_honoured,
1514
+ },
1515
+ last_error: runtime.error ?? runtime.events.last_error ?? status?.last_error ?? null,
1516
+ reason: runtime.reason,
1517
+ // Whether the node has been started in this process yet. `running` alone cannot say: a node that
1518
+ // has not been asked to start and a node that failed to start both report `false`.
1519
+ started: runtime.start !== null,
1520
+ };
1521
+ }
1522
+
1523
+ /**
1524
+ * Type facts about the direct path: whether the UDP socket is actually held.
1525
+ *
1526
+ * Stated as a fact rather than inferred from `running`, because binding a socket and *keeping* one
1527
+ * are different claims: this reads the live socket object, so "no socket" cannot be reported as
1528
+ * "fine, no UDP port was taken" by accident.
1529
+ *
1530
+ * @param {P2PNode|null} node
1531
+ * @returns {object}
1532
+ */
1533
+ function p2pTransportFacts(node) {
1534
+ const socket = node?.socket ?? null;
1535
+ if (socket === null) return { socket_bound: false, family: null, address: null };
1536
+ let bound = null;
1537
+ try {
1538
+ bound = socket.address();
1539
+ } catch {
1540
+ bound = null;
1541
+ }
1542
+ return {
1543
+ socket_bound: true,
1544
+ family: bound?.family ?? null,
1545
+ address: bound === null ? null : { address: bound.address, port: bound.port },
1546
+ };
1547
+ }
1548
+
1549
+ /**
1550
+ * Require the state directory, naming the setting when it is absent.
1551
+ *
1552
+ * Used by the paths that genuinely cannot work without a `device.json - see {@link resolveToken},
1553
+ * which is the only caller, and which explains why an unset `stateDir` is not an error there.
1554
+ *
1555
+ * @param {{stateDir: string|null}} cfg
1556
+ * @returns {string}
1557
+ */
1558
+ function requireStateDir(cfg) {
1559
+ if (!cfg.stateDir) {
1560
+ throw configError(
1561
+ 'stateDir',
1562
+ 'is not set, so device.json cannot be located',
1563
+ 'set `stateDir` to the directory holding device.json (the agent writes it there after pairing)',
1564
+ );
1565
+ }
1566
+ return cfg.stateDir;
1567
+ }
1568
+
1569
+ /**
1570
+ * Require the operator token, naming the setting when it is absent (PROTOCOL-v0.1.2 §5).
1571
+ *
1572
+ * `POST /v1/task` is the one endpoint that authorises with the operator token rather than the
1573
+ * device token. There is deliberately **no fallback** to the device token: a relay that has the
1574
+ * requirement enabled answers such an attempt with `401 OPERATOR_REQUIRED`, and a plugin that
1575
+ * quietly tried the device token first would turn a missing setting into a confusing remote
1576
+ * refusal. Failing here names the setting the operator has to set.
1577
+ *
1578
+ * @param {{operatorToken: string|null}} cfg
1579
+ * @returns {string}
1580
+ */
1581
+ function requireOperatorToken(cfg) {
1582
+ if (cfg.operatorToken) return cfg.operatorToken;
1583
+ throw configError(
1584
+ 'operatorToken',
1585
+ 'is not set, so this tool cannot dispatch a task - a device token cannot dispatch tasks; ' +
1586
+ '`POST /v1/task` requires the operator token',
1587
+ 'set `operatorToken` in this plugin\'s profile patch (or `W2M_OPERATOR_TOKEN` in the environment) ' +
1588
+ 'to the value the relay printed at startup, or read it from `<state>/operator-token.txt`',
1589
+ );
1590
+ }
1591
+
1592
+ // ---------------------------------------------------------------------------------------------
1593
+ // Base URL and endpoint joining (PROTOCOL-v0.1.2 §2)
1594
+ // ---------------------------------------------------------------------------------------------
1595
+
1596
+ /**
1597
+ * Validate a `rabbitUrl` and reduce it to a base address, as a plugin-shaped error.
1598
+ *
1599
+ * The rule itself lives in `../agent/url.mjs` (imported above) so both sides cannot drift. This
1600
+ * wrapper exists only to translate its `TypeError` into the `W2M_CONFIG` error every other
1601
+ * configuration fault in this file uses, whose message names the setting **and** what to d - the
1602
+ * caller is a model reading tool output, not a developer reading a stack trace.
1603
+ *
1604
+ * @param {unknown} raw
1605
+ * @returns {string} Base with every trailing slash removed.
1606
+ */
1607
+ function checkedRabbitBase(raw) {
1608
+ try {
1609
+ return resolveBaseUrl(String(raw ?? ''));
1610
+ } catch (error) {
1611
+ const reason = error instanceof Error ? error.message : String(error);
1612
+ // The shared validator reports a bad scheme as "got ftp://"; naming a "scheme" is friendlier to
1613
+ // whoever has to act on it, so the plugin-shaped message says the word.
1614
+ const scheme = /got\s+([a-z][a-z0-9+.-]*:)/i.exec(reason);
1615
+ const why = scheme
1616
+ ? `uses scheme \`${scheme[1]}\`, which is not supported (only http and https are)`
1617
+ : `is not usable as a base addres - ${reason}`;
1618
+ throw configError(
1619
+ 'rabbitUrl',
1620
+ why,
1621
+ 'set `rabbitUrl` to the relay base address, for example http://127.0.0.1:8787, ' +
1622
+ 'http://100.64.0.5:8787, or https://w2m.example.com/w2m (a deployment sub-path is allowed; ' +
1623
+ 'a query string or fragment is not)',
1624
+ );
1625
+ }
1626
+ }
1627
+
1628
+ /**
1629
+ * Build a `URL` from a joined string, for `fetch`.
1630
+ *
1631
+ * The joining already produced an absolute URL; this only gives `fetch` the object it prefers, and
1632
+ * turns "not absolute after all" into a named configuration error instead of `Invalid URL`.
1633
+ *
1634
+ * @param {string} url
1635
+ * @returns {URL}
1636
+ */
1637
+ function toRequestUrl(url) {
1638
+ try {
1639
+ return new URL(url);
1640
+ } catch (error) {
1641
+ throw new W2MError('W2M_CONFIG', `W2M_CONFIG: the joined endpoint \`${url}\` is not an absolute URL (${error instanceof Error ? error.message : String(error)})`, {
1642
+ hint: 'set `rabbitUrl` to an absolute base such as https://w2m.example.com/w2m',
1643
+ cause: error,
1644
+ });
1645
+ }
1646
+ }
1647
+
1648
+ // ---------------------------------------------------------------------------------------------
1649
+ // Agent RTT (PROTOCOL-v0.1.2 §8.2)
1650
+ // ---------------------------------------------------------------------------------------------
1651
+
1652
+ /**
1653
+ * A status getter reachable from this process, when the plugin started relay+agent itself.
1654
+ *
1655
+ * Set by {@link startLocalStack}. Null in the normal case, where the agent runs as its own process
1656
+ * and its state is not reachable from here at al - see {@link readRtt}.
1657
+ *
1658
+ * @type {(() => object)|null}
1659
+ */
1660
+ let inProcessAgentStatus = null;
1661
+
1662
+ /**
1663
+ * File names an agent may publish its state under, most specific first.
1664
+ *
1665
+ * The `localside/` entry is not decoration: `stateDir` here is documented as the
1666
+ * directory holding `device.json`, and `w2m-localside` keeps its own state one
1667
+ * level below it (`<DSH_HOME>/xclient/localside`, see `resolveStateDir(_, 'localside')`).
1668
+ * Without this entry the default CLI layout could never be read, and RTT would
1669
+ * silently stay unavailable in exactly the cross-region deployment it is for.
1670
+ */
1671
+ const AGENT_STATE_FILES = ['agent-state.json', 'agent.json', 'localside-state.json', path.join('localside', 'agent-state.json')];
1672
+
1673
+ /**
1674
+ * Normalise whatever the agent published into `{last, avg, samples}`.
1675
+ *
1676
+ * Tolerant on purpose: this is a cross-process, cross-version boundary, and a reader that demanded
1677
+ * one exact spelling would report "no RTT" for a perfectly healthy agent. Field aliases cover the
1678
+ * obvious spellings; an array is read as the rolling samples; a bare number is read as the last.
1679
+ *
1680
+ * @param {unknown} rtt
1681
+ * @returns {{last: number|null, avg: number|null, samples: number[]}|null}
1682
+ */
1683
+ function normaliseRtt(rtt) {
1684
+ if (rtt === null || rtt === undefined) return null;
1685
+
1686
+ if (typeof rtt === 'number' && Number.isFinite(rtt)) {
1687
+ return { last: rtt, avg: rtt, samples: [rtt] };
1688
+ }
1689
+
1690
+ if (Array.isArray(rtt)) {
1691
+ const samples = rtt.filter((value) => typeof value === 'number' && Number.isFinite(value));
1692
+ if (samples.length === 0) return null;
1693
+ return { last: samples[samples.length - 1], avg: round(samples.reduce((a, b) => a + b, 0) / samples.length), samples };
1694
+ }
1695
+
1696
+ if (typeof rtt !== 'object') return null;
1697
+
1698
+ const pick = (...keys) => {
1699
+ for (const key of keys) {
1700
+ const value = rtt[key];
1701
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
1702
+ }
1703
+ return null;
1704
+ };
1705
+ const rawSamples = Array.isArray(rtt.samples)
1706
+ ? rtt.samples.filter((value) => typeof value === 'number' && Number.isFinite(value))
1707
+ : [];
1708
+
1709
+ const last = pick('last', 'lastMs', 'last_ms', 'current');
1710
+ const avg = pick('avg', 'average', 'mean');
1711
+ if (last === null && avg === null && rawSamples.length === 0) return null;
1712
+
1713
+ const samples = rawSamples.length > 0 ? rawSamples : [last ?? avg];
1714
+ return { last: last ?? samples[samples.length - 1], avg: avg ?? round(samples.reduce((a, b) => a + b, 0) / samples.length), samples };
1715
+ }
1716
+
1717
+ /**
1718
+ * Round to one decimal: RTT is a diagnostic, and false precision invites false conclusions.
1719
+ *
1720
+ * @param {number} value
1721
+ * @returns {number}
1722
+ */
1723
+ function round(value) {
1724
+ return Math.round(value * 10) / 10;
1725
+ }
1726
+
1727
+ /**
1728
+ * Read the local agent's round-trip time, or explain why it is unavailable (§8.2).
1729
+ *
1730
+ * §8.2 puts `rttMs` on the agent's in-memory state. When the plugin started the agent inside this
1731
+ * process, that state is reachable directly. When the agent is its own proces - the normal
1732
+ * deploymen - it is not, so this also looks for a state file under `stateDir`. Neither source
1733
+ * being present is reported as `available: false` with a reason, never as an error: cross-region
1734
+ * diagnosis fails loudly, but this tool must still answer when the agent has not been started.
1735
+ *
1736
+ * @param {{stateDir: string|null}} cfg
1737
+ * @returns {Promise<{available: boolean, source: string|null, last: number|null, avg: number|null,
1738
+ * samples: number[], ms: number|null, reason: string|null, path: string|null}>}
1739
+ */
1740
+ async function readRtt(cfg) {
1741
+ const empty = {
1742
+ available: false,
1743
+ source: null,
1744
+ last: null,
1745
+ avg: null,
1746
+ samples: [],
1747
+ ms: null,
1748
+ reason: null,
1749
+ path: null,
1750
+ };
1751
+
1752
+ if (inProcessAgentStatus) {
1753
+ try {
1754
+ const status = inProcessAgentStatus();
1755
+ const rtt = normaliseRtt(status?.rttMs ?? status?.rtt ?? null);
1756
+ if (rtt) return { ...empty, ...rtt, available: true, source: 'in-process-agent' };
1757
+ return { ...empty, source: 'in-process-agent', reason: 'the in-process agent has not recorded a round trip yet' };
1758
+ } catch (error) {
1759
+ return {
1760
+ ...empty,
1761
+ source: 'in-process-agent',
1762
+ reason: `the in-process agent status getter threw: ${error instanceof Error ? error.message : String(error)}`,
1763
+ };
1764
+ }
1765
+ }
1766
+
1767
+ if (!cfg.stateDir) {
1768
+ return { ...empty, reason: 'stateDir is not set, so no agent state could be located' };
1769
+ }
1770
+
1771
+ for (const name of AGENT_STATE_FILES) {
1772
+ const file = path.join(cfg.stateDir, name);
1773
+ let text;
1774
+ try {
1775
+ text = await fs.readFile(file, 'utf8');
1776
+ } catch {
1777
+ continue;
1778
+ }
1779
+ try {
1780
+ const parsed = JSON.parse(text);
1781
+ const rtt = normaliseRtt(parsed?.rttMs ?? parsed?.rtt ?? null);
1782
+ if (rtt) return { ...empty, ...rtt, available: true, source: 'state-file', path: file };
1783
+ } catch {
1784
+ continue;
1785
+ }
1786
+ }
1787
+
1788
+ return {
1789
+ ...empty,
1790
+ reason:
1791
+ 'no agent round-trip time is available: the agent is not running in this process and no ' +
1792
+ `agent state file was found under ${cfg.stateDir}`,
1793
+ };
1794
+ }
1795
+
1796
+
1797
+ /**
1798
+ * Read a response body, refusing to buffer more than `maxBytes`.
1799
+ *
1800
+ * A body with no stream falls back to `arrayBuffer()`, which keeps the function usable with bare
1801
+ * response-like stubs.
1802
+ *
1803
+ * @param {Response} response
1804
+ * @param {number} maxBytes
1805
+ * @returns {Promise<string>}
1806
+ */
1807
+ async function readBody(response, maxBytes) {
1808
+ const declared = Number(response.headers?.get?.('content-length') ?? Number.NaN);
1809
+ if (Number.isFinite(declared) && declared > maxBytes) {
1810
+ throw new W2MError('W2M_TOO_LARGE', `W2M_TOO_LARGE: the Rabbit answered with ${declared} bytes, above the ${maxBytes}-byte cap`, {
1811
+ hint: 'ask for a narrower range (for example a smaller `limit` or `format: json`), or read the report from the Rabbit directly',
1812
+ });
1813
+ }
1814
+
1815
+ if (!response.body || typeof response.body.getReader !== 'function') {
1816
+ const buffer = Buffer.from(await response.arrayBuffer());
1817
+ if (buffer.byteLength > maxBytes) {
1818
+ throw new W2MError('W2M_TOO_LARGE', `W2M_TOO_LARGE: the Rabbit answered with ${buffer.byteLength} bytes, above the ${maxBytes}-byte cap`);
1819
+ }
1820
+ return buffer.toString('utf8');
1821
+ }
1822
+
1823
+ const reader = response.body.getReader();
1824
+ const chunks = [];
1825
+ let size = 0;
1826
+ try {
1827
+ for (;;) {
1828
+ const { done, value } = await reader.read();
1829
+ if (done) break;
1830
+ if (value && value.byteLength) {
1831
+ size += value.byteLength;
1832
+ if (size > maxBytes) {
1833
+ throw new W2MError('W2M_TOO_LARGE', `W2M_TOO_LARGE: the Rabbit's answer passed the ${maxBytes}-byte cap and was stopped`, {
1834
+ hint: 'ask for a narrower range, or read the report from the Rabbit directly',
1835
+ });
1836
+ }
1837
+ chunks.push(Buffer.from(value));
1838
+ }
1839
+ }
1840
+ } finally {
1841
+ try {
1842
+ await reader.cancel();
1843
+ } catch {
1844
+ // A reader that cannot be cancelled is not a reason to lose the result we already have.
1845
+ }
1846
+ }
1847
+ return Buffer.concat(chunks).toString('utf8');
1848
+ }
1849
+
1850
+ /**
1851
+ * Parse the protocol's error shape: `{ error: { code, message, detail } }` (PROTOCOL §7).
1852
+ *
1853
+ * @param {string} text
1854
+ * @returns {{code: string, message: string, detail: unknown}|null}
1855
+ */
1856
+ function parseErrorBody(text) {
1857
+ try {
1858
+ const parsed = JSON.parse(text);
1859
+ const error = parsed?.error;
1860
+ if (error && typeof error === 'object' && typeof error.code === 'string') {
1861
+ return { code: error.code, message: String(error.message ?? ''), detail: error.detail };
1862
+ }
1863
+ } catch {
1864
+ // Not JSON, or not our shape; fall through to the generic message.
1865
+ }
1866
+ return null;
1867
+ }
1868
+
1869
+ // ---------------------------------------------------------------------------------------------
1870
+ // HTTP
1871
+ // ---------------------------------------------------------------------------------------------
1872
+
1873
+ /**
1874
+ * Perform one request against the Rabbit.
1875
+ *
1876
+ * Resolves for every HTTP status, including 4xx and 5xx: the Rabbit's refusals are answers, and
1877
+ * the code table in PROTOCOL §7 is part of the protocol, not an exceptional path. Rejects only
1878
+ * when the call could not be made at all (transport, abort, size cap).
1879
+ *
1880
+ * @param {{rabbitUrl: string, token?: string|null, signal?: AbortSignal, timeoutMs?: number,
1881
+ * method?: string, pathname: string, query?: object, body?: unknown,
1882
+ * maxBytes?: number, accept?: string}} options
1883
+ * @returns {Promise<{status: number, ok: boolean, text: string, json: object|null, error: object|null}>}
1884
+ */
1885
+ async function request(options) {
1886
+ const {
1887
+ rabbitUrl,
1888
+ token = null,
1889
+ signal,
1890
+ timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
1891
+ method = 'GET',
1892
+ pathname,
1893
+ query,
1894
+ body,
1895
+ maxBytes = MAX_RESPONSE_BYTES,
1896
+ accept = 'application/json',
1897
+ } = options;
1898
+
1899
+ throwIfAborted(signal);
1900
+
1901
+ // Named here rather than left to `new URL`, whose failure would say only "Invalid URL" and send
1902
+ // the reader looking in the wrong place.
1903
+ if (typeof rabbitUrl !== 'string' || rabbitUrl === '') {
1904
+ throw new W2MError('W2M_CONFIG', 'W2M_CONFIG: no Rabbit URL was available for this call', {
1905
+ hint: 'set `rabbitUrl` in this plugin\'s profile patch, for example http://127.0.0.1:8787',
1906
+ });
1907
+ }
1908
+
1909
+ // §2: base + path, joined by hand. `new URL(pathname, base)` would resolve against the origin
1910
+ // and drop a deployment sub-path (`https://h/w2m` + `/v1/task` 鈫?`https://h/v1/task` 鈫?404).
1911
+ const target = toRequestUrl(joinUrl(rabbitUrl, pathname));
1912
+ for (const [key, value] of Object.entries(query ?? {})) {
1913
+ if (value !== undefined && value !== null) target.searchParams.set(key, String(value));
1914
+ }
1915
+
1916
+ const headers = { accept };
1917
+ if (token) headers.authorization = `Bearer ${token}`;
1918
+ if (body !== undefined) headers['content-type'] = 'application/json';
1919
+
1920
+ const timeoutController = new AbortController();
1921
+ const timer = setTimeout(() => timeoutController.abort(new Error('timeout')), timeoutMs);
1922
+ const combined = combineSignals(signal, timeoutController.signal);
1923
+
1924
+ let response;
1925
+ try {
1926
+ response = await fetch(target, {
1927
+ method,
1928
+ headers,
1929
+ body: body === undefined ? undefined : JSON.stringify(body),
1930
+ signal: combined.signal,
1931
+ });
1932
+ } catch (error) {
1933
+ const aborted = isAbort(error, combined.signal);
1934
+ throw new W2MError(
1935
+ aborted ? 'W2M_ABORTED' : 'W2M_UNREACHABLE',
1936
+ aborted
1937
+ ? `W2M_ABORTED: the call to ${endpointOf(target)} was cancelled after ${timeoutMs} ms or by the caller`
1938
+ : `W2M_UNREACHABLE: could not reach the Rabbit at ${endpointOf(target)}: ${error instanceof Error ? error.message : String(error)}`,
1939
+ {
1940
+ hint: 'check that the Rabbit is running and that `rabbitUrl` points at it (w2m_status probes it)',
1941
+ cause: error,
1942
+ },
1943
+ );
1944
+ } finally {
1945
+ clearTimeout(timer);
1946
+ combined.dispose();
1947
+ }
1948
+
1949
+ const text = await readBody(response, maxBytes);
1950
+ const contentType = response.headers?.get?.('content-type') ?? '';
1951
+ let json = null;
1952
+ if (text.trim() !== '' && (contentType.includes('json') || /^[[{]/.test(text.trim()))) {
1953
+ try {
1954
+ json = JSON.parse(text);
1955
+ } catch {
1956
+ json = null;
1957
+ }
1958
+ }
1959
+
1960
+ const error = response.ok ? null : parseErrorBody(text);
1961
+ if (!error && !response.ok) {
1962
+ return {
1963
+ status: response.status,
1964
+ ok: false,
1965
+ text,
1966
+ json,
1967
+ error: { code: 'HTTP_ERROR', message: text.trim().slice(0, 400) || `HTTP ${response.status}`, detail: null },
1968
+ };
1969
+ }
1970
+
1971
+ return { status: response.status, ok: response.ok, text, json, error };
1972
+ }
1973
+
1974
+ /**
1975
+ * Render a URL for a message, without its query string, so nothing sensitive is echoed back.
1976
+ *
1977
+ * @param {URL} target
1978
+ * @returns {string}
1979
+ */
1980
+ function endpointOf(target) {
1981
+ return `${target.origin}${target.pathname}`;
1982
+ }
1983
+
1984
+ /**
1985
+ * Resolve the bearer token for the Rabbit calls.
1986
+ *
1987
+ * Two sources, and the distinction is the point. An explicit `stateDir` is the operator pointing at
1988
+ * a pairing, so a missing or unreadable `device.json` there is **named as the fault** rather than
1989
+ * being turned into a mystery `401`. With no `stateDir`, this machine's own identity lives where the
1990
+ * agent writes it by default — `$DSH_HOME/xclient/device.json`, the same file {@link readLocalIdentity}
1991
+ * reads — and the calls go out with it. v0.4.0 made that fallback load-bearing rather than optional:
1992
+ * every P2P announcement is authenticated as the announcing device, so a machine whose identity was
1993
+ * only reachable through `DSH_HOME` would have had a permanently unauthenticated rendezvous and a
1994
+ * direct path that never worked, while the relay path kept working and hid the reason.
1995
+ *
1996
+ * A machine with no identity at all still answers `null`: the calls go out unauthenticated, a `401`
1997
+ * is reported as a refusal, and that is the accurate account of what happened.
1998
+ *
1999
+ * @param {ReturnType<typeof readConfig>} cfg
2000
+ * @returns {Promise<string|null>}
2001
+ */
2002
+ async function resolveToken(cfg) {
2003
+ if (cfg.stateDir) return requireToken(await readDevice({ stateDir: requireStateDir(cfg) }));
2004
+ const file = devicePathFor(cfg);
2005
+ if (file === null) return null;
2006
+ const device = await readDevice({ stateDir: path.dirname(file) });
2007
+ return device.present ? requireToken(device) : null;
2008
+ }
2009
+
2010
+ // ---------------------------------------------------------------------------------------------
2011
+ // Device identity
2012
+ // ---------------------------------------------------------------------------------------------
2013
+
2014
+ /**
2015
+ * Read `device.json` from the state directory (PROTOCOL §2.1).
2016
+ *
2017
+ * @param {{stateDir: string|null}} cfg
2018
+ * @returns {Promise<{machine_id: string|null, machine_name: string|null, device_token: string|null,
2019
+ * rabbit_url: string|null, path: string|null, present: boolean, error: string|null}>}
2020
+ */
2021
+ async function readDevice(cfg) {
2022
+ const empty = {
2023
+ machine_id: null,
2024
+ machine_name: null,
2025
+ device_token: null,
2026
+ rabbit_url: null,
2027
+ path: null,
2028
+ present: false,
2029
+ error: null,
2030
+ };
2031
+ if (!cfg.stateDir) return empty;
2032
+
2033
+ const file = path.join(cfg.stateDir, 'device.json');
2034
+ let text;
2035
+ try {
2036
+ text = await fs.readFile(file, 'utf8');
2037
+ } catch (error) {
2038
+ if (error && error.code === 'ENOENT') return { ...empty, path: file };
2039
+ return { ...empty, path: file, error: error instanceof Error ? error.message : String(error) };
2040
+ }
2041
+
2042
+ try {
2043
+ const parsed = JSON.parse(text);
2044
+ return {
2045
+ machine_id: typeof parsed?.machine_id === 'string' ? parsed.machine_id : null,
2046
+ machine_name: typeof parsed?.machine_name === 'string' ? parsed.machine_name : null,
2047
+ device_token: typeof parsed?.device_token === 'string' && parsed.device_token !== '' ? parsed.device_token : null,
2048
+ rabbit_url: typeof parsed?.rabbit_url === 'string' ? parsed.rabbit_url : null,
2049
+ path: file,
2050
+ present: true,
2051
+ error: null,
2052
+ };
2053
+ } catch (error) {
2054
+ return { ...empty, path: file, present: true, error: `device.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}` };
2055
+ }
2056
+ }
2057
+
2058
+ /**
2059
+ * Require a `device_token`, naming where it was looked for when it is absent.
2060
+ *
2061
+ * @param {{machine_id: string|null, device_token: string|null, path: string|null, present: boolean, error: string|null}} device
2062
+ * @returns {string}
2063
+ */
2064
+ function requireToken(device) {
2065
+ if (device.device_token) return device.device_token;
2066
+ if (!device.present) {
2067
+ throw new W2MError(
2068
+ 'W2M_NO_TOKEN',
2069
+ `W2M_NO_TOKEN: no paired device at \`${device.path ?? 'device.json'}\` - the file does not exist; ` +
2070
+ 'start the agent once so it can pair, or set `stateDir` to the directory holding an already-paired device.json',
2071
+ { hint: 'start the agent once so it can pair, or set `stateDir` to the directory holding an already-paired device.json' },
2072
+ );
2073
+ }
2074
+ if (device.error) {
2075
+ throw new W2MError(
2076
+ 'W2M_NO_TOKEN',
2077
+ `W2M_NO_TOKEN: could not read a device token from \`${device.path}\` - ${device.error}; repair or delete the file and pair again`,
2078
+ { hint: 'repair or delete the file and pair again' },
2079
+ );
2080
+ }
2081
+ throw new W2MError(
2082
+ 'W2M_NO_TOKEN',
2083
+ `W2M_NO_TOKEN: \`${device.path}\` has no \`device_token\` - the device has not completed POST /v1/pair yet; run the agent once to pair it`,
2084
+ { hint: 'the device has not completed POST /v1/pair yet; run the agent once to pair it' },
2085
+ );
2086
+ }
2087
+
2088
+ // ---------------------------------------------------------------------------------------------
2089
+ // git anchors
2090
+ // ---------------------------------------------------------------------------------------------
2091
+
2092
+ /**
2093
+ * Run `git` and return its exit code and output, never throwing for a non-zero exit.
2094
+ *
2095
+ * @param {string[]} argv Arguments after the executable.
2096
+ * @param {{cwd?: string, env?: object, timeoutMs?: number}} [options]
2097
+ * @returns {Promise<{code: number|null, stdout: string, stderr: string}>}
2098
+ */
2099
+ function runGit(argv, options = {}) {
2100
+ const { cwd, env, timeoutMs = 10_000 } = options;
2101
+ return new Promise((resolve) => {
2102
+ execFile(
2103
+ process.env.W2M_GIT || 'git',
2104
+ argv,
2105
+ { cwd, env, timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024, windowsHide: true, encoding: 'utf8' },
2106
+ (error, stdout, stderr) => {
2107
+ resolve({
2108
+ code: error ? (typeof error.code === 'number' ? error.code : null) : 0,
2109
+ stdout: stdout ?? '',
2110
+ stderr: stderr ?? (error && error.message ? error.message : ''),
2111
+ });
2112
+ },
2113
+ );
2114
+ });
2115
+ }
2116
+
2117
+ /**
2118
+ * Resolve the project's three anchors' worth of git state.
2119
+ *
2120
+ * `base_commit` is `HEAD`. `base_tree` is the tree of the **working tree as it stands**, which is
2121
+ * what a peer's `pre_tree_fingerprint` can actually be equal t - the tree of `HEAD` would report
2122
+ * every uncommitted change as an anchor mismatch. It is computed the way PROTOCOL §5.1 names the
2123
+ * algorithm, `git-temp-index-tree/v1`, so this machine's answer and a peer's are comparable.
2124
+ *
2125
+ * @param {{projectDir: string, timeoutMs?: number}} options
2126
+ * @returns {Promise<{base_commit: string|null, base_tree: string|null, dirty: boolean|null,
2127
+ * branch: string|null, head_commit: string|null, error: string|null}>}
2128
+ */
2129
+ async function resolveGitAnchors(options) {
2130
+ const { projectDir, timeoutMs = 10_000 } = options;
2131
+ const result = { base_commit: null, base_tree: null, dirty: null, branch: null, head_commit: null, error: null };
2132
+
2133
+ const inRepo = await runGit(['rev-parse', '--is-inside-work-tree'], { cwd: projectDir, timeoutMs });
2134
+ if (inRepo.code !== 0 || inRepo.stdout.trim() !== 'true') {
2135
+ result.error = inRepo.code !== 0
2136
+ ? `\`${projectDir}\` is not a git work tree (${(inRepo.stderr.trim() || 'no diagnostic').slice(0, 200)})`
2137
+ : 'not a git work tree';
2138
+ return result;
2139
+ }
2140
+
2141
+ const head = await runGit(['rev-parse', 'HEAD'], { cwd: projectDir, timeoutMs });
2142
+ if (head.code === 0) {
2143
+ result.base_commit = head.stdout.trim();
2144
+ result.head_commit = result.base_commit;
2145
+ } else {
2146
+ result.error = (head.stderr.trim() || 'HEAD could not be resolved').slice(0, 200);
2147
+ }
2148
+
2149
+ const branch = await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectDir, timeoutMs });
2150
+ if (branch.code === 0) result.branch = branch.stdout.trim();
2151
+
2152
+ const status = await runGit(['status', '--porcelain'], { cwd: projectDir, timeoutMs });
2153
+ if (status.code === 0) result.dirty = status.stdout.trim() !== '';
2154
+
2155
+ const tree = await resolveTreeFingerprint(projectDir, timeoutMs);
2156
+ result.base_tree = tree.value;
2157
+ if (!tree.value && !result.error) result.error = tree.error;
2158
+
2159
+ return result;
2160
+ }
2161
+
2162
+ /**
2163
+ * `git-temp-index-tree/v1`: hash the working tree through a throwaway index.
2164
+ *
2165
+ * A temporary `GIT_INDEX_FILE` means the real index is never touched, so asking for the anchor
2166
+ * cannot itself dirty the repository we are about to measure.
2167
+ *
2168
+ * @param {string} projectDir
2169
+ * @param {number} timeoutMs
2170
+ * @returns {Promise<{value: string|null, error: string|null}>}
2171
+ */
2172
+ async function resolveTreeFingerprint(projectDir, timeoutMs) {
2173
+ // mkdtemp rather than a name in the project: nothing is left behind if we are killed.
2174
+ const scratch = await fs.mkdtemp(path.join(os.tmpdir(), 'w2m-index-'));
2175
+ const indexPath = path.join(scratch, 'index');
2176
+ const env = { ...process.env, GIT_INDEX_FILE: indexPath };
2177
+ try {
2178
+ const added = await runGit(['add', '-A', '--', '.'], { cwd: projectDir, env, timeoutMs });
2179
+ if (added.code !== 0) {
2180
+ return { value: null, error: `git add for the tree fingerprint failed: ${(added.stderr.trim() || 'no diagnostic').slice(0, 200)}` };
2181
+ }
2182
+ const written = await runGit(['write-tree'], { cwd: projectDir, env, timeoutMs });
2183
+ if (written.code !== 0) {
2184
+ return { value: null, error: `git write-tree failed: ${(written.stderr.trim() || 'no diagnostic').slice(0, 200)}` };
2185
+ }
2186
+ return { value: written.stdout.trim() || null, error: null };
2187
+ } finally {
2188
+ await fs.rm(scratch, { recursive: true, force: true }).catch(() => {});
2189
+ }
2190
+ }
2191
+
2192
+ // ---------------------------------------------------------------------------------------------
2193
+ // Command policy
2194
+ // ---------------------------------------------------------------------------------------------
2195
+
2196
+ /**
2197
+ * The comparable form of an **executable token**: basename, lower case, no `.exe`/`.cmd`/`.bat`/`.com`.
2198
+ *
2199
+ * Applied to `argv[0]` and to the first token of an entry, never to the arguments after it --
2200
+ * `--reporter=TAP` and `--reporter=tap` are different arguments, and collapsing them would authorise
2201
+ * something nobody wrote.
2202
+ *
2203
+ * This is the plugin's long-documented tolerance (`allowedCommands: ['Node.exe']` accepts
2204
+ * `C:\Program Files\nodejs\node.exe`), and it is deliberately platform-independent: the old check
2205
+ * normalised the separator itself rather than asking `path`, which is what makes the documented
2206
+ * entry work the same on a POSIX dispatcher. Everything *after* the executable token is compared
2207
+ * verbatim by the shared matcher, which is where the prefix rule lives.
2208
+ *
2209
+ * @param {string} value
2210
+ * @returns {string}
2211
+ */
2212
+ function normalizeExecToken(value) {
2213
+ const base = path.basename(String(value).trim().replace(/\\/g, '/')).toLowerCase();
2214
+ return base.replace(/\.(exe|cmd|bat|com)$/, '');
2215
+ }
2216
+
2217
+ /**
2218
+ * Check one argv against the configured allow-list, with the **same rule the agent applies**.
2219
+ *
2220
+ * Returns `null` when the call may proceed. When `allowedCommands` is unset or empty this plugin
2221
+ * does **not** refuse: the execution gate belongs to the machine that runs the command, and the
2222
+ * agent enforces its own `allowedCommands`. Refusing here on an empty list would either duplicate
2223
+ * that policy badly or hide it behind a silent allow, so the plugin only enforces what it was
2224
+ * explicitly told.
2225
+ *
2226
+ * The matcher is imported rather than re-implemented because this pre-flight exists for one reason:
2227
+ * a dispatcher must not send work the target machine would itself refuse. An executable-only
2228
+ * comparison (`argv[0]` against each entry) could never match a multi-token entry at all, so the
2229
+ * documented config `['node --test','git status --porcelain']` refused `['git','rev-parse','HEAD']`
2230
+ * -- stricter than the gate, which is the direction that silently loses work.
2231
+ *
2232
+ * @param {string[]} allowedCommands Entries as configured, e.g. `['node --test']`.
2233
+ * @param {string[]} argv
2234
+ * @returns {{code: string, message: string, allowed: string[]}|null}
2235
+ */
2236
+ function checkAllowedCommand(allowedCommands, argv) {
2237
+ if (allowedCommands.length === 0) return null;
2238
+ let prefixes;
2239
+ try {
2240
+ prefixes = parseAllowedCommands(allowedCommands);
2241
+ } catch (error) {
2242
+ // Fail closed: an unparseable list is not an empty one, and a control that cannot be evaluated
2243
+ // must refuse rather than wave the command through.
2244
+ return {
2245
+ code: 'COMMAND_NOT_ALLOWED',
2246
+ message: `the plugin's allowedCommands could not be parsed (${error?.message ?? String(error)})`,
2247
+ allowed: [],
2248
+ };
2249
+ }
2250
+ // Prefixes "as written": the configured entries, whitespace-normalised, in a stable order.
2251
+ const allowed = prefixes.map((prefix) => prefix.join(' ')).sort();
2252
+ const entries = prefixes.map((prefix) => [normalizeExecToken(prefix[0]), ...prefix.slice(1)]);
2253
+ const command = [normalizeExecToken(argv[0]), ...argv.slice(1)];
2254
+ if (matchAllowedCommand(command, entries).allowed) return null;
2255
+ return {
2256
+ code: 'COMMAND_NOT_ALLOWED',
2257
+ message: `\`${argv.join(' ')}\` is not in this plugin's allowedCommands (${allowed.join(', ')})`,
2258
+ allowed,
2259
+ };
2260
+ }
2261
+
2262
+ // ---------------------------------------------------------------------------------------------
2263
+ // Shapes shared by the tools
2264
+ // ---------------------------------------------------------------------------------------------
2265
+
2266
+ /**
2267
+ * Strip bulky fields out of an envelope, keeping the comparable ones and a bounded excerpt.
2268
+ *
2269
+ * Comparable fields are copied through in full: silently dropping one would turn a `divergent`
2270
+ * verdict into a mystery.
2271
+ *
2272
+ * @param {object} raw
2273
+ * @param {Set<string>} comparable
2274
+ * @returns {object}
2275
+ */
2276
+ function compactEnvelope(raw, comparable) {
2277
+ if (raw === null || typeof raw !== 'object') return { value: raw };
2278
+
2279
+ const compact = {};
2280
+ for (const [key, value] of Object.entries(raw)) {
2281
+ if (comparable.has(key) || key === 'status' || key === 'refusal_reason') compact[key] = value;
2282
+ }
2283
+ for (const key of ['stdout_head', 'stdout_tail', 'stderr_head', 'stderr_tail']) {
2284
+ if (typeof raw[key] === 'string') compact[key] = excerpt(raw[key]);
2285
+ }
2286
+ return compact;
2287
+ }
2288
+
2289
+ /**
2290
+ * Keep only the first `MAX_EXCERPT_BYTES` bytes of a stream.
2291
+ *
2292
+ * @param {string} value
2293
+ * @returns {string}
2294
+ */
2295
+ function excerpt(value) {
2296
+ const buffer = Buffer.from(value, 'utf8');
2297
+ if (buffer.byteLength <= MAX_EXCERPT_BYTES) return value;
2298
+ return `${buffer.subarray(0, MAX_EXCERPT_BYTES).toString('utf8')}\n - truncated at ${MAX_EXCERPT_BYTES} bytes]`;
2299
+ }
2300
+
2301
+ /**
2302
+ * Compare the Rabbit's envelope against the field list this plugin knows, and report what is new.
2303
+ *
2304
+ * PROTOCOL §5 says unknown fields must be ignored. Ignoring them is not the same as being blind
2305
+ * to them: a Rabbit that starts sending a field this plugin does not understand is a protocol
2306
+ * drift a reader needs to see, so the names are surfaced rather than dropped.
2307
+ *
2308
+ * @param {object} body
2309
+ * @param {{comparable: Set<string>, optional: Set<string>}} known
2310
+ * @returns {string[]}
2311
+ */
2312
+ function collectUnknownFields(body, known) {
2313
+ const aggregate = aggregateOf(body);
2314
+ const machines = Array.isArray(aggregate?.machines) ? aggregate.machines : [];
2315
+ const seen = new Set();
2316
+ for (const machine of machines) {
2317
+ const envelope = machine?.envelope ?? machine?.result;
2318
+ if (envelope === null || typeof envelope !== 'object') continue;
2319
+ for (const key of Object.keys(envelope)) {
2320
+ if (!known.comparable.has(key) && !known.optional.has(key)) seen.add(key);
2321
+ }
2322
+ }
2323
+ return [...seen].sort();
2324
+ }
2325
+
2326
+ /** Comparable field - mirror of PROTOCOL §5.3. Only these decide consistency. */
2327
+ const COMPARABLE_FIELDS = new Set([
2328
+ 'task_id', 'index', 'index_total', 'mode', 'cwd_rel', 'base_commit', 'base_tree',
2329
+ 'pre_tree_fingerprint', 'fingerprint_algo', 'fingerprint_error', 'head_commit', 'command_hash',
2330
+ 'shell_id', 'exit_code', 'status', 'refusal_reason', 'stdout_sha256', 'stdout_bytes',
2331
+ 'stdout_normalized_sha256', 'stderr_sha256', 'artifacts', 'diff_numstat', 'tests',
2332
+ 'semantic_counts', 'warnings',
2333
+ ]);
2334
+
2335
+ /**
2336
+ * Optional field - mirror of PROTOCOL §5.2. Carried, but never compared.
2337
+ *
2338
+ * `artifacts`, `tests`, and `semantic_counts` appear in both §5.2 and §5.3; the union is what
2339
+ * matters for the drift report, so they need naming only once, and `COMPARABLE_FIELDS` already
2340
+ * names them.
2341
+ */
2342
+ const OPTIONAL_FIELDS = new Set([
2343
+ 'stdout_head', 'stdout_tail', 'stderr_head', 'stderr_tail', 'untracked', 'toolchain',
2344
+ 'lockfiles', 'unpinned_deps', 'submodules', 'lfs', 'signal',
2345
+ // v0.4.0 §5: how the envelope travelled, and the path facts that came with it. Optional because a
2346
+ // v0.3.9 agent sends neither and must keep working unchanged; here rather than in
2347
+ // `COMPARABLE_FIELDS` because **a path is not a result** — two byte-identical runs on two machines
2348
+ // must stay `consistent` when one of them punched a hole and the other used the relay.
2349
+ 'transport', 'p2p',
2350
+ ]);
2351
+
2352
+ /**
2353
+ * Every aggregate status this plugin will accept as a verdict.
2354
+ *
2355
+ * The first six are §6.3's states. `pending` and `refused` are not verdicts on consistency, but the
2356
+ * Rabbit emits them for a task that has no verdict yet and for a task every machine refused, so
2357
+ * they are recognise - otherwise the one state that means "keep waiting" would be reported as an
2358
+ * unknown.
2359
+ *
2360
+ * `timeout`, `cancelled` and `degraded` are v0.3.0's additions, and each replaces a case that used
2361
+ * to be reported as something less true:
2362
+ *
2363
+ * timeout previously `pending` forever, so a task nobody was ever going to answer looked
2364
+ * identical to one still in flight.
2365
+ * cancelled previously indistinguishable from `failed`, which reports a deliberate stop as a
2366
+ * malfunction.
2367
+ * degraded previously `partial`, which also means "machines are still out".
2368
+ */
2369
+ const AGGREGATE_STATES = new Set([
2370
+ 'consistent', 'divergent', 'divergent-platform', 'partial', 'failed', 'unverifiable',
2371
+ 'timeout', 'cancelled', 'degraded',
2372
+ 'pending', 'refused',
2373
+ ]);
2374
+
2375
+ /**
2376
+ * Aggregate statuses that are not a verdict: the task may still move.
2377
+ *
2378
+ * `refused` is deliberately absent. It reads like "still deciding", but every machine has answered
2379
+ * and none will run the work, so waiting cannot change i - treating it as non-terminal made
2380
+ * `w2m_wait` poll a finished task until its window elapsed.
2381
+ */
2382
+ const NON_TERMINAL_AGGREGATE = new Set(['pending']);
2383
+
2384
+ /** Per-machine `outcome` values that mean the machine is still working. */
2385
+ const NON_TERMINAL_OUTCOME = new Set(['pending', null, undefined]);
2386
+
2387
+ /**
2388
+ * Find the aggregate inside a task response.
2389
+ *
2390
+ * `GET /v1/tasks/{id}` answers `{protocol_version, rabbit_time, task, leases, aggregate}`, while
2391
+ * `GET /v1/tasks/{id}/report?format=json` answers the aggregate itself. Both shapes are accepted so
2392
+ * a reader that has either one in hand can use the same normalisation.
2393
+ *
2394
+ * @param {object} body
2395
+ * @returns {object|null}
2396
+ */
2397
+ function aggregateOf(body) {
2398
+ if (body === null || typeof body !== 'object') return null;
2399
+ if (body.aggregate !== null && typeof body.aggregate === 'object') return body.aggregate;
2400
+ if (typeof body.status === 'string') return body;
2401
+ return null;
2402
+ }
2403
+
2404
+ /**
2405
+ * Normalise the aggregate the Rabbit reported.
2406
+ *
2407
+ * `known` is false for a status outside {@link AGGREGATE_STATES}: a verdict this plugin does not
2408
+ * understand is surfaced rather than trusted. `terminal` is the question the wait loop actually
2409
+ * ask - a task with no verdict yet, or with a machine whose lease is still unexpired and has not
2410
+ * reported, is not finished, and reporting it as finished would be the worst kind of quiet wrong
2411
+ * answer.
2412
+ *
2413
+ * @param {object} body A task response or a bare aggregate.
2414
+ * @returns {{state: string|null, known: boolean, terminal: boolean, states: string[], detail: object|null}}
2415
+ */
2416
+ function readAggregate(body) {
2417
+ const aggregate = aggregateOf(body);
2418
+ if (aggregate === null) {
2419
+ return { state: null, known: false, terminal: false, states: [], detail: null };
2420
+ }
2421
+
2422
+ const state = typeof aggregate.status === 'string' ? aggregate.status : null;
2423
+ const known = state !== null && AGGREGATE_STATES.has(state);
2424
+ const outcomes = Array.isArray(aggregate.machines)
2425
+ ? aggregate.machines.map((machine) => machine?.outcome ?? null)
2426
+ : [];
2427
+
2428
+ // A status this plugin does not recognise ends the wait rather than extending it: the Rabbit has
2429
+ // stopped saying "no verdict yet", so waiting cannot turn an unknown word into a known one.
2430
+ // `known: false` is what tells the caller the verdict is unreadable.
2431
+ const terminal = state !== null
2432
+ && (!known
2433
+ || (!NON_TERMINAL_AGGREGATE.has(state) && !outcomes.some((outcome) => NON_TERMINAL_OUTCOME.has(outcome))));
2434
+
2435
+ return { state, known, terminal, states: outcomes, detail: aggregate };
2436
+ }
2437
+
2438
+ // ---------------------------------------------------------------------------------------------
2439
+ // The plugin
2440
+ // ---------------------------------------------------------------------------------------------
2441
+
2442
+ /**
2443
+ * Build the short cross-region hints `w2m_status` returns alongside its data.
2444
+ *
2445
+ * These are the three questions §4 says an operator asks firs - is the relay there, has it
2446
+ * restarted, do I have the credential it want - answered in one place so a model does not have to
2447
+ * compare fields itself. v0.4.0 adds the fourth: **which** relay this machine is talking to, and
2448
+ * which path it expects a task to take.
2449
+ *
2450
+ * @param {object} relay The relay block built by `w2m_status`.
2451
+ * @param {object} rtt The block from {@link readRtt}.
2452
+ * @param {ReturnType<typeof readConfig>} cfg
2453
+ * @param {object} p2p The block from {@link readP2PStatus}.
2454
+ * @returns {string[]}
2455
+ */
2456
+ function statusNotes(relay, rtt, cfg, p2p) {
2457
+ const notes = [];
2458
+
2459
+ /**
2460
+ * §7's one-line note, and the reason it exists at all: the shared server is a *default*, and a
2461
+ * default that an operator cannot see is a machine quietly talking to somebody else's host. The
2462
+ * note names the address in use, where it came from and the exact way to override it.
2463
+ */
2464
+ if (cfg.rabbitSource === 'shared-default') {
2465
+ notes.push(
2466
+ `\`rabbitUrl\` is not set, so this machine is using the shared W2M server at ${SHARED_SERVER.rabbitUrl} ` +
2467
+ '(rabbit_source: "shared-default"); set `rabbitUrl` in this plugin\'s profile patch, or `W2M_RABBIT_URL` ' +
2468
+ 'in the environment, to point at your own relay',
2469
+ );
2470
+ } else if (cfg.rabbitSource === 'env') {
2471
+ notes.push(`\`rabbitUrl\` came from the environment (\`W2M_RABBIT_URL\`), not from the profile patch, so a profile setting would be ignored`);
2472
+ } else if (cfg.rabbitUrlOverride !== null && cfg.rabbitSource === 'config') {
2473
+ notes.push(
2474
+ '`rabbitUrl` is set in the profile patch, so it wins over `device.json`\'s own `rabbit_url` ' +
2475
+ '(the v0.3.9 fallback, which is no longer consulted)',
2476
+ );
2477
+ }
2478
+
2479
+ if (p2p.mode === 'relay') {
2480
+ notes.push('p2pMode is "relay": no UDP socket is bound on this machine and every task travels through the relay, exactly as in v0.3.9');
2481
+ } else if (p2p.running) {
2482
+ notes.push(
2483
+ `p2pMode is "${p2p.mode}" and the direct path is up on ${p2p.local ? `${p2p.local.address}:${p2p.local.port}` : '(unknown local address)'}` +
2484
+ `; ${p2p.reflexive ? `reflexive ${p2p.reflexive.address}:${p2p.reflexive.port}, NAT mapping ${p2p.mapping ?? 'unknown'}` : 'no reflexive address was measured (a LAN punch still works; a punch across the internet will fall back to the relay)'}` +
2485
+ '. A punch between two machines on this same host proves the code path, not NAT traversal.',
2486
+ );
2487
+ } else if (p2p.enabled) {
2488
+ notes.push(
2489
+ `p2pMode is "${p2p.mode}" but the direct path is not running (${p2p.last_error ?? p2p.reason ?? 'no reason recorded'}), ` +
2490
+ 'so every task is dispatched over the relay; the relay\'s offer path is unaffected',
2491
+ );
2492
+ }
2493
+
2494
+ if (p2p.enabled && p2p.inbox.depth > 0) {
2495
+ notes.push(
2496
+ `${p2p.inbox.depth} result(s) received over a direct channel are waiting in ${p2p.inbox.dir}; ` +
2497
+ 'the relay\'s copy of each result is the ledger, these are the fast-path copies',
2498
+ );
2499
+ }
2500
+
2501
+ if (relay.reachable === true && relay.relay_id) {
2502
+ notes.push(
2503
+ `relay_id \`${relay.relay_id}\` has been up ${Math.round((relay.uptime_ms ?? 0) / 1000)} - ` +
2504
+ 'record this id: a different one on a later call means the relay restarted and every machine reconnected to a new process',
2505
+ );
2506
+ } else if (relay.reachable === true && relay.relay_id === null) {
2507
+ notes.push('the relay answered /healthz but sent no relay_id, so it predates v0.1.2 and a restart cannot be detected from here');
2508
+ }
2509
+
2510
+ if (relay.reachable === true && relay.base_path !== null && cfg.rabbitUrl) {
2511
+ notes.push(`the relay reports base_path \`${relay.base_path}\`; \`rabbitUrl\` in use is \`${cfg.rabbitUrl}\``);
2512
+ }
2513
+
2514
+ if (relay.operator_token_required === true && cfg.operatorToken === null) {
2515
+ notes.push(
2516
+ 'this relay requires an operator token for POST /v1/task and none is configured, so w2m_run will refuse before sending anythin - set `operatorToken` or `W2M_OPERATOR_TOKEN`',
2517
+ );
2518
+ } else if (relay.operator_token_required === false) {
2519
+ notes.push('this relay does not require an operator token (an explicit escape hatch, not the default), so an unauthenticated dispatch can succeed');
2520
+ }
2521
+
2522
+ if (rtt.available) {
2523
+ notes.push(`round trip to the relay: last ${rtt.last} ms, average ${rtt.avg} ms over ${rtt.samples.length} sample(s) (source: ${rtt.source})`);
2524
+ } else if (rtt.reason) {
2525
+ notes.push(`round-trip time unavailable - ${rtt.reason}`);
2526
+ }
2527
+
2528
+ return notes;
2529
+ }
2530
+
2531
+ /**
2532
+ * Register the five W2M tools.
2533
+ *
2534
+ * @param {object} ctx Cordis context, carrying the `tools` service.
2535
+ * @param {object} [config] Entry configuration from the profile patch.
2536
+ * @returns {Promise<void>} Resolves once the tools are registered. `apply` is async because
2537
+ * resolving `@deepseek-ai/dsh-tools` is.
2538
+ */
2539
+ export async function apply(ctx, config = {}) {
2540
+ if (!ctx?.tools || typeof ctx.tools.register !== 'function') {
2541
+ throw new W2MError('W2M_NO_TOOLS', 'this plugin needs the `tools` service, and ctx.tools.register is missing', {
2542
+ hint: 'load the plugin in a profile that injects the `tools` service',
2543
+ });
2544
+ }
2545
+
2546
+ const cfg = readConfig(config);
2547
+ const { defineTool, usedFallback, reason } = await loadDefineTool();
2548
+ if (usedFallback) {
2549
+ ctx.logger?.warn?.(
2550
+ `w2m: @deepseek-ai/dsh-tools did not resolve (${reason}); using the offline shim. ` +
2551
+ 'Argument validation, timeouts, cancellation, and rendering will come from DSH in production.',
2552
+ );
2553
+ }
2554
+
2555
+ // §7: the direct path, owned by this plugin for the plugin's whole lifetime.
2556
+ //
2557
+ // Composed here, next to the updater's own `ctx.effect`, and for the same reason: a socket and a
2558
+ // refresh timer have to be released when the plugin is unloaded, and an effect disposer is the
2559
+ // only mechanism Cordis gives for that. Creating it never throws — a machine that cannot punch
2560
+ // still has to dispatch, and `w2m_status` is where the failure is reported.
2561
+ const p2p = createP2PRuntime(ctx, cfg, config.p2pBlock);
2562
+
2563
+ /** Text output: rendered as JSON, because every one of these tools answers with a structure. */
2564
+ const jsonOutput = {
2565
+ schema: { type: 'string' },
2566
+ render: (_args, value) => [{ type: 'text', text: value }],
2567
+ };
2568
+
2569
+ // -------------------------------------------------------------------------------------------
2570
+ // 1. w2m_device - read-only
2571
+ // -------------------------------------------------------------------------------------------
2572
+ ctx.tools.register(defineTool({
2573
+ name: 'w2m_devices',
2574
+ description:
2575
+ 'List the machines the Rabbit currently holds leases for: machine_id, name, platform, ' +
2576
+ 'capabilities, and when each was last seen. Read-only. Answers "who is online" without ' +
2577
+ 'running anything; a machine that is listed may still refuse the next command for a reason ' +
2578
+ 'the capability gate will report.',
2579
+ parameters: {
2580
+ include_stale: {
2581
+ type: 'boolean',
2582
+ description: 'Include machines whose lease looks expired rather than only the live ones. Defaults to false.',
2583
+ },
2584
+ },
2585
+ output: jsonOutput,
2586
+ async execute(args, exec) {
2587
+ throwIfAborted(exec?.signal);
2588
+ const base = requireRabbit(cfg);
2589
+ const token = await resolveToken(cfg);
2590
+ const result = await request({
2591
+ rabbitUrl: base,
2592
+ token,
2593
+ signal: exec?.signal,
2594
+ pathname: '/v1/devices',
2595
+ });
2596
+ if (!result.ok) {
2597
+ throw new W2MError('W2M_RABBIT_REFUSED', `W2M_RABBIT_REFUSED: the Rabbit refused GET /v1/devices with HTTP ${result.status}${result.error ? ` (${result.error.code}: ${result.error.message})` : ''}; ${result.status === 401 ? 'this machine is not paired, or its device_token is stal - pair the agent again' : 'check the Rabbit log for the reason it gave'}`, {
2598
+ hint: result.status === 401
2599
+ ? 'this machine is not paired, or its device_token is stale; pair the agent again'
2600
+ : 'check the Rabbit log for the reason it gave',
2601
+ });
2602
+ }
2603
+
2604
+ // `GET /v1/devices` answers `{protocol_version, rabbit_time, devices:[...]}`. Each entry is a
2605
+ // device recor - the Rabbit's own lease bookkeeping is on the task, not her - so a device
2606
+ // is described by identity and capabilities, and "online" is the honest default for a device
2607
+ // the Rabbit still lists.
2608
+ const machines = Array.isArray(result.json?.devices)
2609
+ ? result.json.devices
2610
+ : (Array.isArray(result.json?.machines) ? result.json.machines : []);
2611
+ const includeStale = args?.include_stale === true;
2612
+ const listed = machines.filter((machine) => includeStale || String(machine?.state ?? 'online') !== 'expired');
2613
+
2614
+ return JSON.stringify(
2615
+ {
2616
+ ok: true,
2617
+ protocol_version: PROTOCOL_VERSION,
2618
+ rabbit_time: result.json?.rabbit_time ?? null,
2619
+ count: listed.length,
2620
+ devices: listed.map((machine) => ({
2621
+ machine_id: machine?.machine_id ?? null,
2622
+ name: machine?.machine_name ?? machine?.name ?? null,
2623
+ os: machine?.platform?.os ?? null,
2624
+ os_version: machine?.platform?.os_version ?? null,
2625
+ arch: machine?.platform?.arch ?? null,
2626
+ shell: machine?.platform?.shell ?? null,
2627
+ caps: machine?.caps ?? null,
2628
+ last_seen: machine?.last_seen ?? machine?.last_seen_at ?? null,
2629
+ state: machine?.state ?? 'online',
2630
+ })),
2631
+ excluded_stale: includeStale ? 0 : machines.length - listed.length,
2632
+ note:
2633
+ 'a listed device has paired and is being tracked; it can still refuse the next command ' +
2634
+ 'at the capability gate, which w2m_wait reports per machine as `refused`',
2635
+ },
2636
+ null,
2637
+ 2,
2638
+ );
2639
+ },
2640
+ presentCall: () => ({ card: 'generic', title: 'w2m devices', kind: 'read', rawInput: {} }),
2641
+ }));
2642
+
2643
+ // -------------------------------------------------------------------------------------------
2644
+ // 2. w2m_ru - broadcast
2645
+ // -------------------------------------------------------------------------------------------
2646
+ ctx.tools.register(defineTool({
2647
+ name: 'w2m_run',
2648
+ description:
2649
+ 'Broadcast one command, as an argv array, to every online machine and return the task_id ' +
2650
+ 'with its per-machine leases. The command runs on each machine in its own copy of the ' +
2651
+ 'project; nothing is written unless `write` is true. Read the result with w2m_wait. ' +
2652
+ 'Anchors (base_commit, base_tree) are computed here from this machine\'s working tree so ' +
2653
+ 'the peers can be checked against them. Under the default p2pMode the offer is also pushed ' +
2654
+ 'over the direct path, best effort: `direct_pushes` names every attempt, and the relay\'s ' +
2655
+ 'own offer remains the delivery when a push fails.',
2656
+ parameters: {
2657
+ command_argv: {
2658
+ type: 'array',
2659
+ items: { type: 'string' },
2660
+ // Not `required`: a pipeline states its commands in `stages`, and the relay derives
2661
+ // `command_argv` from stage 0. Marking it required would make a pipeline task impossible to
2662
+ // express, so the check below is conditional instead.
2663
+ description: 'The command as an argv array, for example ["node","--test"]. Never a shell string. Omit under mode=pipeline.',
2664
+ },
2665
+ mode: {
2666
+ type: 'string',
2667
+ enum: ['replicate', 'split', 'broadcast', 'pipeline'],
2668
+ description:
2669
+ 'replicate: every machine runs the whole command. split: each machine takes its share by index. ' +
2670
+ 'broadcast: one machine runs it and every other machine is told the outcome without running anything ' +
2671
+ '(pair with executor_machine_id to choose which). pipeline: every machine runs the whole chain from ' +
2672
+ '`stages`, in order, stopping at the first stage that fails. Defaults to replicate.',
2673
+ default: 'replicate',
2674
+ },
2675
+ stages: {
2676
+ type: 'array',
2677
+ // Array of strings, matching `command_argv`'s existing convention: the tool shim can check
2678
+ // that shape, and an array of objects would be declared as `object` items that nothing
2679
+ // validates. A stage that needs its own cwd is not supported through this tool; the chain
2680
+ // inherits the task's `cwd_rel`, and per-stage cwd stays available to the relay API.
2681
+ items: { type: 'string' },
2682
+ description:
2683
+ 'mode=pipeline only: the chain, in order, one argv per stage with elements separated by spaces ' +
2684
+ '(for example ["node --test", "node scripts/pack.mjs"]). At most 16. Every machine runs the entire ' +
2685
+ 'chain, so there is no cross-machine data flow and nothing can be dropped in transit. The timeout ' +
2686
+ 'covers the whole chain, not each stage. A stage containing a space inside one argument cannot be ' +
2687
+ 'expressed here; use the relay API for that.',
2688
+ },
2689
+ executor_machine_id: {
2690
+ type: 'string',
2691
+ description:
2692
+ 'mode=broadcast only: which machine runs the command. Defaults to the first machine the relay lists, ' +
2693
+ 'chosen deterministically so the same task record describes the same execution twice. Every other ' +
2694
+ 'machine is recorded as an observer and is never counted as having verified the result.',
2695
+ },
2696
+ index_total: {
2697
+ type: 'number',
2698
+ description: 'Number of slices for mode=split. Defaults to 1, which is the only value replicate accepts.',
2699
+ default: 1,
2700
+ },
2701
+ cwd_rel: {
2702
+ type: 'string',
2703
+ description: 'Working directory relative to the project root, with forward slashes. Defaults to ".".',
2704
+ default: '.',
2705
+ },
2706
+ timeout_ms: {
2707
+ type: 'number',
2708
+ // No `default` here on purpose. If the schema carried one, DSH would fill it in before
2709
+ // `execute` ran, so a timeout set in the shared config would be silently overridden by the
2710
+ // schema on every call -- and the operator would see their setting have no effect. Leaving it
2711
+ // unset lets `execute` fall back to the merged config, and an explicit per-call value still wins.
2712
+ description:
2713
+ 'How long each machine may spend on the command, in milliseconds. Defaults to the shared config ' +
2714
+ `(defaultTimeoutMs, itself 300000 unless set), capped at ${MAX_TASK_TIMEOUT_MS}.`,
2715
+ },
2716
+ write: {
2717
+ type: 'boolean',
2718
+ description: 'Allow the machines to write inside write_scope. Defaults to false, which makes the whole run read-only.',
2719
+ default: false,
2720
+ },
2721
+ write_scope: {
2722
+ type: 'array',
2723
+ items: { type: 'string' },
2724
+ description: 'Project-relative path prefixes the machines may write, for example ["src/"]. Required in spirit when write is true.',
2725
+ },
2726
+ require_exclusive_write: {
2727
+ type: 'boolean',
2728
+ description: 'Ask the Rabbit to grant at most one writing lease at a time. Defaults to false.',
2729
+ default: false,
2730
+ },
2731
+ halt: {
2732
+ type: 'string',
2733
+ description: 'never: a machine that stops reporting is only recorded. now: other machines may take over its work. Defaults to never.',
2734
+ default: 'never',
2735
+ },
2736
+ },
2737
+ output: jsonOutput,
2738
+ async execute(args, exec) {
2739
+ throwIfAborted(exec?.signal);
2740
+ const base = requireRabbit(cfg);
2741
+
2742
+ // `required` is enforced by DSH's schema layer in production, and by the shim offline; this
2743
+ // check is here so a caller that bypassed both still gets a named reason instead of a
2744
+ // confusing failure several layers down.
2745
+ const mode0 = args?.mode ?? 'replicate';
2746
+ const argv = args?.command_argv;
2747
+ const argvUsable = Array.isArray(argv) && argv.length > 0 && argv.every((part) => typeof part === 'string' && part !== '');
2748
+ // Under `pipeline` the commands live in `stages` and the relay derives stage 0 into
2749
+ // `command_argv`; every other mode needs it here.
2750
+ if (!argvUsable && mode0 !== 'pipeline') {
2751
+ throw new W2MError('W2M_INVALID_ARGV', '`command_argv` must be a non-empty array of non-empty strings', {
2752
+ hint: 'pass the command as separate array elements, for example ["node","--test"]',
2753
+ });
2754
+ }
2755
+ if (!argvUsable && mode0 === 'pipeline' && !Array.isArray(args?.stages)) {
2756
+ // Only when `stages` is absent outright. An empty array is caught by the pipeline check
2757
+ // below, which names the real problem ("pipeline needs a chain") instead of talking about
2758
+ // `command_argv` -- and the argv guard must not pre-empt that message.
2759
+ throw new W2MError(
2760
+ 'W2M_INVALID_ARGV',
2761
+ '`command_argv` is missing and `mode` is `pipeline` without `stages`, so there is no command to run',
2762
+ { hint: 'pass stages: ["node --test", ...] for a pipeline, or command_argv for a single command' },
2763
+ );
2764
+ }
2765
+
2766
+ const mode = args?.mode ?? 'replicate';
2767
+ if (mode !== 'replicate' && mode !== 'split' && mode !== 'broadcast' && mode !== 'pipeline') {
2768
+ throw new W2MError(
2769
+ 'W2M_BAD_MODE',
2770
+ `W2M_BAD_MODE: \`mode\` must be \`replicate\`, \`split\`, \`broadcast\` or \`pipeline\`, not \`${mode}\`; ` +
2771
+ 'replicate runs the whole command on every machine, split hands each machine a slice by index, ' +
2772
+ 'broadcast runs it on one machine and tells the rest, pipeline runs a chain on every machine',
2773
+ );
2774
+ }
2775
+
2776
+ // v0.3.3 `pipeline`: validated here as well as at the relay, because a malformed chain would
2777
+ // otherwise be rejected only after the request crossed the network -- and the caller would get a
2778
+ // relay error instead of a sentence naming the stage that is wrong.
2779
+ const stageStrings = Array.isArray(args?.stages) ? args.stages : null;
2780
+ /**
2781
+ * The chain as the relay wants it: `{ command_argv }` per stage.
2782
+ *
2783
+ * Each tool-level stage is one string whose elements are space-separated. That is a deliberate
2784
+ * narrowing of what the relay accepts (which takes a full argv per stage): the tool schema can
2785
+ * only declare an array of strings, and an array of objects would validate as "whatever". An
2786
+ * argument containing a space therefore cannot be expressed through this tool, which the
2787
+ * parameter description says outright rather than failing obscurely later.
2788
+ */
2789
+ const stagesPayload = stageStrings === null
2790
+ ? null
2791
+ : stageStrings.map((s) => ({ command_argv: String(s).split(' ').filter((part) => part !== '') }));
2792
+ if (mode === 'pipeline') {
2793
+ if (stagesPayload === null || stagesPayload.length === 0) {
2794
+ throw new W2MError(
2795
+ 'W2M_BAD_STAGES',
2796
+ 'W2M_BAD_STAGES: `mode` is `pipeline` but `stages` is missing or empty; pipeline runs a chain, so ' +
2797
+ 'give at least one stage as an array of argvs, for example ["node --test", "node scripts/pack.mjs"]',
2798
+ { hint: 'pass stages: ["node --test", ...], or use mode=replicate for a single command' },
2799
+ );
2800
+ }
2801
+ if (stagesPayload.length > MAX_STAGES) {
2802
+ throw new W2MError(
2803
+ 'W2M_BAD_STAGES',
2804
+ `W2M_BAD_STAGES: \`stages\` has ${stagesPayload.length} entries but at most ${MAX_STAGES} are allowed; ` +
2805
+ 'each stage is another command run on every machine, so the count is bounded deliberately',
2806
+ );
2807
+ }
2808
+ stagesPayload.forEach((stage, i) => {
2809
+ if (stage.command_argv.length === 0) {
2810
+ throw new W2MError(
2811
+ 'W2M_BAD_STAGES',
2812
+ `W2M_BAD_STAGES: \`stages[${i}]\` is empty or whitespace; every stage must name a command`,
2813
+ );
2814
+ }
2815
+ });
2816
+ } else if (stagesPayload !== null) {
2817
+ // Refused rather than ignored: the caller described a chain, and running only stage 0 would
2818
+ // silently drop every later stage while reporting success.
2819
+ throw new W2MError(
2820
+ 'W2M_BAD_STAGES',
2821
+ `W2M_BAD_STAGES: \`stages\` only applies to mode=pipeline, but \`mode\` is \`${mode}\`; ` +
2822
+ 'under the other modes the relay runs `command_argv`, so every stage after the first would be dropped without a word',
2823
+ { hint: 'use mode=pipeline with stages, or drop stages and pass command_argv' },
2824
+ );
2825
+ }
2826
+
2827
+ const indexTotal = Number.isInteger(args?.index_total) ? args.index_total : 1;
2828
+ if (indexTotal < 1) {
2829
+ throw new W2MError(
2830
+ 'W2M_BAD_INDEX',
2831
+ `W2M_BAD_INDEX: \`index_total\` is \`${args?.index_total}\` but must be an integer of at least 1 (number of slices)`,
2832
+ );
2833
+ }
2834
+ if (mode === 'replicate' && indexTotal !== 1) {
2835
+ throw new W2MError(
2836
+ 'W2M_BAD_INDEX',
2837
+ `W2M_BAD_INDEX: mode=replicate means every machine runs the whole command, so \`index_total\` must be 1, not \`${indexTotal}\`; use mode=split with index_total>1 to hand each machine a slice`,
2838
+ { hint: 'use mode=split with index_total>1 to hand each machine a slice' },
2839
+ );
2840
+ }
2841
+
2842
+ // `broadcast` runs the command on exactly one machine. Accepting a multi-shard index_total here
2843
+ // would record a split task while executing a single shard, and the report would then describe
2844
+ // work that never happened -- so it is refused rather than quietly coerced.
2845
+ if (mode === 'broadcast' && indexTotal !== 1) {
2846
+ throw new W2MError(
2847
+ 'W2M_BAD_INDEX',
2848
+ `W2M_BAD_INDEX: mode=broadcast runs the command on one machine, so \`index_total\` must be 1, not \`${indexTotal}\`; use mode=split to hand each machine a slice`,
2849
+ { hint: 'use mode=broadcast with index_total=1, or mode=split to distribute slices' },
2850
+ );
2851
+ }
2852
+
2853
+ // A pipeline is a sequence, not a sharded sequence: sharding it would mean each machine ran its
2854
+ // own slice's chain, which is a different feature and not what was asked for.
2855
+ if (mode === 'pipeline' && indexTotal !== 1) {
2856
+ throw new W2MError(
2857
+ 'W2M_BAD_INDEX',
2858
+ `W2M_BAD_INDEX: mode=pipeline runs the whole chain on each machine, so \`index_total\` must be 1, not \`${indexTotal}\``,
2859
+ { hint: 'use mode=pipeline with index_total=1, or mode=split for independent slices' },
2860
+ );
2861
+ }
2862
+
2863
+ const executorMachineId =
2864
+ typeof args?.executor_machine_id === 'string' && args.executor_machine_id !== ''
2865
+ ? args.executor_machine_id
2866
+ : null;
2867
+ if (executorMachineId !== null && mode !== 'broadcast') {
2868
+ // The relay only reads this field for broadcast, so accepting it elsewhere would promise a
2869
+ // restriction that nothing enforces: the command would still run everywhere.
2870
+ throw new W2MError(
2871
+ 'W2M_BAD_EXECUTOR',
2872
+ `W2M_BAD_EXECUTOR: \`executor_machine_id\` only applies to mode=broadcast, but \`mode\` is \`${mode}\`; ` +
2873
+ 'under replicate and split the relay decides the targets, so passing this would look like a restriction that is not applied',
2874
+ { hint: 'use mode=broadcast with executor_machine_id, or drop executor_machine_id' },
2875
+ );
2876
+ }
2877
+
2878
+ // Plugin-side allow-list check: only when one is configured. See checkAllowedCommand.
2879
+ //
2880
+ // **Every** stage is checked, not just the first. Checking only `command_argv` would let a
2881
+ // pipeline smuggle a disallowed command in stage 2 while stage 1 satisfied the list -- the
2882
+ // allow-list is a default-deny control, and a chain is not an exemption from it.
2883
+ const commandsToCheck = mode === 'pipeline' && stagesPayload !== null
2884
+ ? stagesPayload.map((s) => s.command_argv)
2885
+ : [argv];
2886
+ for (const [stageIndex, stageArgv] of commandsToCheck.entries()) {
2887
+ const refused = checkAllowedCommand(cfg.allowedCommands, stageArgv);
2888
+ if (refused) {
2889
+ return JSON.stringify(
2890
+ {
2891
+ ok: false,
2892
+ state: 'refused',
2893
+ refusal_reason: refused.code,
2894
+ message: commandsToCheck.length > 1
2895
+ ? `stage ${stageIndex}: ${refused.message}`
2896
+ : refused.message,
2897
+ stage_index: commandsToCheck.length > 1 ? stageIndex : null,
2898
+ allowed: refused.allowed,
2899
+ },
2900
+ null,
2901
+ 2,
2902
+ );
2903
+ }
2904
+ }
2905
+
2906
+ // The default comes from the merged shared config, so a fleet that agrees on a timeout gets it
2907
+ // from one committed file instead of every machine's own settings. The per-call argument still
2908
+ // wins, and MAX_TASK_TIMEOUT_MS still bounds the result.
2909
+ const timeoutMs = Math.min(
2910
+ Math.max(1, Number.isFinite(args?.timeout_ms) ? Number(args.timeout_ms) : cfg.defaultTimeoutMs),
2911
+ MAX_TASK_TIMEOUT_MS,
2912
+ );
2913
+ const write = args?.write === true;
2914
+ const writeScope = Array.isArray(args?.write_scope)
2915
+ ? args.write_scope.filter((entry) => typeof entry === 'string' && entry.trim() !== '')
2916
+ : [];
2917
+ if (write && writeScope.length === 0) {
2918
+ throw new W2MError(
2919
+ 'W2M_WRITE_SCOPE',
2920
+ 'W2M_WRITE_SCOPE: `write` is true but `write_scope` is empty; name the project-relative prefixes the machines may write, for example ["src/"], or set write to false',
2921
+ { hint: 'name the project-relative prefixes the machines may write, for example ["src/"], or leave write false' },
2922
+ );
2923
+ }
2924
+
2925
+ // §5: dispatching a task is the operator's privilege, not the device's. The operator token is
2926
+ // required and there is no device-token fallbac - see requireOperatorToken.
2927
+ const token = requireOperatorToken(cfg);
2928
+ const anchors = await resolveGitAnchors({ projectDir: cfg.projectDir, timeoutMs: Math.min(15_000, timeoutMs) });
2929
+
2930
+ // §7: the two routing facts the offer carries to the machine that has to run the command.
2931
+ //
2932
+ // `origin_machine_id` is *this* machine's id from `device.json` — where the work was typed, not
2933
+ // where it runs — and it is omitted entirely (not sent as null) when this machine has no
2934
+ // identity, because the relay's own validation refuses an empty string and "unknown origin" is
2935
+ // what a missing field already means on the wire.
2936
+ //
2937
+ // The identity comes from the runtime, which resolves it off the dispatch path; a dispatch that
2938
+ // arrives before that one small read has finished waits for it rather than racing it. The wait
2939
+ // is bounded by the identity read itself and can never reject — see `createP2PRuntime`. This is
2940
+ // also the first use of the direct path, so it is where the node is started.
2941
+ await p2p.ensureStarted();
2942
+ const originMachineId =
2943
+ typeof p2p.machineId === 'string' && p2p.machineId !== '' ? p2p.machineId : null;
2944
+
2945
+ const payload = {
2946
+ mode,
2947
+ // Under pipeline the relay derives this from stage 0; sending the caller's value when there is
2948
+ // none would put `undefined` on the wire, which JSON drops -- so it is omitted explicitly and
2949
+ // the relay's normalization is the single source of that field.
2950
+ ...(argvUsable ? { command_argv: argv } : {}),
2951
+ cwd_rel: typeof args?.cwd_rel === 'string' && args.cwd_rel !== '' ? args.cwd_rel : '.',
2952
+ index_total: indexTotal,
2953
+ timeout_ms: timeoutMs,
2954
+ write,
2955
+ write_scope: writeScope,
2956
+ require_exclusive_write: args?.require_exclusive_write === true,
2957
+ base_commit: anchors.base_commit,
2958
+ base_tree: anchors.base_tree,
2959
+ requirements: { toolchain: {}, platform: [] },
2960
+ compare_policy: { strip_ansi: true, normalize_crlf: true, strip_trailing_blank_lines: true, redact: [] },
2961
+ halt: args?.halt === 'now' ? 'now' : 'never',
2962
+ created_by: cfg.machineName,
2963
+ // Only sent for broadcast; the relay ignores it otherwise, and the validation above already
2964
+ // refused the combination, so this cannot silently mean nothing.
2965
+ ...(executorMachineId !== null ? { executor_machine_id: executorMachineId } : {}),
2966
+ // Only for pipeline. The relay ignores `stages` under the other modes and the validation
2967
+ // above refuses the combination, so this can never silently mean nothing.
2968
+ ...(mode === 'pipeline' && stagesPayload !== null ? { stages: stagesPayload } : {}),
2969
+ // v0.4.0: stated on every task, so `w2m_wait`'s `transport` and a machine's report can say
2970
+ // which path the dispatcher expected to use — and so the executor in `direct` mode knows it
2971
+ // must refuse an offer that did not arrive over the direct path.
2972
+ ...(originMachineId !== null ? { origin_machine_id: originMachineId } : {}),
2973
+ p2p: { mode: cfg.p2pMode },
2974
+ };
2975
+
2976
+ const result = await request({
2977
+ rabbitUrl: base,
2978
+ token,
2979
+ signal: exec?.signal,
2980
+ method: 'POST',
2981
+ pathname: '/v1/task',
2982
+ body: payload,
2983
+ });
2984
+
2985
+ if (!result.ok) {
2986
+ // OPERATOR_REQUIRED gets its own guidance: it is the one refusal whose fix is a client-side
2987
+ // setting rather than something on the relay.
2988
+ const operatorRequired = result.error?.code === 'OPERATOR_REQUIRED' || result.status === 401;
2989
+ throw new W2MError(
2990
+ 'W2M_RABBIT_REFUSED',
2991
+ `W2M_RABBIT_REFUSED: the Rabbit refused POST /v1/task with HTTP ${result.status}${result.error ? ` (${result.error.code}: ${result.error.message})` : ''}; ${operatorRequired ? 'this relay requires the operator token to dispatch tasks and the one sent was not accepted - set `operatorToken` (or `W2M_OPERATOR_TOKEN`) to the value the relay printed at startup; a device token cannot dispatch tasks' : result.error?.code === 'NO_ONLINE_DEVICE' ? 'no machine is currently streaming - start the agent on each machine, then w2m_devices shows who is online' : 'check the Rabbit log for the reason it gave'}`,
2992
+ {
2993
+ hint: operatorRequired
2994
+ ? 'set `operatorToken` (or `W2M_OPERATOR_TOKEN`) to the value the relay printed at startup, or read it from `<state>/operator-token.txt`'
2995
+ : result.error?.code === 'NO_ONLINE_DEVICE'
2996
+ ? 'no machine is currently streaming; start the agent on each machine (w2m_devices shows who is online)'
2997
+ : 'check the Rabbit log for the reason it gave',
2998
+ },
2999
+ );
3000
+ }
3001
+
3002
+ /**
3003
+ * v0.4.0 §7: best-effort direct pushes, after the relay has the task.
3004
+ *
3005
+ * Order matters and is deliberate. The task exists on the relay *before* anything is punched,
3006
+ * so a push that fails, a peer that never announced and a socket that was never bound all leave
3007
+ * exactly the same outcome: the relay's own SSE offer is the delivery, and the machine's
3008
+ * `transport` says which path won. A failed push is therefore **not** an error and never fails
3009
+ * this tool — but it is not hidden either, because `direct_pushes` names every attempt and the
3010
+ * reason it did not land.
3011
+ *
3012
+ * `relay` mode and a machine with no direct path both report `attempted: 0`, which is the
3013
+ * honest answer ("there was nothing to push over") rather than an empty object.
3014
+ */
3015
+ let directPushes = { mode: cfg.p2pMode, attempted: 0, delivered: 0, failed: 0, pushes: [] };
3016
+ if (cfg.p2pMode !== 'relay' && p2p.node !== null && p2p.start?.ok === true) {
3017
+ try {
3018
+ const pushes = await pushOffers(p2p.node, {
3019
+ taskId: result.json?.task_id ?? null,
3020
+ leases: Array.isArray(result.json?.leases) ? result.json.leases : [],
3021
+ payload,
3022
+ originMachineId,
3023
+ });
3024
+ directPushes = {
3025
+ mode: cfg.p2pMode,
3026
+ attempted: pushes.length,
3027
+ delivered: pushes.filter((entry) => entry.ok).length,
3028
+ failed: pushes.filter((entry) => !entry.ok).length,
3029
+ pushes,
3030
+ };
3031
+ } catch (error) {
3032
+ // `pushOffers` is written not to throw; this is here so a future edit cannot turn a
3033
+ // best-effort optimisation into a failed dispatch.
3034
+ directPushes = {
3035
+ mode: cfg.p2pMode,
3036
+ attempted: 0,
3037
+ delivered: 0,
3038
+ failed: 0,
3039
+ pushes: [],
3040
+ error: `P2P_PUSH_FAILED: ${error instanceof Error ? error.message : String(error)}`,
3041
+ };
3042
+ }
3043
+ } else if (cfg.p2pMode !== 'relay') {
3044
+ directPushes.error = p2p.error ?? 'the direct path is not running, so nothing was pushed';
3045
+ }
3046
+ // eslint-disable-next-line require-atomic-updates -- each dispatch writes its own summary; the last one wins by design
3047
+ p2p.directPushes = directPushes;
3048
+
3049
+ return JSON.stringify(
3050
+ {
3051
+ ok: true,
3052
+ task_id: result.json?.task_id ?? null,
3053
+ seq: result.json?.seq ?? null,
3054
+ leases: result.json?.leases ?? [],
3055
+ anchors: {
3056
+ base_commit: anchors.base_commit,
3057
+ base_tree: anchors.base_tree,
3058
+ dirty: anchors.dirty,
3059
+ branch: anchors.branch,
3060
+ fingerprint_algo: 'git-temp-index-tree/v1',
3061
+ error: anchors.error,
3062
+ },
3063
+ // v0.4.0: what the direct path tried, per machine. `mode` is repeated here rather than left
3064
+ // to `w2m_status` so one dispatch's answer is self-contained.
3065
+ direct_pushes: directPushes,
3066
+ note: anchors.base_tree === null
3067
+ ? 'this machine\'s working tree could not be fingerprinted, so peers have no anchor to match and the aggregate will be unverifiable'
3068
+ : 'peers are checked against base_commit + base_tree + command_hash',
3069
+ },
3070
+ null,
3071
+ 2,
3072
+ );
3073
+ },
3074
+ presentCall: (args) => ({
3075
+ card: 'generic',
3076
+ title: `w2m run ${Array.isArray(args?.command_argv) ? args.command_argv.join(' ') : ''}`.trim(),
3077
+ kind: args?.write === true ? 'write' : 'read',
3078
+ rawInput: args,
3079
+ }),
3080
+ }));
3081
+
3082
+ // -------------------------------------------------------------------------------------------
3083
+ // 3. w2m_wait
3084
+ // -------------------------------------------------------------------------------------------
3085
+ ctx.tools.register(defineTool({
3086
+ name: 'w2m_wait',
3087
+ description:
3088
+ 'Wait for a task to reach a terminal state and return the aggregate verdict with a ' +
3089
+ 'per-machine summary. The verdicts are consistent, divergent, divergent-platform, failed, ' +
3090
+ 'partial, unverifiable, timeout, cancelled and degraded. A non-zero exit, a refusal, and a ' +
3091
+ 'divergence are all reported as values, not as tool failures. If the wait window elapses ' +
3092
+ 'first, says so and leaves the task runnabl - call again to keep waiting.',
3093
+ parameters: {
3094
+ task_id: { type: 'string', required: true, description: 'Task id returned by w2m_run.' },
3095
+ wait_ms: {
3096
+ type: 'number',
3097
+ description: `How long to wait before giving up, in milliseconds. Defaults to 120000, capped at ${MAX_WAIT_MS}.`,
3098
+ default: 120_000,
3099
+ },
3100
+ poll_ms: {
3101
+ type: 'number',
3102
+ description: `Interval between status polls, in milliseconds. Defaults to 750. Raise it to be gentler on the Rabbit; it is capped below by ${MIN_POLL_MS}.`,
3103
+ default: 750,
3104
+ },
3105
+ },
3106
+ output: jsonOutput,
3107
+ async execute(args, exec) {
3108
+ throwIfAborted(exec?.signal);
3109
+ const base = requireRabbit(cfg);
3110
+ const taskId = typeof args?.task_id === 'string' ? args.task_id.trim() : '';
3111
+ if (taskId === '') {
3112
+ throw new W2MError('W2M_BAD_TASK_ID', '`task_id` must be a non-empty string');
3113
+ }
3114
+
3115
+ const waitMs = Math.min(Math.max(1, Number.isFinite(args?.wait_ms) ? Number(args.wait_ms) : 120_000), MAX_WAIT_MS);
3116
+ const pollMs = Math.min(Math.max(MIN_POLL_MS, Number.isFinite(args?.poll_ms) ? Number(args.poll_ms) : 750), 30_000);
3117
+ const token = await resolveToken(cfg);
3118
+
3119
+ const deadline = Date.now() + waitMs;
3120
+
3121
+ let body = null;
3122
+ let attempts = 0;
3123
+ let lastFailure = null;
3124
+
3125
+ for (;;) {
3126
+ throwIfAborted(exec?.signal);
3127
+ const result = await request({
3128
+ rabbitUrl: base,
3129
+ token,
3130
+ signal: exec?.signal,
3131
+ pathname: `/v1/tasks/${encodeURIComponent(taskId)}`,
3132
+ });
3133
+ attempts += 1;
3134
+
3135
+ if (!result.ok && !RETRYABLE_STATUS.has(result.status)) {
3136
+ throw new W2MError(
3137
+ 'W2M_RABBIT_REFUSED',
3138
+ `W2M_RABBIT_REFUSED: the Rabbit refused GET /v1/tasks/${taskId} with HTTP ${result.status}${result.error ? ` (${result.error.code}: ${result.error.message})` : ''}; ${result.error?.code === 'NOT_FOUND' ? `check the task_id returned by w2m_ru - the Rabbit does not know \`${taskId}\`` : 'check the Rabbit log for the reason it gave'}`,
3139
+ { hint: result.error?.code === 'NOT_FOUND' ? 'check the task_id returned by w2m_run' : 'check the Rabbit log' },
3140
+ );
3141
+ }
3142
+
3143
+ // A retryable failure leaves us with no state to read: keep whatever the last good answer
3144
+ // said, and remember that the latest attempt did not land.
3145
+ if (result.ok) {
3146
+ body = result.json;
3147
+ lastFailure = null;
3148
+ const current = readAggregate(body);
3149
+ if (current.terminal) break;
3150
+ } else {
3151
+ lastFailure = `HTTP ${result.status}${result.error ? ` (${result.error.code})` : ''}`;
3152
+ }
3153
+
3154
+ if (Date.now() >= deadline) {
3155
+ const states = readAggregate(body).states;
3156
+ const envelope = timeoutEnvelope(taskId, states.length > 0 ? states.map(String) : ['unknown']);
3157
+ // Both facts matter when nothing was ever read: how far we got, and why. Reporting only
3158
+ // the first would hide a Rabbit that answered every time with a retryable error.
3159
+ const note = body === null
3160
+ ? `no status has been read from the Rabbit yet; the task may or may not exist${lastFailure ? ` - the most recent poll did not land (${lastFailure})` : ''}`
3161
+ : (lastFailure
3162
+ ? `${envelope.note}; the most recent poll did not land (${lastFailure})`
3163
+ : envelope.note);
3164
+ return JSON.stringify(
3165
+ {
3166
+ ...envelope,
3167
+ // After the spread, so the local verdict is not overwritten by what was last seen.
3168
+ state: 'timeout',
3169
+ last_state: readAggregate(body).state,
3170
+ note,
3171
+ polls: attempts,
3172
+ ...(lastFailure ? { last_error: lastFailure } : {}),
3173
+ },
3174
+ null,
3175
+ 2,
3176
+ );
3177
+ }
3178
+
3179
+ const remaining = deadline - Date.now();
3180
+ await sleepWithSignal(Math.min(pollMs, Math.max(1, remaining)), exec?.signal);
3181
+ }
3182
+
3183
+ const aggregate = readAggregate(body);
3184
+ const machines = Array.isArray(aggregate.detail?.machines)
3185
+ ? aggregate.detail.machines.map((machine) => {
3186
+ const envelope = machine?.envelope ?? machine?.result ?? null;
3187
+ return {
3188
+ machine_id: machine?.machine_id ?? null,
3189
+ machine_name: machine?.machine_name ?? null,
3190
+ index: machine?.index ?? null,
3191
+ lease_state: machine?.lease_state ?? null,
3192
+ outcome: machine?.outcome ?? null,
3193
+ status: machine?.status ?? null,
3194
+ refusal_reason: machine?.refusal_reason ?? null,
3195
+ exit_code: machine?.exit_code ?? null,
3196
+ /**
3197
+ * v0.4.0 §5: which path this machine's result travelled, and the path facts that came
3198
+ * with it. Read from the envelope because that is where the agent writes them, with the
3199
+ * per-machine record as the fallback (`GET /v1/tasks/{id}` copies both onto the machine
3200
+ * entry as well). `null` means the machine did not say — which is exactly what a
3201
+ * v0.3.9 agent sends, so a missing value must not be dressed up as `'relay'`.
3202
+ */
3203
+ transport: envelope?.transport ?? machine?.transport ?? null,
3204
+ p2p: envelope?.p2p ?? machine?.p2p ?? null,
3205
+ reasons: machine?.reasons ?? [],
3206
+ envelope: compactEnvelope(envelope, COMPARABLE_FIELDS),
3207
+ };
3208
+ })
3209
+ : [];
3210
+
3211
+ return JSON.stringify(
3212
+ {
3213
+ ok: true,
3214
+ complete: true,
3215
+ task_id: taskId,
3216
+ state: aggregate.state,
3217
+ state_known: aggregate.known,
3218
+ states: aggregate.states,
3219
+ machines,
3220
+ counts: aggregate.detail?.counts ?? null,
3221
+ differences: aggregate.detail?.differences ?? [],
3222
+ notes: aggregate.detail?.notes ?? [],
3223
+ unknown_fields: collectUnknownFields(body, { comparable: COMPARABLE_FIELDS, optional: OPTIONAL_FIELDS }),
3224
+ polls: attempts,
3225
+ },
3226
+ null,
3227
+ 2,
3228
+ );
3229
+ },
3230
+ presentCall: (args) => ({ card: 'generic', title: `w2m wait ${args?.task_id ?? ''}`, kind: 'read', rawInput: args }),
3231
+ }));
3232
+
3233
+ // -------------------------------------------------------------------------------------------
3234
+ // 4. w2m_report
3235
+ // -------------------------------------------------------------------------------------------
3236
+ ctx.tools.register(defineTool({
3237
+ name: 'w2m_report',
3238
+ description:
3239
+ 'Fetch the rendered report for a finished task, as markdown or json. Markdown is the ' +
3240
+ 'human summary; json is the same aggregate shape w2m_wait returns. Read-only, and does ' +
3241
+ 'not start, resume, or cancel anything.',
3242
+ parameters: {
3243
+ task_id: { type: 'string', required: true, description: 'Task id returned by w2m_run.' },
3244
+ format: { type: 'string', description: 'md for the rendered report, json for the aggregate. Defaults to md.', default: 'md' },
3245
+ },
3246
+ output: jsonOutput,
3247
+ async execute(args, exec) {
3248
+ throwIfAborted(exec?.signal);
3249
+ const base = requireRabbit(cfg);
3250
+ const taskId = typeof args?.task_id === 'string' ? args.task_id.trim() : '';
3251
+ if (taskId === '') {
3252
+ throw new W2MError('W2M_BAD_TASK_ID', '`task_id` must be a non-empty string');
3253
+ }
3254
+ const format = args?.format === 'json' ? 'json' : 'md';
3255
+ const token = await resolveToken(cfg);
3256
+
3257
+ const result = await request({
3258
+ rabbitUrl: base,
3259
+ token,
3260
+ signal: exec?.signal,
3261
+ pathname: `/v1/tasks/${encodeURIComponent(taskId)}/report`,
3262
+ query: { format },
3263
+ maxBytes: MAX_REPORT_BYTES,
3264
+ accept: format === 'json' ? 'application/json' : 'text/markdown, text/plain',
3265
+ });
3266
+
3267
+ if (!result.ok) {
3268
+ throw new W2MError(
3269
+ 'W2M_RABBIT_REFUSED',
3270
+ `W2M_RABBIT_REFUSED: the Rabbit refused GET /v1/tasks/${taskId}/report with HTTP ${result.status}${result.error ? ` (${result.error.code}: ${result.error.message})` : ''}; ${result.error?.code === 'NOT_FOUND' ? 'the task may still be runnin - w2m_wait first, or check the task_id returned by w2m_run' : 'check the Rabbit log for the reason it gave'}`,
3271
+ { hint: result.error?.code === 'NOT_FOUND' ? 'the task may still be running; w2m_wait first' : 'check the Rabbit log' },
3272
+ );
3273
+ }
3274
+
3275
+ if (format === 'json') {
3276
+ return JSON.stringify(
3277
+ { ok: true, task_id: taskId, format, report: result.json ?? result.text },
3278
+ null,
3279
+ 2,
3280
+ );
3281
+ }
3282
+
3283
+ return JSON.stringify(
3284
+ {
3285
+ ok: true,
3286
+ task_id: taskId,
3287
+ format,
3288
+ bytes: Buffer.byteLength(result.text, 'utf8'),
3289
+ report: result.text,
3290
+ },
3291
+ null,
3292
+ 2,
3293
+ );
3294
+ },
3295
+ presentCall: (args) => ({
3296
+ card: 'generic',
3297
+ title: `w2m report ${args?.task_id ?? ''}`,
3298
+ kind: 'read',
3299
+ rawInput: args,
3300
+ }),
3301
+ }));
3302
+
3303
+ // -------------------------------------------------------------------------------------------
3304
+ // 5. w2m_statu - read-only, and the only tool that works without rabbitUrl
3305
+ // -------------------------------------------------------------------------------------------
3306
+ ctx.tools.register(defineTool({
3307
+ name: 'w2m_status',
3308
+ description:
3309
+ 'Report this plugin\'s view of itself: the machine identity on disk, which relay it is ' +
3310
+ 'talking to and where that address came from (rabbit_source; "shared-default" means nobody ' +
3311
+ 'configured one), whether the Rabbit answers, the paired token presence, the relay\'s ' +
3312
+ 'cross-region diagnostics (relay_id, uptime, deployment base_path, effective_scheme, whether ' +
3313
+ 'it demands an operator token), the direct P2P path (mode, whether a UDP socket is bound, ' +
3314
+ 'the measured reflexive address and NAT mapping, punches in and out, and how many results ' +
3315
+ 'are waiting in the local inbox), this machine\'s round-trip time to the relay, and the ' +
3316
+ 'project\'s base_commit/base_tree. Read-only, degrades field by field, and deliberately ' +
3317
+ 'usable while the configuration is still broken, because it is the tool you reach for when ' +
3318
+ 'w2m_devices says rabbitUrl is unset.',
3319
+ parameters: {},
3320
+ output: jsonOutput,
3321
+ async execute(_args, exec) {
3322
+ throwIfAborted(exec?.signal);
3323
+ // Read, not required: this tool exists to describe a half-configured machine, so an absent or
3324
+ // unpaired device.json is part of the answer rather than a reason to refuse. `/healthz` needs
3325
+ // no bearer token, so nothing here depends on the token being present.
3326
+ const device = await readDevice(cfg);
3327
+
3328
+ // A cheap reachability probe of a documented endpoint. Without a configured URL there is
3329
+ // nothing to probe, and that is reported rather than guessed at.
3330
+ let relay = {
3331
+ configured_url: cfg.rabbitUrl,
3332
+ probed_url: null,
3333
+ reachable: null,
3334
+ http_status: null,
3335
+ protocol_version: null,
3336
+ error: null,
3337
+ // §4 cross-region diagnostics. Null until `/healthz` answers, and individually null when a
3338
+ // relay predates v0.1.2: a missing field is reported as missing, never invented.
3339
+ relay_id: null,
3340
+ uptime_ms: null,
3341
+ started_at: null,
3342
+ base_path: null,
3343
+ effective_scheme: null,
3344
+ operator_token_required: null,
3345
+ pair_rate_limit: null,
3346
+ persistence: null,
3347
+ };
3348
+ const probeBase = cfg.rabbitUrl ?? device.rabbit_url ?? null;
3349
+ if (probeBase) {
3350
+ const target = `${probeBase.replace(/\/+$/, '')}`;
3351
+ try {
3352
+ const probe = await request({
3353
+ rabbitUrl: target,
3354
+ signal: exec?.signal,
3355
+ pathname: '/healthz',
3356
+ timeoutMs: PROBE_TIMEOUT_MS,
3357
+ maxBytes: 64 * 1024,
3358
+ });
3359
+ const health = probe.json ?? {};
3360
+ relay = {
3361
+ configured_url: cfg.rabbitUrl,
3362
+ probed_url: target,
3363
+ reachable: probe.ok,
3364
+ http_status: probe.status,
3365
+ protocol_version: health.protocol_version ?? null,
3366
+ error: probe.ok ? null : (probe.error?.code ?? `HTTP ${probe.status}`),
3367
+ relay_id: health.relay_id ?? null,
3368
+ uptime_ms: typeof health.uptime_ms === 'number' ? health.uptime_ms : null,
3369
+ started_at: health.started_at ?? null,
3370
+ base_path: health.base_path ?? null,
3371
+ effective_scheme: health.effective_scheme ?? null,
3372
+ operator_token_required:
3373
+ typeof health.operator_token_required === 'boolean' ? health.operator_token_required : null,
3374
+ pair_rate_limit: typeof health.pair_rate_limit === 'number' ? health.pair_rate_limit : null,
3375
+ persistence: health.persistence ?? null,
3376
+ // Which of the §4 fields this relay actually sent. A v1 relay sends none of them, and a
3377
+ // reader should be able to tell "field absent" from "field false".
3378
+ diagnostics_present: ['relay_id', 'uptime_ms', 'base_path', 'effective_scheme', 'operator_token_required']
3379
+ .filter((key) => health[key] !== undefined && health[key] !== null),
3380
+ };
3381
+ } catch (error) {
3382
+ relay = {
3383
+ ...relay,
3384
+ configured_url: cfg.rabbitUrl,
3385
+ probed_url: target,
3386
+ reachable: false,
3387
+ http_status: null,
3388
+ error: error instanceof Error ? error.message : String(error),
3389
+ };
3390
+ }
3391
+ }
3392
+
3393
+ const anchors = await resolveGitAnchors({ projectDir: cfg.projectDir });
3394
+ const rtt = await readRtt(cfg);
3395
+ // §7: this tool is one of the two places the direct path is asked about, so it is one of the
3396
+ // two places the node is started. Never throws, and reports `enabled: false`/`running: false`
3397
+ // rather than omitting itself — a reader has to be able to tell "relay mode" from "p2p was
3398
+ // requested and did not start".
3399
+ await p2p.ensureStarted();
3400
+ const p2pStatus = await readP2PStatus(cfg, p2p, Number.isInteger(relay.peers_announced) ? relay.peers_announced : null);
3401
+
3402
+ return JSON.stringify(
3403
+ {
3404
+ ok: true,
3405
+ protocol_version: PROTOCOL_VERSION,
3406
+ identity: {
3407
+ machine_id: device.machine_id,
3408
+ machine_name: cfg.machineName,
3409
+ device_json: device.path,
3410
+ device_json_present: device.present,
3411
+ paired: device.device_token !== null,
3412
+ device_error: device.error,
3413
+ },
3414
+ relay: { ...relay, healthz: '/healthz' },
3415
+ /**
3416
+ * §7: which relay this machine is actually talking to, and where that address came from.
3417
+ *
3418
+ * `rabbit_source` is the field an operator needs when two machines disagree about the
3419
+ * fleet, and `'shared-default'` is the one value that means "nobody chose this endpoint".
3420
+ * It is repeated at the top level rather than only under `config` because it is the answer
3421
+ * to a question asked about the *relay*, not about this plugin's settings.
3422
+ */
3423
+ rabbit_source: cfg.rabbitSource,
3424
+ rabbit_url: cfg.rabbitUrl,
3425
+ p2p: p2pStatus,
3426
+ rtt,
3427
+ config: {
3428
+ rabbitUrl: cfg.rabbitUrl,
3429
+ rabbit_source: cfg.rabbitSource,
3430
+ rabbit_url_override_ignored: cfg.rabbitUrlOverride,
3431
+ stateDir: cfg.stateDir,
3432
+ projectDir: cfg.projectDir,
3433
+ autoStartAgent: cfg.autoStartAgent,
3434
+ allowedCommands: cfg.allowedCommands,
3435
+ pairingCode_configured: cfg.pairingCode !== null,
3436
+ p2pMode: cfg.p2pMode,
3437
+ // The resolved list, shared server first, exactly as the node will query it.
3438
+ stunServers: cfg.stunServers,
3439
+ // §5: `validated` reports presence only. The plugin cannot tell a correct token from a
3440
+ // wrong one without spending a dispatch, and `required_by_relay` is what makes the two
3441
+ // halves of the mismatch (client has none / relay demands one) visible side by side.
3442
+ operatorToken_configured: cfg.operatorToken !== null,
3443
+ operatorToken_source: cfg.operatorToken !== null
3444
+ ? (typeof config?.operatorToken === 'string' && config.operatorToken.trim() !== ''
3445
+ ? 'config'
3446
+ : 'environment')
3447
+ : null,
3448
+ operatorToken_required_by_relay: relay.operator_token_required ?? null,
3449
+ },
3450
+ /**
3451
+ * v0.3.3 shared config, with the source of every value.
3452
+ *
3453
+ * The sources are the point, not decoration: "the setting is there but something overrides
3454
+ * it" is the characteristic failure of layered configuration, and it must be answerable from
3455
+ * one call rather than by editing files and observing what changes.
3456
+ */
3457
+ shared_config: {
3458
+ files: cfg.sharedConfig.files,
3459
+ values: cfg.sharedConfig.values,
3460
+ sources: cfg.sharedConfig.sources,
3461
+ // Pre-rendered too, because the human reading this in a terminal is usually the one who
3462
+ // has to decide which file to edit.
3463
+ summary: describeSharedConfig(cfg.sharedConfig),
3464
+ },
3465
+ project: {
3466
+ base_commit: anchors.base_commit,
3467
+ base_tree: anchors.base_tree,
3468
+ head_commit: anchors.head_commit,
3469
+ branch: anchors.branch,
3470
+ dirty: anchors.dirty,
3471
+ fingerprint_algo: 'git-temp-index-tree/v1',
3472
+ error: anchors.error,
3473
+ },
3474
+ notes: statusNotes(relay, rtt, cfg, p2pStatus),
3475
+ },
3476
+ null,
3477
+ 2,
3478
+ );
3479
+ },
3480
+ presentCall: () => ({ card: 'generic', title: 'w2m status', kind: 'read', rawInput: {} }),
3481
+ }));
3482
+
3483
+ // -------------------------------------------------------------------------------------------
3484
+ // Optional: bring up relay + agent in this process
3485
+ // -------------------------------------------------------------------------------------------
3486
+ if (cfg.autoStartAgent) {
3487
+ ctx.effect?.(() => startLocalStack(ctx, cfg));
3488
+ }
3489
+
3490
+ // ---------------------------------------------------------------------------------------------
3491
+ // v0.2.3: daily self-update check
3492
+ // ---------------------------------------------------------------------------------------------
3493
+ // Compiled-in version, overridable via W2M_PLUGIN_VERSION. When it is unknown the updater
3494
+ // refuses to act rather than assuming it is old, which is why this may legitimately be null.
3495
+ const currentVersion = resolveCurrentVersion(PLUGIN_VERSION, process.env);
3496
+ const updater = createAutoUpdater({
3497
+ profileDir: cfg.profileDir,
3498
+ currentVersion,
3499
+ repo: cfg.update.repo,
3500
+ times: cfg.update.times,
3501
+ timeZone: cfg.update.timeZone,
3502
+ catchUpMs: cfg.update.catchUpMs,
3503
+ enabled: cfg.update.enabled,
3504
+ dryRun: cfg.update.dryRun,
3505
+ allowPrerelease: cfg.update.allowPrerelease,
3506
+ timeoutMs: cfg.update.timeoutMs,
3507
+ token: cfg.update.token,
3508
+ restartCommand: cfg.update.restartCommand,
3509
+ stateDir: cfg.update.stateDir,
3510
+ log: (message) => ctx.logger?.info?.(`w2m update: ${message}`),
3511
+ // A failing check is a normal Tuesday, not a crash: the scheduler keeps its slot.
3512
+ onError: (error) => ctx.logger?.warn?.(`w2m update: ${error.message}`),
3513
+ });
3514
+
3515
+ // Reversible side effect. `ctx.effect` is provided by Cordis 4 (verified against 4.0.4). When the
3516
+ // feature is on and the host cannot track the effect, we fail loudly: a silently skipped effect
3517
+ // means a timer that is never released, a daily job leaking across every reload. When the feature
3518
+ // is off there is no timer to own, so the missing service must not take the tool set down with it.
3519
+ if (cfg.update.enabled) {
3520
+ if (typeof ctx.effect !== 'function') {
3521
+ throw new W2MError(
3522
+ 'W2M_NO_EFFECT',
3523
+ 'autoUpdate is enabled, so this plugin needs `ctx.effect` to own its scheduled job, and the context does not provide it',
3524
+ { hint: 'load the plugin in a Cordis 4 host, where ctx.effect tracks reversible side effects' },
3525
+ );
3526
+ }
3527
+ ctx.effect(() => {
3528
+ updater.start();
3529
+ return () => updater.stop();
3530
+ });
3531
+ } else {
3532
+ // Still resolvable and inspectable through `w2m_update`; just never scheduled.
3533
+ updater.start();
3534
+ }
3535
+
3536
+ // ---------------------------------------------------------------------------------------------
3537
+ // 6. w2m_update -- read-only unless asked to act
3538
+ // ---------------------------------------------------------------------------------------------
3539
+ ctx.tools.register(defineTool({
3540
+ name: 'w2m_update',
3541
+ description:
3542
+ 'Inspect and drive this plugin\'s own updater. It checks the project\'s GitHub releases at ' +
3543
+ '00:00, 03:00 and 05:00 Beijing time by default and installs a newer release only after ' +
3544
+ 'verifying the hash published with it. Action "status" reports the schedule, the installed ' +
3545
+ 'version, the last check and whether a restart is still pending; "check" runs one ' +
3546
+ 'check-and-install cycle now and does not throw -- a network failure comes back as a recorded ' +
3547
+ 'error, which is exactly how it differs from "already up to date". A new version is installed ' +
3548
+ 'for the *next* start: this tool never restarts the Harness and never hot-swaps running code.',
3549
+ parameters: {
3550
+ action: {
3551
+ type: 'string',
3552
+ description:
3553
+ 'status (default) reports the updater; check runs one check-and-install cycle now and ' +
3554
+ 'records the outcome. One of: status, check.',
3555
+ },
3556
+ },
3557
+ output: jsonOutput,
3558
+ execute: async (args) => {
3559
+ const action = typeof args?.action === 'string' && args.action !== '' ? args.action : 'status';
3560
+ // A STRING, like every other tool here. Measured: the first version returned the plain object
3561
+ // and the host rejected the call outright -- `tool "w2m_update" returned invalid output:
3562
+ // "value" must be a string` -- so this tool could never be used at all, in v0.4.0 or v0.4.1.
3563
+ // `jsonOutput` is a renderer, not a serialiser; the tool still owns turning its value into
3564
+ // text.
3565
+ if (action === 'status') {
3566
+ return JSON.stringify({ ok: true, action, update: updater.describe() }, null, 2);
3567
+ }
3568
+ const result = await updater.check('manual');
3569
+ // `ok:false` inside `result` means "the check could not complete", never "the tool failed".
3570
+ return JSON.stringify({ ok: true, action, result, update: updater.describe() }, null, 2);
3571
+ },
3572
+ // `kind: 'other'` for the actions that can install, never `'read'`. This tool mutates the profile
3573
+ // it runs in, and labelling that a read is a false claim in exactly the place a user looks to
3574
+ // decide whether a call is safe. `'other'` is the value the built-in plugin-manager tool in this
3575
+ // same runtime uses for its mutating actions (`dsh-plugin-manager/lib/types/tools.js`), so it is
3576
+ // the one vocabulary item here with a reference implementation behind it rather than a guess.
3577
+ presentCall: (args) => ({
3578
+ card: 'generic',
3579
+ title: 'w2m update',
3580
+ kind: args?.action === 'check' ? 'other' : 'read',
3581
+ rawInput: args ?? {},
3582
+ }),
3583
+ }));
3584
+
3585
+ // -------------------------------------------------------------------------------------------
3586
+ // 7. w2m_history -- what this fleet has been asked to do
3587
+ // -------------------------------------------------------------------------------------------
3588
+ ctx.tools.register(defineTool({
3589
+ name: 'w2m_history',
3590
+ description:
3591
+ 'List recent tasks the Rabbit still remembers, newest first: task id, mode, how many shards, ' +
3592
+ 'which machines hold leases, and whether it was cancelled. Read-only, and deliberately ' +
3593
+ 'narrower than w2m_status or w2m_wait: it answers "what has been run lately" without fetching ' +
3594
+ 'any result envelope. The relay keeps a bounded history, so an empty list can mean "nothing ' +
3595
+ 'recent" rather than "nothing ever" -- the count of what the relay holds is reported alongside.',
3596
+ parameters: {
3597
+ limit: {
3598
+ type: 'number',
3599
+ description: `How many tasks to return, newest first. Defaults to 20, and the relay caps it at ${MAX_HISTORY_LIMIT}.`,
3600
+ default: 20,
3601
+ },
3602
+ mode: {
3603
+ type: 'string',
3604
+ description: 'Only tasks whose mode matches this, for example "replicate" or "split". Omit for all modes.',
3605
+ },
3606
+ include_cancelled: {
3607
+ type: 'boolean',
3608
+ description: 'Include tasks an operator cancelled. Defaults to true, because a cancel is part of the history.',
3609
+ },
3610
+ },
3611
+ output: jsonOutput,
3612
+ async execute(args, exec) {
3613
+ throwIfAborted(exec?.signal);
3614
+ const base = requireRabbit(cfg);
3615
+ const token = await resolveToken(cfg);
3616
+
3617
+ const requested = typeof args?.limit === 'number' && Number.isFinite(args.limit)
3618
+ ? Math.floor(args.limit)
3619
+ : 20;
3620
+ // Clamp locally as well as at the relay: asking for a million rows from a machine you cannot
3621
+ // see is how a status call turns into a memory problem on someone else's relay.
3622
+ const limit = Math.min(Math.max(requested, 1), MAX_HISTORY_LIMIT);
3623
+
3624
+ const result = await request({
3625
+ rabbitUrl: base,
3626
+ token,
3627
+ signal: exec?.signal,
3628
+ pathname: `/v1/tasks?limit=${limit}`,
3629
+ });
3630
+ if (!result.ok) {
3631
+ throw new W2MError(
3632
+ 'W2M_RABBIT_REFUSED',
3633
+ `W2M_RABBIT_REFUSED: the Rabbit refused GET /v1/tasks with HTTP ${result.status}` +
3634
+ `${result.error ? ` (${result.error.code}: ${result.error.message})` : ''}; ` +
3635
+ (result.status === 401
3636
+ ? 'this machine is not paired, or its device_token is stale - pair the agent again'
3637
+ : 'check the Rabbit log for the reason it gave'),
3638
+ {
3639
+ hint: result.status === 401
3640
+ ? 'this machine is not paired, or its device_token is stale; pair the agent again'
3641
+ : 'check the Rabbit log for the reason it gave',
3642
+ },
3643
+ );
3644
+ }
3645
+
3646
+ // `GET /v1/tasks` answers `{protocol_version, rabbit_time, tasks:[...]}`. Filtering happens
3647
+ // here rather than at the relay because the endpoint takes only a limit: a relay-side filter
3648
+ // would be a protocol change for a display concern.
3649
+ const all = Array.isArray(result.json?.tasks) ? result.json.tasks : [];
3650
+ const wantedMode = typeof args?.mode === 'string' && args.mode.trim() !== '' ? args.mode.trim() : null;
3651
+ const includeCancelled = args?.include_cancelled !== false;
3652
+ const tasks = all.filter((t) => {
3653
+ if (!includeCancelled && t?.cancelled === true) return false;
3654
+ if (wantedMode !== null && t?.mode !== wantedMode) return false;
3655
+ return true;
3656
+ });
3657
+
3658
+ return JSON.stringify(
3659
+ {
3660
+ ok: true,
3661
+ protocol_version: PROTOCOL_VERSION,
3662
+ rabbit_time: result.json?.rabbit_time ?? null,
3663
+ // Both numbers, because they answer different questions: `returned` is what the caller
3664
+ // sees, `held` is what the relay has. A filter that hides everything must not look
3665
+ // identical to an empty relay.
3666
+ returned: tasks.length,
3667
+ held: all.length,
3668
+ limit,
3669
+ filters: { mode: wantedMode, include_cancelled: includeCancelled },
3670
+ tasks: tasks.map((t) => ({
3671
+ task_id: t?.task_id ?? null,
3672
+ mode: t?.mode ?? null,
3673
+ created_at: t?.created_at ?? null,
3674
+ created_by: t?.created_by ?? null,
3675
+ index_total: t?.index_total ?? null,
3676
+ cancelled: t?.cancelled === true,
3677
+ degraded: t?.degraded ?? null,
3678
+ machines: Array.isArray(t?.leases) ? t.leases : [],
3679
+ lease_states: t?.lease_states ?? {},
3680
+ })),
3681
+ notes: tasks.length === 0 && all.length > 0
3682
+ ? ['every task the relay holds was filtered out; widen the filters to see them']
3683
+ : [],
3684
+ },
3685
+ null,
3686
+ 2,
3687
+ );
3688
+ },
3689
+ presentCall: () => ({ card: 'generic', title: 'w2m history', kind: 'read', rawInput: {} }),
3690
+ }));
3691
+
3692
+ // -------------------------------------------------------------------------------------------
3693
+ // 8. w2m_stats -- the fleet at a glance, without reading any task
3694
+ // -------------------------------------------------------------------------------------------
3695
+ ctx.tools.register(defineTool({
3696
+ name: 'w2m_stats',
3697
+ description:
3698
+ 'Summarise the fleet in one read-only call: how many machines the relay holds, how many are ' +
3699
+ 'online, the round-trip time it has measured for each (null means never measured, which is ' +
3700
+ 'not the same as 0ms), task and result counts, and whether the relay restarted since your ' +
3701
+ 'last look. Cheaper than w2m_devices plus w2m_history when you only need the shape of things.',
3702
+ parameters: {},
3703
+ output: jsonOutput,
3704
+ async execute(_args, exec) {
3705
+ throwIfAborted(exec?.signal);
3706
+ const base = requireRabbit(cfg);
3707
+
3708
+ // Two reads rather than one: `/healthz` is the only place the relay reports the aggregate RTT
3709
+ // view, and `/v1/devices` is the only place it reports per-machine state. Neither requires a
3710
+ // device token, so a machine that is not paired can still report on the fleet.
3711
+ const health = await request({ rabbitUrl: base, signal: exec?.signal, pathname: '/healthz' });
3712
+ if (!health.ok) {
3713
+ throw new W2MError(
3714
+ 'W2M_RABBIT_REFUSED',
3715
+ `W2M_RABBIT_REFUSED: the Rabbit refused GET /healthz with HTTP ${health.status}; ` +
3716
+ 'the relay is the only source of fleet state, so there is nothing to summarise',
3717
+ { hint: 'check the relay is running and rabbitUrl points at it' },
3718
+ );
3719
+ }
3720
+
3721
+ let devices = null;
3722
+ try {
3723
+ const token = await resolveToken(cfg);
3724
+ const res = await request({ rabbitUrl: base, token, signal: exec?.signal, pathname: '/v1/devices' });
3725
+ devices = res.ok ? res.json : null;
3726
+ } catch {
3727
+ // A machine that is not paired can still report the fleet from /healthz. Dropping the
3728
+ // per-machine half is better than failing a summary.
3729
+ devices = null;
3730
+ }
3731
+
3732
+ const h = health.json ?? {};
3733
+ const rtt = h.rtt ?? null;
3734
+ const list = Array.isArray(devices?.devices) ? devices.devices : [];
3735
+ const online = list.filter((d) => d?.online === true).length;
3736
+
3737
+ return JSON.stringify(
3738
+ {
3739
+ ok: true,
3740
+ protocol_version: PROTOCOL_VERSION,
3741
+ rabbit_time: h.rabbit_time ?? null,
3742
+ relay: {
3743
+ relay_id: h.relay_id ?? null,
3744
+ uptime_ms: h.uptime_ms ?? null,
3745
+ started_at: h.started_at ?? null,
3746
+ effective_scheme: h.effective_scheme ?? null,
3747
+ base_path: h.base_path ?? null,
3748
+ operator_token_required: h.operator_token_required ?? null,
3749
+ },
3750
+ counts: {
3751
+ devices: h.devices ?? list.length,
3752
+ online: devices === null ? null : online,
3753
+ tasks: h.tasks ?? null,
3754
+ results: h.results ?? null,
3755
+ pairing_codes: h.pairing_codes ?? null,
3756
+ },
3757
+ // `null` throughout means "the relay did not report it", never "zero". Collapsing those
3758
+ // two is how a broken relay reads as an idle one.
3759
+ rtt: rtt === null
3760
+ ? null
3761
+ : {
3762
+ machines_reporting: rtt.machines_reporting ?? null,
3763
+ min_ms: rtt.min_ms ?? null,
3764
+ max_ms: rtt.max_ms ?? null,
3765
+ avg_ms: rtt.avg_ms ?? null,
3766
+ machines_stale: rtt.machines_stale ?? null,
3767
+ machines_unknown: rtt.machines_unknown ?? null,
3768
+ },
3769
+ machines: devices === null
3770
+ ? null
3771
+ : list.map((d) => ({
3772
+ machine_id: d?.machine_id ?? null,
3773
+ machine_name: d?.machine_name ?? null,
3774
+ online: d?.online === true,
3775
+ rtt_ms: d?.rtt_ms ?? null,
3776
+ rtt_stale: d?.rtt_stale ?? null,
3777
+ last_heartbeat_at: d?.last_heartbeat_at ?? null,
3778
+ })),
3779
+ notes: devices === null
3780
+ ? ['per-machine detail is unavailable (this machine could not read /v1/devices); the fleet totals above still come from the relay']
3781
+ : [],
3782
+ },
3783
+ null,
3784
+ 2,
3785
+ );
3786
+ },
3787
+ presentCall: () => ({ card: 'generic', title: 'w2m stats', kind: 'read', rawInput: {} }),
3788
+ }));
3789
+ }
3790
+
3791
+ /**
3792
+ * Wait, but stop waiting the moment the caller cancels.
3793
+ *
3794
+ * @param {number} ms
3795
+ * @param {AbortSignal|undefined} signal
3796
+ * @returns {Promise<void>}
3797
+ */
3798
+ function sleepWithSignal(ms, signal) {
3799
+ return new Promise((resolve, reject) => {
3800
+ const fail = (reason) => {
3801
+ if (reason instanceof W2MError) reject(reason);
3802
+ else {
3803
+ reject(new W2MError('W2M_ABORTED', `W2M_ABORTED: the wait was cancelled${reason instanceof Error && reason.message ? ` (${reason.message})` : ''}`, {
3804
+ cause: reason,
3805
+ }));
3806
+ }
3807
+ };
3808
+
3809
+ if (signal?.aborted) {
3810
+ fail(signal.reason);
3811
+ return;
3812
+ }
3813
+ const onAbort = () => {
3814
+ clearTimeout(timer);
3815
+ fail(signal.reason);
3816
+ };
3817
+ const timer = setTimeout(() => {
3818
+ signal?.removeEventListener?.('abort', onAbort);
3819
+ resolve();
3820
+ }, ms);
3821
+ signal?.addEventListener?.('abort', onAbort, { once: true });
3822
+ });
3823
+ }
3824
+
3825
+ /**
3826
+ * Start the relay, then pair an agent against it, inside this process.
3827
+ *
3828
+ * Both modules are imported dynamically: this repository is written by several people at once, and
3829
+ * a plugin that refuses to load because a sibling module is not on disk yet would be useless to
3830
+ * the other four tools. A missing module is reported as a typed error naming the path and what
3831
+ * that means.
3832
+ *
3833
+ * @param {object} ctx
3834
+ * @param {ReturnType<typeof readConfig>} cfg
3835
+ * @returns {Promise<() => Promise<void>>} A disposer that stops the agent and closes the relay.
3836
+ */
3837
+ async function startLocalStack(ctx, cfg) {
3838
+ const relayUrl = new URL('../relay/server.mjs', import.meta.url);
3839
+ const agentUrl = new URL('../agent/agent.mjs', import.meta.url);
3840
+
3841
+ let createRelay;
3842
+ let createAgent;
3843
+ try {
3844
+ ({ createRelay } = await import(relayUrl.href));
3845
+ } catch (error) {
3846
+ throw new W2MError('W2M_MODULE_MISSING', `autoStartAgent is on but ${relayUrl.pathname} could not be imported: ${error instanceof Error ? error.message : String(error)}`, {
3847
+ hint: 'run the relay yourself and set rabbitUrl, or make sure src/relay/server.mjs is present',
3848
+ });
3849
+ }
3850
+ try {
3851
+ ({ createAgent } = await import(agentUrl.href));
3852
+ } catch (error) {
3853
+ throw new W2MError('W2M_MODULE_MISSING', `autoStartAgent is on but ${agentUrl.pathname} could not be imported: ${error instanceof Error ? error.message : String(error)}`, {
3854
+ hint: 'run the agent yourself and set rabbitUrl, or make sure src/agent/agent.mjs is present',
3855
+ });
3856
+ }
3857
+
3858
+ const target = cfg.rabbitUrl ? new URL(cfg.rabbitUrl) : null;
3859
+ const relay = await createRelay({
3860
+ host: target?.hostname ?? '127.0.0.1',
3861
+ port: target?.port ? Number(target.port) : 8787,
3862
+ stateDir: cfg.stateDir,
3863
+ pairingCode: cfg.pairingCode ?? undefined,
3864
+ });
3865
+ ctx.logger?.info?.(`w2m: relay listening at ${relay.url ?? '(unknown url)'}`);
3866
+
3867
+ const agent = await createAgent({
3868
+ rabbitUrl: relay.url ?? cfg.rabbitUrl,
3869
+ pairingCode: cfg.pairingCode ?? relay.pairingCode ?? undefined,
3870
+ projectDir: cfg.projectDir,
3871
+ machineName: cfg.machineName,
3872
+ stateDir: cfg.stateDir,
3873
+ allowedCommands: cfg.allowedCommands,
3874
+ });
3875
+ await agent.start();
3876
+ ctx.logger?.info?.(`w2m: agent started as ${JSON.stringify(agent.identity?.() ?? null)}`);
3877
+
3878
+ // §8.2: when the agent runs inside this process its `rttMs` is reachable directly, so `w2m_status`
3879
+ // can report it without a file. When the agent is its own process this stays null and the tool
3880
+ // says so instead of guessing.
3881
+ const statusGetter = agent.status ?? agent.state ?? null;
3882
+ inProcessAgentStatus = typeof statusGetter === 'function' ? statusGetter.bind(agent) : null;
3883
+
3884
+ return async () => {
3885
+ inProcessAgentStatus = null;
3886
+ await agent.stop();
3887
+ await relay.close();
3888
+ };
3889
+ }
3890
+
3891
+