@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.
- package/CHANGELOG.md +1309 -0
- package/LICENSE +21 -0
- package/PROTOCOL-v0.1.2.md +165 -0
- package/PROTOCOL-v0.3.0.md +454 -0
- package/PROTOCOL-v0.3.9.md +207 -0
- package/PROTOCOL-v0.4.0.md +255 -0
- package/PROTOCOL.md +339 -0
- package/README.md +665 -2
- package/bin/w2m-localside.mjs +303 -0
- package/bin/w2m-rabbit.mjs +396 -0
- package/bin/w2m-stun.mjs +231 -0
- package/cordis.patch.yml +94 -0
- package/deploy/caddy/Caddyfile.example +118 -0
- package/deploy/docker/Dockerfile +144 -0
- package/deploy/docker/README.md +338 -0
- package/deploy/docker/docker-compose.yml +179 -0
- package/deploy/nginx/w2m.conf.example +196 -0
- package/deploy/shared-server/README.md +118 -0
- package/deploy/shared-server/install.sh +255 -0
- package/deploy/systemd/w2m-rabbit.service +150 -0
- package/deploy/systemd/w2m-stun.service +69 -0
- package/deploy/windows/README.md +115 -0
- package/deploy/windows/install-service.ps1 +195 -0
- package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
- package/deploy/windows/winsw/w2m-localside.xml +50 -0
- package/docs/DEPLOY.md +902 -0
- package/docs/LINT-AUDIT.md +492 -0
- package/docs/PIPELINE-DESIGN.md +95 -0
- package/docs/TROUBLESHOOTING.md +560 -0
- package/docs/UI-CARDS.md +81 -0
- package/lib/index.js +24 -0
- package/lib/tools.js +11 -0
- package/package.json +133 -4
- package/src/agent/agent.mjs +3317 -0
- package/src/agent/allowed-commands.mjs +116 -0
- package/src/agent/caps.mjs +437 -0
- package/src/agent/exec.mjs +356 -0
- package/src/agent/git.mjs +616 -0
- package/src/agent/identity.mjs +253 -0
- package/src/agent/p2p-node.mjs +1243 -0
- package/src/agent/p2p.mjs +605 -0
- package/src/agent/resolve.mjs +148 -0
- package/src/agent/spool.mjs +252 -0
- package/src/agent/stun.mjs +397 -0
- package/src/agent/url.mjs +127 -0
- package/src/plugin/auto-update.mjs +574 -0
- package/src/plugin/schedule.mjs +452 -0
- package/src/plugin/shared-config.mjs +264 -0
- package/src/plugin/tools.mjs +3891 -0
- package/src/plugin/update-install.mjs +764 -0
- package/src/plugin/update-source.mjs +686 -0
- package/src/relay/metrics.mjs +284 -0
- package/src/relay/peers.mjs +180 -0
- package/src/relay/persistence.mjs +207 -0
- package/src/relay/report.mjs +591 -0
- package/src/relay/server.mjs +1187 -0
- package/src/relay/state.mjs +2004 -0
- package/src/relay/stun-server.mjs +733 -0
- package/src/signing.mjs +359 -0
- 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
|
+
|