@twinsearth/w2m-dsh-plugin 0.5.8 → 0.6.1

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.8';
180
+ const PLUGIN_VERSION = '0.6.1';
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
@@ -1630,9 +1810,15 @@ async function initP2P(ctx, cfg, runtime, block) {
1630
1810
  * Registered *here*, after the node exists, rather than in `createP2PRuntime` — an effect that owns
1631
1811
  * nothing is a disposer that releases nothing, and `relay` mode and a machine with no identity must
1632
1812
  * leave the host's effect list exactly as they found it. A host that provides no `ctx.effect` (an
1633
- * older Cordis, or a bare test context) still gets a working node: the socket is `unref()`ed, so it
1634
- * cannot keep the process alive, and refusing to start the direct path because the host cannot track
1635
- * a disposer would break the release's own default.
1813
+ * older Cordis, or a bare test context) still gets a working node: the node itself is asked not to
1814
+ * hold the process open (`node.unref()`, below), so it cannot keep the process alive, and refusing
1815
+ * to start the direct path because the host cannot track a disposer would break the release's own
1816
+ * default.
1817
+ *
1818
+ * v0.5.10: `node.unref()` is a real call, not a description. Until it was added this comment
1819
+ * claimed an `unref()` that existed nowhere in the plugin or the node — the socket went on holding
1820
+ * the event loop, so a host without `ctx.effect` that started a node never exited (found by an
1821
+ * independent verifier against the `w2m_status` test's bare context).
1636
1822
  */
1637
1823
  if (typeof ctx?.effect === 'function') {
1638
1824
  ctx.effect(() => () => {
@@ -1644,6 +1830,11 @@ async function initP2P(ctx, cfg, runtime, block) {
1644
1830
  runtime.events.last_error = error instanceof Error ? error.message : String(error);
1645
1831
  });
1646
1832
  });
1833
+ } else {
1834
+ // No disposer will ever be called by the host, so the node must not be the reason the process
1835
+ // stays up. Called before `start()`: the node remembers it and unrefs the socket it binds, which
1836
+ // is the only ordering that leaves no window where the loop is held open.
1837
+ runtime.node?.unref?.();
1647
1838
  }
1648
1839
 
1649
1840
  // eslint-disable-next-line require-atomic-updates -- single-entry: `ensureStarted` assigns `starting` before this runs
@@ -1678,11 +1869,19 @@ async function handleP2PMessage(runtime, event) {
1678
1869
  try {
1679
1870
  frame = JSON.parse(raw);
1680
1871
  } catch {
1681
- runtime.events.refused.push({ reason: 'P2P_FRAME_NOT_JSON: the payload is not a JSON document', bytes: raw.length });
1872
+ runtime.events.refused.push({
1873
+ reason_code: REFUSAL_CODE.P2P_FRAME_NOT_JSON,
1874
+ reason: 'P2P_FRAME_NOT_JSON: the payload is not a JSON document',
1875
+ bytes: raw.length,
1876
+ });
1682
1877
  return;
1683
1878
  }
1684
1879
  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 });
1880
+ runtime.events.refused.push({
1881
+ reason_code: REFUSAL_CODE.P2P_FRAME_NOT_AN_OBJECT,
1882
+ reason: 'P2P_FRAME_NOT_AN_OBJECT: expected a JSON object',
1883
+ type: typeof frame,
1884
+ });
1686
1885
  return;
1687
1886
  }
