@twinsearth/w2m-dsh-plugin 0.5.7 → 0.5.9
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 +159 -41
- package/CONTRIBUTING.md +14 -0
- package/package.json +3 -3
- package/src/agent/allowed-commands.mjs +67 -5
- package/src/agent/exec.mjs +60 -4
- package/src/agent/mdns-wire.mjs +54 -54
- package/src/agent/mdns.mjs +31 -31
- package/src/plugin/tools.mjs +295 -30
package/src/plugin/tools.mjs
CHANGED
|
@@ -30,6 +30,49 @@
|
|
|
30
30
|
* * This file never reads `.credentials.yaml`, and never hard-codes an endpoint: every URL comes
|
|
31
31
|
* from `config.rabbitUrl` or from `device.json`'s `rabbit_url`.
|
|
32
32
|
*
|
|
33
|
+
* # The exposure boundary: what an agent-facing tool may not do
|
|
34
|
+
*
|
|
35
|
+
* Everything in this file is callable by a model, against a fleet whose machines run commands under
|
|
36
|
+
* their own allow-lists, and it holds an operator token that authorises dispatch fleet-wide. That
|
|
37
|
+
* makes the *tool surface* a boundary, and this section is the boundary written down as it actually
|
|
38
|
+
* is — not as it would sound best. `test/tool-cards.test.mjs` asserts the parts of it that can be
|
|
39
|
+
* asserted mechanically, and a change that crosses it is meant to turn that suite red.
|
|
40
|
+
*
|
|
41
|
+
* **No tool parameter names a credential, an allow-list, a key path, or a state directory.** There is
|
|
42
|
+
* no parameter through which a caller can set a token, a secret, `allowedCommands`, `p2pMode`, a
|
|
43
|
+
* `device.key` path, or another machine's identity file. `w2m_run`'s only machine-naming parameter is
|
|
44
|
+
* `executor_machine_id`, and it *narrows* one `broadcast` to a machine the relay already lists; it
|
|
45
|
+
* cannot name a machine the relay does not hold, and it cannot hand that machine anything new.
|
|
46
|
+
*
|
|
47
|
+
* **No tool can widen what a machine will run.** `allowedCommands` is read in `readConfig` from the
|
|
48
|
+
* host config and the shared `.w2m.json` / `machine.json` layers, is never written by anything here,
|
|
49
|
+
* and is applied in one direction only: {@link checkAllowedCommand} returns a refusal or `null`, and
|
|
50
|
+
* there is no path in this file that adds an entry to the list. Each target machine's own agent
|
|
51
|
+
* re-decides the same gate against its own list, so a dispatcher that lost this check would still not
|
|
52
|
+
* widen anything — the direction the shared matcher exists to keep honest (`src/agent/allowed-commands.mjs`).
|
|
53
|
+
*
|
|
54
|
+
* **No tool can change a machine's `p2pMode`.** The mode is resolved once, in `readConfig` at
|
|
55
|
+
* `apply()` time, from the config object or `W2M_P2P_MODE`, and every dispatch publishes it as a fact
|
|
56
|
+
* (`p2p: {mode}`) rather than accepting one. The *executor's* mode is its own local setting: an agent
|
|
57
|
+
* configured `direct` refuses a relay-delivered offer with `P2P_UNAVAILABLE` rather than accepting the
|
|
58
|
+
* weaker path, which is the one place a dispatcher could otherwise have quietly downgraded a peer.
|
|
59
|
+
*
|
|
60
|
+
* **No tool writes another machine's identity or keys.** `device.json` is read (`readDevice`) and
|
|
61
|
+
* `device.key` is loaded (`DeviceKey.load`) — neither is ever written here. What a dispatch publishes
|
|
62
|
+
* is this machine's own **public** key inside `p2p.static_keys`, so the executor can authenticate a
|
|
63
|
+
* dial; the private half never leaves `src/agent/crypto.mjs`.
|
|
64
|
+
*
|
|
65
|
+
* **No tool result carries a token or a key.** `w2m_status` reports `paired`,
|
|
66
|
+
* `operatorToken_configured` and `pairingCode_configured` as booleans and never the values. The
|
|
67
|
+
* operator token travels only in the `Authorization` header of `POST /v1/task` — never in the request
|
|
68
|
+
* body and never in an answer — and `direct_pushes[].static_key` is the literal marker `'published'`,
|
|
69
|
+
* not the key it describes.
|
|
70
|
+
*
|
|
71
|
+
* What an agent-facing tool *may* do across machines is bounded and stated: ask the machines the relay
|
|
72
|
+
* leased a task to run one argv inside their own allow-lists, in their own copy of the project,
|
|
73
|
+
* read-only unless `write: true` names a `write_scope`, and report what they said — refusals
|
|
74
|
+
* included, as values.
|
|
75
|
+
*
|
|
33
76
|
* # `@deepseek-ai/dsh-tools` and the offline fallback
|
|
34
77
|
*
|
|
35
78
|
* `defineTool` is imported lazily, at `apply()` time, through {@link loadDefineTool}. When the
|
|
@@ -134,7 +177,7 @@ const MAX_STAGES = 16;
|
|
|
134
177
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
135
178
|
* overrides it at runtime for tests and packagers.
|
|
136
179
|
*/
|
|
137
|
-
const PLUGIN_VERSION = '0.5.
|
|
180
|
+
const PLUGIN_VERSION = '0.5.9';
|
|
138
181
|
|
|
139
182
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
140
183
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -167,6 +210,88 @@ const RETRYABLE_STATUS = new Set([429, 502, 503, 504]);
|
|
|
167
210
|
// Errors
|
|
168
211
|
// ---------------------------------------------------------------------------------------------
|
|
169
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Every stable code a **gate in this file** refuses with, in one greppable place.
|
|
215
|
+
*
|
|
216
|
+
* The nine aggregate verdicts never reduce an outcome to a boolean; the gates that run *before* any
|
|
217
|
+
* comparison owe a caller the same courtesy, and this table is that debt paid. Each code names what
|
|
218
|
+
* was refused, and every refusal site also carries a `detail` saying what would change it, because a
|
|
219
|
+
* caller that cannot act on a refusal has been told nothing.
|
|
220
|
+
*
|
|
221
|
+
* Two of these are the agent's own vocabulary, deliberately shared rather than re-spelled:
|
|
222
|
+
* `COMMAND_NOT_ALLOWED` (`REFUSAL.COMMAND_NOT_ALLOWED` in `src/agent/agent.mjs`, which is also what
|
|
223
|
+
* `src/agent/allowed-commands.mjs` returns) and the `P2P_FRAME_*` family that already travelled into
|
|
224
|
+
* `w2m_status` as message prefixes.
|
|
225
|
+
*
|
|
226
|
+
* A code is added only when it names a *distinct remedy*. `W2M_CONFIG` still covers a malformed
|
|
227
|
+
* setting at large; the credential gate below has its own code because its remedy — set
|
|
228
|
+
* `operatorToken` — is unlike any other configuration fault's.
|
|
229
|
+
*/
|
|
230
|
+
export const REFUSAL_CODE = Object.freeze({
|
|
231
|
+
/** The dispatch pre-flight found the command outside this plugin's `allowedCommands`. */
|
|
232
|
+
COMMAND_NOT_ALLOWED: 'COMMAND_NOT_ALLOWED',
|
|
233
|
+
/**
|
|
234
|
+
* The configured `allowedCommands` could not be parsed, so the control could not be evaluated.
|
|
235
|
+
* Distinct from the code above: the remedy is to fix the list, not the command.
|
|
236
|
+
*/
|
|
237
|
+
ALLOWED_COMMANDS_UNPARSEABLE: 'ALLOWED_COMMANDS_UNPARSEABLE',
|
|
238
|
+
/** No operator credential is configured, so `POST /v1/task` cannot be authorised at all. */
|
|
239
|
+
OPERATOR_TOKEN_MISSING: 'OPERATOR_TOKEN_MISSING',
|
|
240
|
+
/** No `device.json` exists where this machine's identity was looked for. */
|
|
241
|
+
DEVICE_NOT_PAIRED: 'DEVICE_NOT_PAIRED',
|
|
242
|
+
/** `device.json` exists but could not be read as JSON. */
|
|
243
|
+
DEVICE_UNREADABLE: 'DEVICE_UNREADABLE',
|
|
244
|
+
/** `device.json` parsed but carries no `device_token`, so the device never completed pairing. */
|
|
245
|
+
DEVICE_TOKEN_ABSENT: 'DEVICE_TOKEN_ABSENT',
|
|
246
|
+
/** The relay returned a lease with no `machine_id`, so there is nobody to address an offer to. */
|
|
247
|
+
P2P_LEASE_NO_MACHINE_ID: 'P2P_LEASE_NO_MACHINE_ID',
|
|
248
|
+
/** The lease is not in a runnable state, so the relay never handed this machine the command. */
|
|
249
|
+
P2P_LEASE_NOT_RUNNABLE: 'P2P_LEASE_NOT_RUNNABLE',
|
|
250
|
+
/** A direct dial to a leased machine failed; `direct_pushes[].error` carries the node's own code. */
|
|
251
|
+
P2P_DIAL_FAILED: 'P2P_DIAL_FAILED',
|
|
252
|
+
/** The best-effort offer push threw rather than returning a refusal. */
|
|
253
|
+
P2P_PUSH_THREW: 'P2P_PUSH_THREW',
|
|
254
|
+
/** A frame arrived over a direct channel that is not a JSON document. */
|
|
255
|
+
P2P_FRAME_NOT_JSON: 'P2P_FRAME_NOT_JSON',
|
|
256
|
+
/** A frame arrived that parsed to something other than a JSON object. */
|
|
257
|
+
P2P_FRAME_NOT_AN_OBJECT: 'P2P_FRAME_NOT_AN_OBJECT',
|
|
258
|
+
/** A result frame carried no `task_id`, so it could not be stored or acknowledged. */
|
|
259
|
+
P2P_FRAME_NO_TASK_ID: 'P2P_FRAME_NO_TASK_ID',
|
|
260
|
+
/** A result frame carried no `machine_id`, so two machines' answers could collide on one file. */
|
|
261
|
+
P2P_FRAME_NO_MACHINE_ID: 'P2P_FRAME_NO_MACHINE_ID',
|
|
262
|
+
/** A result arrived with no `stateDir` to write it to. */
|
|
263
|
+
P2P_NO_STATE_DIR: 'P2P_NO_STATE_DIR',
|
|
264
|
+
/** Storing a result frame failed, so the result exists only on the sender. */
|
|
265
|
+
P2P_INBOX_WRITE_FAILED: 'P2P_INBOX_WRITE_FAILED',
|
|
266
|
+
/**
|
|
267
|
+
* The composer's selected target routed this dispatch. Not a refusal — a named *reason*, so the
|
|
268
|
+
* routing decision is greppable in the answer instead of being a bare `applied: true`.
|
|
269
|
+
*/
|
|
270
|
+
SELECTION_APPLIED: 'SELECTION_APPLIED',
|
|
271
|
+
/** Nothing is selected for this session, so the dispatch went to the whole fleet. */
|
|
272
|
+
SELECTION_NONE: 'SELECTION_NONE',
|
|
273
|
+
/** A selection exists but names no `machine_id`, so none of it could be applied. */
|
|
274
|
+
SELECTION_UNUSABLE: 'SELECTION_UNUSABLE',
|
|
275
|
+
/** The call named its own `executor_machine_id`, which wins over the session selection. */
|
|
276
|
+
SELECTION_OVERRIDDEN_BY_CALLER: 'SELECTION_OVERRIDDEN_BY_CALLER',
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The leading `CODE:` token of a message one of this project's own modules produced, or `null`.
|
|
281
|
+
*
|
|
282
|
+
* Every coded failure in this tree is written as `'CODE: what happened'`, so the code is already in
|
|
283
|
+
* the string; this lifts it back out for the places that must publish it as a field too. It refuses
|
|
284
|
+
* to invent one: a message with no such prefix returns `null` and the caller supplies its own code.
|
|
285
|
+
*
|
|
286
|
+
* @param {unknown} message
|
|
287
|
+
* @returns {string|null}
|
|
288
|
+
*/
|
|
289
|
+
function reasonCodeOf(message) {
|
|
290
|
+
if (typeof message !== 'string') return null;
|
|
291
|
+
const match = /^([A-Z][A-Z0-9_]{2,}):/.exec(message);
|
|
292
|
+
return match ? match[1] : null;
|
|
293
|
+
}
|
|
294
|
+
|
|
170
295
|
/**
|
|
171
296
|
* An error a caller can act on, carrying a stable machine-readable code.
|
|
172
297
|
*
|
|
@@ -177,12 +302,16 @@ class W2MError extends Error {
|
|
|
177
302
|
/**
|
|
178
303
|
* @param {string} code Stable code, e.g. `W2M_CONFIG`.
|
|
179
304
|
* @param {string} message What went wrong and, when known, how to fix it.
|
|
180
|
-
* @param {{hint?: string, detail?: object, cause?: unknown}} [options]
|
|
305
|
+
* @param {{hint?: string, detail?: object, cause?: unknown, reason_code?: string}} [options]
|
|
306
|
+
* `reason_code` names the *refusal* when `code` only names its class. `W2M_CONFIG` says "a
|
|
307
|
+
* setting is wrong"; `OPERATOR_TOKEN_MISSING` says which gate refused and, with `hint`, what
|
|
308
|
+
* would change it. It defaults to `code`, so every existing error keeps one obvious code.
|
|
181
309
|
*/
|
|
182
310
|
constructor(code, message, options = {}) {
|
|
183
311
|
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
|
184
312
|
this.name = 'W2MError';
|
|
185
313
|
this.code = code;
|
|
314
|
+
this.reason_code = options.reason_code ?? code;
|
|
186
315
|
this.hint = options.hint;
|
|
187
316
|
this.detail = options.detail;
|
|
188
317
|
}
|
|
@@ -198,10 +327,14 @@ class W2MError extends Error {
|
|
|
198
327
|
* @param {string} setting Dotted setting name, e.g. `rabbitUrl`.
|
|
199
328
|
* @param {string} why Why the value was refused.
|
|
200
329
|
* @param {string} hint What to set.
|
|
330
|
+
* @param {string} [reasonCode] The refusal's own code, when this is a gate rather than a typo.
|
|
201
331
|
* @returns {W2MError}
|
|
202
332
|
*/
|
|
203
|
-
function configError(setting, why, hint) {
|
|
204
|
-
return new W2MError('W2M_CONFIG', `W2M_CONFIG: configuration \`${setting}\` ${why}; ${hint}`, {
|
|
333
|
+
function configError(setting, why, hint, reasonCode) {
|
|
334
|
+
return new W2MError('W2M_CONFIG', `W2M_CONFIG: configuration \`${setting}\` ${why}; ${hint}`, {
|
|
335
|
+
hint,
|
|
336
|
+
...(reasonCode === undefined ? {} : { reason_code: reasonCode }),
|
|
337
|
+
});
|
|
205
338
|
}
|
|
206
339
|
|
|
207
340
|
// ---------------------------------------------------------------------------------------------
|
|
@@ -954,15 +1087,27 @@ function canonicalJson(value) {
|
|
|
954
1087
|
* acknowledgement is keyed by `task_id`, and two machines answering the same task must not overwrite
|
|
955
1088
|
* each other — so it is refused by name instead of being written somewhere surprising.
|
|
956
1089
|
*
|
|
1090
|
+
* Both halves of the refusal are returned: `reason` is the `CODE: sentence` string this has always
|
|
1091
|
+
* produced (it is what `w2m_status` shows), `reason_code` is the same code as a field, so a reader
|
|
1092
|
+
* that wants to branch on the refusal does not have to split a message.
|
|
1093
|
+
*
|
|
957
1094
|
* @param {object} frame
|
|
958
|
-
* @returns {string|null} The reason it is unusable, or null.
|
|
1095
|
+
* @returns {{reason_code: string, reason: string}|null} The reason it is unusable, or null.
|
|
959
1096
|
*/
|
|
960
1097
|
function inboxKeyProblem(frame) {
|
|
961
1098
|
const taskId = frame?.task_id;
|
|
962
1099
|
const machineId = frame?.machine_id;
|
|
963
|
-
if (typeof taskId !== 'string' || taskId.trim() === '')
|
|
1100
|
+
if (typeof taskId !== 'string' || taskId.trim() === '') {
|
|
1101
|
+
return {
|
|
1102
|
+
reason_code: REFUSAL_CODE.P2P_FRAME_NO_TASK_ID,
|
|
1103
|
+
reason: 'P2P_FRAME_NO_TASK_ID: `task_id` is missing or empty',
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
964
1106
|
if (typeof machineId !== 'string' || machineId.trim() === '') {
|
|
965
|
-
return
|
|
1107
|
+
return {
|
|
1108
|
+
reason_code: REFUSAL_CODE.P2P_FRAME_NO_MACHINE_ID,
|
|
1109
|
+
reason: 'P2P_FRAME_NO_MACHINE_ID: `machine_id` is missing or empty',
|
|
1110
|
+
};
|
|
966
1111
|
}
|
|
967
1112
|
return null;
|
|
968
1113
|
}
|
|
@@ -1198,11 +1343,21 @@ function offerP2PFor(published, { taskId, machineId, peerKey = null }) {
|
|
|
1198
1343
|
* @param {string|null} options.originMachineId
|
|
1199
1344
|
* @param {string|null} [options.peerKey] The target's static public key (v0.6.0), added to the
|
|
1200
1345
|
* frame's `p2p.static_keys` under that machine's id.
|
|
1201
|
-
* @returns {{frame: object|null, skipped: string|null}}
|
|
1346
|
+
* @returns {{frame: object|null, skipped: {reason_code: string, message: string}|null}} `skipped` is
|
|
1347
|
+
* a named refusal, not a sentence: a lease the relay did not address to a machine is a different
|
|
1348
|
+
* fault from one it decided must not run the command.
|
|
1202
1349
|
*/
|
|
1203
1350
|
function buildOfferFrame({ taskId, lease, payload, originMachineId, peerKey = null }) {
|
|
1204
1351
|
const machineId = typeof lease?.machine_id === 'string' && lease.machine_id !== '' ? lease.machine_id : null;
|
|
1205
|
-
if (machineId === null)
|
|
1352
|
+
if (machineId === null) {
|
|
1353
|
+
return {
|
|
1354
|
+
frame: null,
|
|
1355
|
+
skipped: {
|
|
1356
|
+
reason_code: REFUSAL_CODE.P2P_LEASE_NO_MACHINE_ID,
|
|
1357
|
+
message: 'the relay returned a lease with no machine_id',
|
|
1358
|
+
},
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1206
1361
|
|
|
1207
1362
|
const index = Number.isInteger(lease?.index) ? lease.index : 0;
|
|
1208
1363
|
// The relay's first attempt is 1, and a lease that never says otherwise is a first attempt.
|
|
@@ -1263,8 +1418,9 @@ function buildOfferFrame({ taskId, lease, payload, originMachineId, peerKey = nu
|
|
|
1263
1418
|
* well because the *dispatcher* is the initiator here and needs the executor's key to handshake.
|
|
1264
1419
|
* `peerKeys` is the node's own store, taught the same key so the accept side can verify the
|
|
1265
1420
|
* executor's handshake later without a second relay lookup.
|
|
1266
|
-
* @returns {Promise<{ok: boolean,
|
|
1267
|
-
* encrypted: boolean, encryption_reason: string|null}>}
|
|
1421
|
+
* @returns {Promise<{ok: boolean, reason_code: string|null, error: string|null, session: number|null,
|
|
1422
|
+
* reused: boolean, encrypted: boolean, encryption_reason: string|null}>} `reason_code` is null on a
|
|
1423
|
+
* delivered push and names the refusal otherwise, so the caller never has to read `error` to branch.
|
|
1268
1424
|
*/
|
|
1269
1425
|
async function pushOffer(node, frame, crypto = {}) {
|
|
1270
1426
|
const session = Number.isInteger(frame?.p2p?.session) && frame.p2p.session > 0 ? frame.p2p.session : undefined;
|
|
@@ -1288,6 +1444,9 @@ async function pushOffer(node, frame, crypto = {}) {
|
|
|
1288
1444
|
if (!dialled.ok) {
|
|
1289
1445
|
return {
|
|
1290
1446
|
ok: false,
|
|
1447
|
+
// The node writes its own refusal as `CODE: sentence`; the code is lifted out so the push
|
|
1448
|
+
// reports the *same* code as a field instead of making the reader parse a message.
|
|
1449
|
+
reason_code: reasonCodeOf(dialled.error) ?? REFUSAL_CODE.P2P_DIAL_FAILED,
|
|
1291
1450
|
error: dialled.error ?? 'P2P_DIAL_FAILED',
|
|
1292
1451
|
session: dialled.session ?? null,
|
|
1293
1452
|
reused: false,
|
|
@@ -1300,6 +1459,7 @@ async function pushOffer(node, frame, crypto = {}) {
|
|
|
1300
1459
|
await channel.send(JSON.stringify(frame));
|
|
1301
1460
|
return {
|
|
1302
1461
|
ok: true,
|
|
1462
|
+
reason_code: null,
|
|
1303
1463
|
error: null,
|
|
1304
1464
|
session: dialled.session ?? null,
|
|
1305
1465
|
reused: dialled.reused === true,
|
|
@@ -1314,6 +1474,7 @@ async function pushOffer(node, frame, crypto = {}) {
|
|
|
1314
1474
|
} catch (error) {
|
|
1315
1475
|
return {
|
|
1316
1476
|
ok: false,
|
|
1477
|
+
reason_code: REFUSAL_CODE.P2P_PUSH_THREW,
|
|
1317
1478
|
error: `P2P_PUSH_THREW: ${error instanceof Error ? error.message : String(error)}`,
|
|
1318
1479
|
session: session ?? null,
|
|
1319
1480
|
reused: false,
|
|
@@ -1342,7 +1503,9 @@ async function pushOffer(node, frame, crypto = {}) {
|
|
|
1342
1503
|
*
|
|
1343
1504
|
* @param {P2PNode} node
|
|
1344
1505
|
* @param {object} options
|
|
1345
|
-
* @returns {Promise<Array<object>>} One entry per lease considered, in lease order.
|
|
1506
|
+
* @returns {Promise<Array<object>>} One entry per lease considered, in lease order. A machine that was
|
|
1507
|
+
* not pushed to carries `ok: false` **and** a `reason_code` naming which gate refused it — this
|
|
1508
|
+
* block is a tool result, so a bare boolean here would be exactly the reduction the verdicts refuse.
|
|
1346
1509
|
*/
|
|
1347
1510
|
async function pushOffers(node, { taskId, leases, payload, originMachineId, getJson = null }) {
|
|
1348
1511
|
const out = [];
|
|
@@ -1350,27 +1513,44 @@ async function pushOffers(node, { taskId, leases, payload, originMachineId, getJ
|
|
|
1350
1513
|
const machineId = typeof lease?.machine_id === 'string' && lease.machine_id !== '' ? lease.machine_id : null;
|
|
1351
1514
|
const state = lease?.state;
|
|
1352
1515
|
if (machineId === null) {
|
|
1353
|
-
out.push({
|
|
1516
|
+
out.push({
|
|
1517
|
+
machine_id: null,
|
|
1518
|
+
ok: false,
|
|
1519
|
+
reason_code: REFUSAL_CODE.P2P_LEASE_NO_MACHINE_ID,
|
|
1520
|
+
error: 'the relay returned a lease with no machine_id',
|
|
1521
|
+
});
|
|
1354
1522
|
continue;
|
|
1355
1523
|
}
|
|
1356
1524
|
if (state !== 'queued' && state !== 'offered') {
|
|
1357
1525
|
out.push({
|
|
1358
1526
|
machine_id: machineId,
|
|
1359
1527
|
ok: false,
|
|
1528
|
+
reason_code: REFUSAL_CODE.P2P_LEASE_NOT_RUNNABLE,
|
|
1360
1529
|
error: `lease state is ${JSON.stringify(state ?? null)}, so this machine was not handed the command`,
|
|
1530
|
+
// What would change it: nothing the dispatcher can do. The relay decided, and the honest
|
|
1531
|
+
// report is that the decision was the relay's rather than a delivery failure.
|
|
1532
|
+
detail:
|
|
1533
|
+
'only leases in a runnable state (queued, offered) are pushed; this lease is refused or ' +
|
|
1534
|
+
'observing, so it was never handed the command and the relay\'s own SSE offer is the only delivery',
|
|
1361
1535
|
});
|
|
1362
1536
|
continue;
|
|
1363
1537
|
}
|
|
1364
1538
|
const peerKey = await readPeerStaticKey(getJson, machineId);
|
|
1365
1539
|
const built = buildOfferFrame({ taskId, lease, payload, originMachineId, peerKey });
|
|
1366
1540
|
if (built.frame === null) {
|
|
1367
|
-
out.push({
|
|
1541
|
+
out.push({
|
|
1542
|
+
machine_id: machineId,
|
|
1543
|
+
ok: false,
|
|
1544
|
+
reason_code: built.skipped.reason_code,
|
|
1545
|
+
error: built.skipped.message,
|
|
1546
|
+
});
|
|
1368
1547
|
continue;
|
|
1369
1548
|
}
|
|
1370
1549
|
const result = await pushOffer(node, built.frame, { peerKey, peerKeys: node?.peerKeys ?? null });
|
|
1371
1550
|
out.push({
|
|
1372
1551
|
machine_id: machineId,
|
|
1373
1552
|
ok: result.ok,
|
|
1553
|
+
reason_code: result.ok ? null : (result.reason_code ?? REFUSAL_CODE.P2P_DIAL_FAILED),
|
|
1374
1554
|
error: result.error,
|
|
1375
1555
|
dedupe_key: built.frame.dedupe_key === null ? null : 'relay-formula',
|
|
1376
1556
|
// The session this push and the executor's reverse dial meet on, so a machine's own
|
|
@@ -1678,11 +1858,19 @@ async function handleP2PMessage(runtime, event) {
|
|
|
1678
1858
|
try {
|
|
1679
1859
|
frame = JSON.parse(raw);
|
|
1680
1860
|
} catch {
|
|
1681
|
-
runtime.events.refused.push({
|
|
1861
|
+
runtime.events.refused.push({
|
|
1862
|
+
reason_code: REFUSAL_CODE.P2P_FRAME_NOT_JSON,
|
|
1863
|
+
reason: 'P2P_FRAME_NOT_JSON: the payload is not a JSON document',
|
|
1864
|
+
bytes: raw.length,
|
|
1865
|
+
});
|
|
1682
1866
|
return;
|
|
1683
1867
|
}
|
|
1684
1868
|
if (frame === null || typeof frame !== 'object' || Array.isArray(frame)) {
|
|
1685
|
-
runtime.events.refused.push({
|
|
1869
|
+
runtime.events.refused.push({
|
|
1870
|
+
reason_code: REFUSAL_CODE.P2P_FRAME_NOT_AN_OBJECT,
|
|
1871
|
+
reason: 'P2P_FRAME_NOT_AN_OBJECT: expected a JSON object',
|
|
1872
|
+
type: typeof frame,
|
|
1873
|
+
});
|
|
1686
1874
|
return;
|
|
1687
1875
|
}
|
|
1688
1876
|
if (frame.type !== 'task.result') {
|
|
@@ -1693,11 +1881,14 @@ async function handleP2PMessage(runtime, event) {
|
|
|
1693
1881
|
|
|
1694
1882
|
const problem = inboxKeyProblem(frame);
|
|
1695
1883
|
if (problem !== null) {
|
|
1696
|
-
|
|
1884
|
+
// `reason_code` travels beside `reason` so `w2m_status`'s inbox block is greppable by code rather
|
|
1885
|
+
// than by string prefix; this is a refusal at a boundary a user reads.
|
|
1886
|
+
runtime.events.refused.push({ reason_code: problem.reason_code, reason: problem.reason, type: 'task.result' });
|
|
1697
1887
|
return;
|
|
1698
1888
|
}
|
|
1699
1889
|
if (runtime.inboxDir === null) {
|
|
1700
1890
|
runtime.events.refused.push({
|
|
1891
|
+
reason_code: REFUSAL_CODE.P2P_NO_STATE_DIR,
|
|
1701
1892
|
reason: 'P2P_NO_STATE_DIR: stateDir is not set, so a received result has nowhere to be written',
|
|
1702
1893
|
type: 'task.result',
|
|
1703
1894
|
task_id: frame.task_id,
|
|
@@ -1725,7 +1916,12 @@ async function handleP2PMessage(runtime, event) {
|
|
|
1725
1916
|
} catch (error) {
|
|
1726
1917
|
// eslint-disable-next-line require-atomic-updates -- one message at a time; the write is the only await
|
|
1727
1918
|
runtime.events.last_error = `P2P_INBOX_WRITE_FAILED: ${error instanceof Error ? error.message : String(error)}`;
|
|
1728
|
-
runtime.events.refused.push({
|
|
1919
|
+
runtime.events.refused.push({
|
|
1920
|
+
reason_code: REFUSAL_CODE.P2P_INBOX_WRITE_FAILED,
|
|
1921
|
+
reason: runtime.events.last_error,
|
|
1922
|
+
type: 'task.result',
|
|
1923
|
+
task_id: frame.task_id,
|
|
1924
|
+
});
|
|
1729
1925
|
return;
|
|
1730
1926
|
}
|
|
1731
1927
|
runtime.events.results += 1;
|
|
@@ -1886,6 +2082,10 @@ function requireOperatorToken(cfg) {
|
|
|
1886
2082
|
'`POST /v1/task` requires the operator token',
|
|
1887
2083
|
'set `operatorToken` in this plugin\'s profile patch (or `W2M_OPERATOR_TOKEN` in the environment) ' +
|
|
1888
2084
|
'to the value the relay printed at startup, or read it from `<state>/operator-token.txt`',
|
|
2085
|
+
// The gate's own code. `W2M_CONFIG` names the class of the fault; this names the refusal, and it
|
|
2086
|
+
// is the one credential this plugin holds, so "why was my dispatch refused" must not have to be
|
|
2087
|
+
// answered by reading a message.
|
|
2088
|
+
REFUSAL_CODE.OPERATOR_TOKEN_MISSING,
|
|
1889
2089
|
);
|
|
1890
2090
|
}
|
|
1891
2091
|
|
|
@@ -2358,6 +2558,10 @@ async function readDevice(cfg) {
|
|
|
2358
2558
|
/**
|
|
2359
2559
|
* Require a `device_token`, naming where it was looked for when it is absent.
|
|
2360
2560
|
*
|
|
2561
|
+
* The `code` stays `W2M_NO_TOKEN` for every case — one class, "this machine cannot authenticate" —
|
|
2562
|
+
* while `reason_code` names which of the three it is, because the three have different remedies: pair
|
|
2563
|
+
* the agent, repair the file, or finish a pairing that never completed.
|
|
2564
|
+
*
|
|
2361
2565
|
* @param {{machine_id: string|null, device_token: string|null, path: string|null, present: boolean, error: string|null}} device
|
|
2362
2566
|
* @returns {string}
|
|
2363
2567
|
*/
|
|
@@ -2368,20 +2572,23 @@ function requireToken(device) {
|
|
|
2368
2572
|
'W2M_NO_TOKEN',
|
|
2369
2573
|
`W2M_NO_TOKEN: no paired device at \`${device.path ?? 'device.json'}\` - the file does not exist; ` +
|
|
2370
2574
|
'start the agent once so it can pair, or set `stateDir` to the directory holding an already-paired device.json',
|
|
2371
|
-
{
|
|
2575
|
+
{
|
|
2576
|
+
hint: 'start the agent once so it can pair, or set `stateDir` to the directory holding an already-paired device.json',
|
|
2577
|
+
reason_code: REFUSAL_CODE.DEVICE_NOT_PAIRED,
|
|
2578
|
+
},
|
|
2372
2579
|
);
|
|
2373
2580
|
}
|
|
2374
2581
|
if (device.error) {
|
|
2375
2582
|
throw new W2MError(
|
|
2376
2583
|
'W2M_NO_TOKEN',
|
|
2377
2584
|
`W2M_NO_TOKEN: could not read a device token from \`${device.path}\` - ${device.error}; repair or delete the file and pair again`,
|
|
2378
|
-
{ hint: 'repair or delete the file and pair again' },
|
|
2585
|
+
{ hint: 'repair or delete the file and pair again', reason_code: REFUSAL_CODE.DEVICE_UNREADABLE },
|
|
2379
2586
|
);
|
|
2380
2587
|
}
|
|
2381
2588
|
throw new W2MError(
|
|
2382
2589
|
'W2M_NO_TOKEN',
|
|
2383
2590
|
`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`,
|
|
2384
|
-
{ hint: 'the device has not completed POST /v1/pair yet; run the agent once to pair it' },
|
|
2591
|
+
{ hint: 'the device has not completed POST /v1/pair yet; run the agent once to pair it', reason_code: REFUSAL_CODE.DEVICE_TOKEN_ABSENT },
|
|
2385
2592
|
);
|
|
2386
2593
|
}
|
|
2387
2594
|
|
|
@@ -2529,9 +2736,14 @@ function normalizeExecToken(value) {
|
|
|
2529
2736
|
* documented config `['node --test','git status --porcelain']` refused `['git','rev-parse','HEAD']`
|
|
2530
2737
|
* -- stricter than the gate, which is the direction that silently loses work.
|
|
2531
2738
|
*
|
|
2739
|
+
* A refusal names itself: `reason_code` is the matcher's own code (so the two ends cannot report one
|
|
2740
|
+
* refusal under two spellings) and `detail` says what would change it. The two ways this control
|
|
2741
|
+
* refuses are kept apart — a command outside the list and a list that cannot be parsed have
|
|
2742
|
+
* different remedies, and sharing one code would send the reader to the wrong one.
|
|
2743
|
+
*
|
|
2532
2744
|
* @param {string[]} allowedCommands Entries as configured, e.g. `['node --test']`.
|
|
2533
2745
|
* @param {string[]} argv
|
|
2534
|
-
* @returns {{
|
|
2746
|
+
* @returns {{allowed: string[], reason_code: string, message: string, detail: string}|null}
|
|
2535
2747
|
*/
|
|
2536
2748
|
function checkAllowedCommand(allowedCommands, argv) {
|
|
2537
2749
|
if (allowedCommands.length === 0) return null;
|
|
@@ -2542,20 +2754,32 @@ function checkAllowedCommand(allowedCommands, argv) {
|
|
|
2542
2754
|
// Fail closed: an unparseable list is not an empty one, and a control that cannot be evaluated
|
|
2543
2755
|
// must refuse rather than wave the command through.
|
|
2544
2756
|
return {
|
|
2545
|
-
code: 'COMMAND_NOT_ALLOWED',
|
|
2546
|
-
message: `the plugin's allowedCommands could not be parsed (${error?.message ?? String(error)})`,
|
|
2547
2757
|
allowed: [],
|
|
2758
|
+
reason_code: REFUSAL_CODE.ALLOWED_COMMANDS_UNPARSEABLE,
|
|
2759
|
+
message: `the plugin's allowedCommands could not be parsed (${error?.message ?? String(error)})`,
|
|
2760
|
+
detail:
|
|
2761
|
+
'the configured list is not a JSON array of command prefixes (entries are split on ' +
|
|
2762
|
+
'whitespace, and commas are not separators); fix `allowedCommands` in the profile patch, ' +
|
|
2763
|
+
'.w2m.json or machine.json - until it parses, this control cannot be evaluated and every ' +
|
|
2764
|
+
'dispatch is refused',
|
|
2548
2765
|
};
|
|
2549
2766
|
}
|
|
2550
2767
|
// Prefixes "as written": the configured entries, whitespace-normalised, in a stable order.
|
|
2551
2768
|
const allowed = prefixes.map((prefix) => prefix.join(' ')).sort();
|
|
2552
2769
|
const entries = prefixes.map((prefix) => [normalizeExecToken(prefix[0]), ...prefix.slice(1)]);
|
|
2553
2770
|
const command = [normalizeExecToken(argv[0]), ...argv.slice(1)];
|
|
2554
|
-
|
|
2771
|
+
const match = matchAllowedCommand(command, entries);
|
|
2772
|
+
if (match.allowed) return null;
|
|
2555
2773
|
return {
|
|
2556
|
-
code: 'COMMAND_NOT_ALLOWED',
|
|
2557
|
-
message: `\`${argv.join(' ')}\` is not in this plugin's allowedCommands (${allowed.join(', ')})`,
|
|
2558
2774
|
allowed,
|
|
2775
|
+
// The shared matcher's code, not a second copy of it: `COMMAND_NOT_ALLOWED` is the literal the
|
|
2776
|
+
// agent writes into its own envelope, so a machine's refusal and this pre-flight's refusal read
|
|
2777
|
+
// the same to whoever has to act on them.
|
|
2778
|
+
reason_code: match.reason_code ?? REFUSAL_CODE.COMMAND_NOT_ALLOWED,
|
|
2779
|
+
message: `\`${argv.join(' ')}\` is not in this plugin's allowedCommands (${allowed.join(', ')})`,
|
|
2780
|
+
detail:
|
|
2781
|
+
match.detail ??
|
|
2782
|
+
`add \`${argv.join(' ')}\` (or a prefix of it) to allowedCommands, or run a command the list already covers`,
|
|
2559
2783
|
};
|
|
2560
2784
|
}
|
|
2561
2785
|
|
|
@@ -3280,6 +3504,38 @@ export async function apply(ctx, config = {}) {
|
|
|
3280
3504
|
? null
|
|
3281
3505
|
: routedBy.applied_as;
|
|
3282
3506
|
|
|
3507
|
+
/**
|
|
3508
|
+
* *Why* the selection did or did not route this task, as a code rather than a bare boolean.
|
|
3509
|
+
*
|
|
3510
|
+
* `applied` alone conflates three different situations an operator has to tell apart: nothing
|
|
3511
|
+
* was selected, the selection was overridden by the call's own `executor_machine_id`, or a
|
|
3512
|
+
* selection exists that names no machine at all and therefore routed nothing. The last one is
|
|
3513
|
+
* the one that used to be invisible — a stored selection with no `machine_id` produces
|
|
3514
|
+
* `applied: false` and nothing else, which looks exactly like "the whole fleet ran it".
|
|
3515
|
+
*/
|
|
3516
|
+
const selectionReason = selection === null
|
|
3517
|
+
? {
|
|
3518
|
+
reason_code: REFUSAL_CODE.SELECTION_NONE,
|
|
3519
|
+
detail: 'no machine is selected for this session, so this dispatch was not narrowed to one',
|
|
3520
|
+
}
|
|
3521
|
+
: selectionAppliedAs !== null
|
|
3522
|
+
? {
|
|
3523
|
+
reason_code: REFUSAL_CODE.SELECTION_APPLIED,
|
|
3524
|
+
detail: `the composer's selected machine routed this dispatch, sent as ${selectionAppliedAs}=${selection.machine_id}`,
|
|
3525
|
+
}
|
|
3526
|
+
: mode === 'broadcast' && executorMachineId !== null
|
|
3527
|
+
? {
|
|
3528
|
+
reason_code: REFUSAL_CODE.SELECTION_OVERRIDDEN_BY_CALLER,
|
|
3529
|
+
detail: `this call named executor_machine_id=${executorMachineId}, which wins over the session selection and was sent instead`,
|
|
3530
|
+
}
|
|
3531
|
+
: {
|
|
3532
|
+
reason_code: REFUSAL_CODE.SELECTION_UNUSABLE,
|
|
3533
|
+
detail:
|
|
3534
|
+
'a machine is selected for this session but the selection names no machine_id, so ' +
|
|
3535
|
+
'nothing could be applied and the dispatch was not narrowed; pick a machine in the ' +
|
|
3536
|
+
'composer again, or clear the selection',
|
|
3537
|
+
};
|
|
3538
|
+
|
|
3283
3539
|
// Plugin-side allow-list check: only when one is configured. See checkAllowedCommand.
|
|
3284
3540
|
//
|
|
3285
3541
|
// **Every** stage is checked, not just the first. Checking only `command_argv` would let a
|
|
@@ -3291,14 +3547,21 @@ export async function apply(ctx, config = {}) {
|
|
|
3291
3547
|
for (const [stageIndex, stageArgv] of commandsToCheck.entries()) {
|
|
3292
3548
|
const refused = checkAllowedCommand(cfg.allowedCommands, stageArgv);
|
|
3293
3549
|
if (refused) {
|
|
3550
|
+
// The refusal is a *value*, like every other verdict this project reports, and it carries
|
|
3551
|
+
// the code twice on purpose: `refusal_reason` is the field a caller and a report already
|
|
3552
|
+
// read, `reason_code` is the same code under the name the rest of the gates now use, and
|
|
3553
|
+
// `detail` is what would change it. No request is made, so this result is the only trace
|
|
3554
|
+
// of the decision the caller gets.
|
|
3294
3555
|
return JSON.stringify(
|
|
3295
3556
|
{
|
|
3296
3557
|
ok: false,
|
|
3297
3558
|
state: 'refused',
|
|
3298
|
-
refusal_reason: refused.
|
|
3559
|
+
refusal_reason: refused.reason_code,
|
|
3560
|
+
reason_code: refused.reason_code,
|
|
3299
3561
|
message: commandsToCheck.length > 1
|
|
3300
3562
|
? `stage ${stageIndex}: ${refused.message}`
|
|
3301
3563
|
: refused.message,
|
|
3564
|
+
detail: refused.detail,
|
|
3302
3565
|
stage_index: commandsToCheck.length > 1 ? stageIndex : null,
|
|
3303
3566
|
allowed: refused.allowed,
|
|
3304
3567
|
},
|
|
@@ -3504,15 +3767,17 @@ export async function apply(ctx, config = {}) {
|
|
|
3504
3767
|
// v0.6.1: whether this dispatch was narrowed to the composer's selected machine, and to
|
|
3505
3768
|
// which one. Published because a dispatch that ran somewhere other than where the operator
|
|
3506
3769
|
// pointed is the one failure this feature could hide, and a caller that cannot see the
|
|
3507
|
-
// routing cannot report it.
|
|
3770
|
+
// routing cannot report it. `applied` is kept as the boolean it has always been; the code
|
|
3771
|
+
// and detail beside it say which of the four situations produced it.
|
|
3508
3772
|
selection: selection === null
|
|
3509
|
-
? { applied: false, field: null, machine_id: null, machine_name: null, session: sessionKey }
|
|
3773
|
+
? { applied: false, field: null, machine_id: null, machine_name: null, session: sessionKey, ...selectionReason }
|
|
3510
3774
|
: {
|
|
3511
3775
|
applied: selectionAppliedAs !== null,
|
|
3512
3776
|
field: selectionAppliedAs,
|
|
3513
3777
|
machine_id: selection.machine_id,
|
|
3514
3778
|
machine_name: selection.machine_name,
|
|
3515
3779
|
session: sessionKey,
|
|
3780
|
+
...selectionReason,
|
|
3516
3781
|
},
|
|
3517
3782
|
note: anchors.base_tree === null
|
|
3518
3783
|
? 'this machine\'s working tree could not be fingerprinted, so peers have no anchor to match and the aggregate will be unverifiable'
|