@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.
@@ -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.7';
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}`, { 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() === '') return 'P2P_FRAME_NO_TASK_ID: `task_id` is missing or empty';
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 'P2P_FRAME_NO_MACHINE_ID: `machine_id` is missing or empty';
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) return { frame: null, skipped: 'the relay returned a lease with no machine_id' };
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, error: string|null, session: number|null, reused: 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({ machine_id: null, ok: false, error: 'the relay returned a lease with no machine_id' });
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({ machine_id: machineId, ok: false, error: built.skipped });
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({ reason: 'P2P_FRAME_NOT_JSON: the payload is not a JSON document', bytes: raw.length });
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({ reason: 'P2P_FRAME_NOT_AN_OBJECT: expected a JSON object', type: typeof frame });
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
- runtime.events.refused.push({ reason: problem, type: 'task.result' });
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({ reason: runtime.events.last_error, type: 'task.result', task_id: frame.task_id });
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
- { hint: 'start the agent once so it can pair, or set `stateDir` to the directory holding an already-paired device.json' },
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 {{code: string, message: string, allowed: string[]}|null}
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
- if (matchAllowedCommand(command, entries).allowed) return null;
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.code,
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'