1688
1887
  if (frame.type !== 'task.result') {
@@ -1693,11 +1892,14 @@ async function handleP2PMessage(runtime, event) {
1693
1892
 
1694
1893
  const problem = inboxKeyProblem(frame);
1695
1894
  if (problem !== null) {
1696
- runtime.events.refused.push({ reason: problem, type: 'task.result' });
1895
+ // `reason_code` travels beside `reason` so `w2m_status`'s inbox block is greppable by code rather
1896
+ // than by string prefix; this is a refusal at a boundary a user reads.
1897
+ runtime.events.refused.push({ reason_code: problem.reason_code, reason: problem.reason, type: 'task.result' });
1697
1898
  return;
1698
1899
  }
1699
1900
  if (runtime.inboxDir === null) {
1700
1901
  runtime.events.refused.push({
1902
+ reason_code: REFUSAL_CODE.P2P_NO_STATE_DIR,
1701
1903
  reason: 'P2P_NO_STATE_DIR: stateDir is not set, so a received result has nowhere to be written',
1702
1904
  type: 'task.result',
1703
1905
  task_id: frame.task_id,
@@ -1725,7 +1927,12 @@ async function handleP2PMessage(runtime, event) {
1725
1927
  } catch (error) {
1726
1928
  // eslint-disable-next-line require-atomic-updates -- one message at a time; the write is the only await
1727
1929
  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 });
1930
+ runtime.events.refused.push({
1931
+ reason_code: REFUSAL_CODE.P2P_INBOX_WRITE_FAILED,
1932
+ reason: runtime.events.last_error,
1933
+ type: 'task.result',
1934
+ task_id: frame.task_id,
1935
+ });
1729
1936
  return;
1730
1937
  }
1731
1938
  runtime.events.results += 1;
@@ -1886,6 +2093,10 @@ function requireOperatorToken(cfg) {
1886
2093
  '`POST /v1/task` requires the operator token',
1887
2094
  'set `operatorToken` in this plugin\'s profile patch (or `W2M_OPERATOR_TOKEN` in the environment) ' +
1888
2095
  'to the value the relay printed at startup, or read it from `<state>/operator-token.txt`',
2096
+ // The gate's own code. `W2M_CONFIG` names the class of the fault; this names the refusal, and it
2097
+ // is the one credential this plugin holds, so "why was my dispatch refused" must not have to be
2098
+ // answered by reading a message.
2099
+ REFUSAL_CODE.OPERATOR_TOKEN_MISSING,
1889
2100
  );
1890
2101
  }
1891
2102
 
@@ -2358,6 +2569,10 @@ async function readDevice(cfg) {
2358
2569
  /**
2359
2570
  * Require a `device_token`, naming where it was looked for when it is absent.
2360
2571
  *
2572
+ * The `code` stays `W2M_NO_TOKEN` for every case — one class, "this machine cannot authenticate" —
2573
+ * while `reason_code` names which of the three it is, because the three have different remedies: pair
2574
+ * the agent, repair the file, or finish a pairing that never completed.
2575
+ *
2361
2576
  * @param {{machine_id: string|null, device_token: string|null, path: string|null, present: boolean, error: string|null}} device
2362
2577
  * @returns {string}
2363
2578
  */
@@ -2368,20 +2583,23 @@ function requireToken(device) {
2368
2583
  'W2M_NO_TOKEN',
2369
2584
  `W2M_NO_TOKEN: no paired device at \`${device.path ?? 'device.json'}\` - the file does not exist; ` +
2370
2585
  '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' },
2586
+ {
2587
+ hint: 'start the agent once so it can pair, or set `stateDir` to the directory holding an already-paired device.json',
2588
+ reason_code: REFUSAL_CODE.DEVICE_NOT_PAIRED,
2589
+ },
2372
2590
  );
2373
2591
  }
2374
2592
  if (device.error) {
2375
2593
  throw new W2MError(
2376
2594
  'W2M_NO_TOKEN',
2377
2595
  `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' },
2596
+ { hint: 'repair or delete the file and pair again', reason_code: REFUSAL_CODE.DEVICE_UNREADABLE },
2379
2597
  );
2380
2598
  }
2381
2599
  throw new W2MError(
2382
2600
  'W2M_NO_TOKEN',
2383
2601
  `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' },
2602
+ { 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
2603
  );
2386
2604
  }
2387
2605
 
@@ -2493,27 +2711,6 @@ async function resolveTreeFingerprint(projectDir, timeoutMs) {
2493
2711
  // Command policy
2494
2712
  // ---------------------------------------------------------------------------------------------
2495
2713
 
2496
- /**
2497
- * The comparable form of an **executable token**: basename, lower case, no `.exe`/`.cmd`/`.bat`/`.com`.
2498
- *
2499
- * Applied to `argv[0]` and to the first token of an entry, never to the arguments after it --
2500
- * `--reporter=TAP` and `--reporter=tap` are different arguments, and collapsing them would authorise
2501
- * something nobody wrote.
2502
- *
2503
- * This is the plugin's long-documented tolerance (`allowedCommands: ['Node.exe']` accepts
2504
- * `C:\Program Files\nodejs\node.exe`), and it is deliberately platform-independent: the old check
2505
- * normalised the separator itself rather than asking `path`, which is what makes the documented
2506
- * entry work the same on a POSIX dispatcher. Everything *after* the executable token is compared
2507
- * verbatim by the shared matcher, which is where the prefix rule lives.
2508
- *
2509
- * @param {string} value
2510
- * @returns {string}
2511
- */
2512
- function normalizeExecToken(value) {
2513
- const base = path.basename(String(value).trim().replace(/\\/g, '/')).toLowerCase();
2514
- return base.replace(/\.(exe|cmd|bat|com)$/, '');
2515
- }
2516
-
2517
2714
  /**
2518
2715
  * Check one argv against the configured allow-list, with the **same rule the agent applies**.
2519
2716
  *
@@ -2529,9 +2726,23 @@ function normalizeExecToken(value) {
2529
2726
  * documented config `['node --test','git status --porcelain']` refused `['git','rev-parse','HEAD']`
2530
2727
  * -- stricter than the gate, which is the direction that silently loses work.
2531
2728
  *
2729
+ * The argv is handed over **verbatim**, including `argv[0]`. It used to be reduced to a basename
2730
+ * first, and that was the plugin half of a defect: the matcher decides executable identity by
2731
+ * resolving a path, and a pre-flight that strips the path can only ever compare names, so it would
2732
+ * wave `E:\Temp\evil\node.exe` through while the agent refused it. Passing the argv through
2733
+ * unchanged is what keeps the two ends one rule. What the shared matcher then resolves, it resolves
2734
+ * against **this** process's environment — the dispatcher's PATH — which is a real limit of a
2735
+ * pre-flight (the executing machine's PATH is the one that will decide), and the reason the refusal
2736
+ * that matters is the agent's own.
2737
+ *
2738
+ * A refusal names itself: `reason_code` is the matcher's own code (so the two ends cannot report one
2739
+ * refusal under two spellings) and `detail` says what would change it. The two ways this control
2740
+ * refuses are kept apart — a command outside the list and a list that cannot be parsed have
2741
+ * different remedies, and sharing one code would send the reader to the wrong one.
2742
+ *
2532
2743
  * @param {string[]} allowedCommands Entries as configured, e.g. `['node --test']`.
2533
2744
  * @param {string[]} argv
2534
- * @returns {{code: string, message: string, allowed: string[]}|null}
2745
+ * @returns {{allowed: string[], reason_code: string, message: string, detail: string}|null}
2535
2746
  */
2536
2747
  function checkAllowedCommand(allowedCommands, argv) {
2537
2748
  if (allowedCommands.length === 0) return null;
@@ -2542,20 +2753,31 @@ function checkAllowedCommand(allowedCommands, argv) {
2542
2753
  // Fail closed: an unparseable list is not an empty one, and a control that cannot be evaluated
2543
2754
  // must refuse rather than wave the command through.
2544
2755
  return {
2545
- code: 'COMMAND_NOT_ALLOWED',
2546
- message: `the plugin's allowedCommands could not be parsed (${error?.message ?? String(error)})`,
2547
2756
  allowed: [],
2757
+ reason_code: REFUSAL_CODE.ALLOWED_COMMANDS_UNPARSEABLE,
2758
+ message: `the plugin's allowedCommands could not be parsed (${error?.message ?? String(error)})`,
2759
+ detail:
2760
+ 'the configured list is not a JSON array of command prefixes (entries are split on ' +
2761
+ 'whitespace, and commas are not separators); fix `allowedCommands` in the profile patch, ' +
2762
+ '.w2m.json or machine.json - until it parses, this control cannot be evaluated and every ' +
2763
+ 'dispatch is refused',
2548
2764
  };
2549
2765
  }
2550
2766
  // Prefixes "as written": the configured entries, whitespace-normalised, in a stable order.
2551
2767
  const allowed = prefixes.map((prefix) => prefix.join(' ')).sort();
2552
- const entries = prefixes.map((prefix) => [normalizeExecToken(prefix[0]), ...prefix.slice(1)]);
2553
- const command = [normalizeExecToken(argv[0]), ...argv.slice(1)];
2554
- if (matchAllowedCommand(command, entries).allowed) return null;
2768
+ const match = matchAllowedCommand(argv, prefixes);
2769
+ if (match.allowed) return null;
2555
2770
  return {
2556
- code: 'COMMAND_NOT_ALLOWED',
2557
- message: `\`${argv.join(' ')}\` is not in this plugin's allowedCommands (${allowed.join(', ')})`,
2558
2771
  allowed,
2772
+ // The shared matcher's code, not a second copy of it: `COMMAND_NOT_ALLOWED` is the literal the
2773
+ // agent writes into its own envelope, so a machine's refusal and this pre-flight's refusal read
2774
+ // the same to whoever has to act on them. The matcher's three newer codes travel the same way
2775
+ // for the same reason.
2776
+ reason_code: match.reason_code ?? REFUSAL_CODE.COMMAND_NOT_ALLOWED,
2777
+ message: `\`${argv.join(' ')}\` is not in this plugin's allowedCommands (${allowed.join(', ')})`,
2778
+ detail:
2779
+ match.detail ??
2780
+ `add \`${argv.join(' ')}\` (or a prefix of it) to allowedCommands, or run a command the list already covers`,
2559
2781
  };
2560
2782
  }
2561
2783
 
@@ -3280,6 +3502,38 @@ export async function apply(ctx, config = {}) {
3280
3502
  ? null
3281
3503
  : routedBy.applied_as;
3282
3504
 
3505
+ /**
3506
+ * *Why* the selection did or did not route this task, as a code rather than a bare boolean.
3507
+ *
3508
+ * `applied` alone conflates three different situations an operator has to tell apart: nothing
3509
+ * was selected, the selection was overridden by the call's own `executor_machine_id`, or a
3510
+ * selection exists that names no machine at all and therefore routed nothing. The last one is
3511
+ * the one that used to be invisible — a stored selection with no `machine_id` produces
3512
+ * `applied: false` and nothing else, which looks exactly like "the whole fleet ran it".
3513
+ */
3514
+ const selectionReason = selection === null
3515
+ ? {
3516
+ reason_code: REFUSAL_CODE.SELECTION_NONE,
3517
+ detail: 'no machine is selected for this session, so this dispatch was not narrowed to one',
3518
+ }
3519
+ : selectionAppliedAs !== null
3520
+ ? {
3521
+ reason_code: REFUSAL_CODE.SELECTION_APPLIED,
3522
+ detail: `the composer's selected machine routed this dispatch, sent as ${selectionAppliedAs}=${selection.machine_id}`,
3523
+ }
3524
+ : mode === 'broadcast' && executorMachineId !== null
3525
+ ? {
3526
+ reason_code: REFUSAL_CODE.SELECTION_OVERRIDDEN_BY_CALLER,
3527
+ detail: `this call named executor_machine_id=${executorMachineId}, which wins over the session selection and was sent instead`,
3528
+ }
3529
+ : {
3530
+ reason_code: REFUSAL_CODE.SELECTION_UNUSABLE,
3531
+ detail:
3532
+ 'a machine is selected for this session but the selection names no machine_id, so ' +
3533
+ 'nothing could be applied and the dispatch was not narrowed; pick a machine in the ' +
3534
+ 'composer again, or clear the selection',
3535
+ };
3536
+
3283
3537
  // Plugin-side allow-list check: only when one is configured. See checkAllowedCommand.
3284
3538
  //
3285
3539
  // **Every** stage is checked, not just the first. Checking only `command_argv` would let a
@@ -3291,14 +3545,21 @@ export async function apply(ctx, config = {}) {
3291
3545
  for (const [stageIndex, stageArgv] of commandsToCheck.entries()) {
3292
3546
  const refused = checkAllowedCommand(cfg.allowedCommands, stageArgv);
3293
3547
  if (refused) {
3548
+ // The refusal is a *value*, like every other verdict this project reports, and it carries
3549
+ // the code twice on purpose: `refusal_reason` is the field a caller and a report already
3550
+ // read, `reason_code` is the same code under the name the rest of the gates now use, and
3551
+ // `detail` is what would change it. No request is made, so this result is the only trace
3552
+ // of the decision the caller gets.
3294
3553
  return JSON.stringify(
3295
3554
  {
3296
3555
  ok: false,
3297
3556
  state: 'refused',
3298
- refusal_reason: refused.code,
3557
+ refusal_reason: refused.reason_code,
3558
+ reason_code: refused.reason_code,
3299
3559
  message: commandsToCheck.length > 1
3300
3560
  ? `stage ${stageIndex}: ${refused.message}`
3301
3561
  : refused.message,
3562
+ detail: refused.detail,
3302
3563
  stage_index: commandsToCheck.length > 1 ? stageIndex : null,
3303
3564
  allowed: refused.allowed,
3304
3565
  },
@@ -3504,15 +3765,17 @@ export async function apply(ctx, config = {}) {
3504
3765
  // v0.6.1: whether this dispatch was narrowed to the composer's selected machine, and to
3505
3766
  // which one. Published because a dispatch that ran somewhere other than where the operator
3506
3767
  // pointed is the one failure this feature could hide, and a caller that cannot see the
3507
- // routing cannot report it.
3768
+ // routing cannot report it. `applied` is kept as the boolean it has always been; the code
3769
+ // and detail beside it say which of the four situations produced it.
3508
3770
  selection: selection === null
3509
- ? { applied: false, field: null, machine_id: null, machine_name: null, session: sessionKey }
3771
+ ? { applied: false, field: null, machine_id: null, machine_name: null, session: sessionKey, ...selectionReason }
3510
3772
  : {
3511
3773
  applied: selectionAppliedAs !== null,
3512
3774
  field: selectionAppliedAs,
3513
3775
  machine_id: selection.machine_id,
3514
3776
  machine_name: selection.machine_name,
3515
3777
  session: sessionKey,
3778
+ ...selectionReason,
3516
3779
  },
3517
3780
  note: anchors.base_tree === null
3518
3781
  ? 'this machine\'s working tree could not be fingerprinted, so peers have no anchor to match and the aggregate will be unverifiable'