@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.
- package/CHANGELOG.md +384 -7
- package/CONTRIBUTING.md +14 -0
- package/PROTOCOL-v0.4.0.md +11 -3
- package/PROTOCOL.md +69 -15
- package/README.md +180 -27
- package/SECURITY.md +69 -19
- package/TROUBLESHOOTING.md +100 -53
- package/bin/w2m-rabbit.mjs +32 -5
- package/deploy/networking/LESSONS.md +18 -1
- package/deploy/networking/README.md +32 -7
- package/deploy/windows/README.md +20 -1
- package/deploy/windows/install-service.ps1 +98 -19
- package/deploy/windows/verify-supervisor.mjs +794 -0
- package/deploy/windows/w2m-agent-supervisor.mjs +867 -40
- package/docs/AUDIT.md +10 -1
- package/docs/DEPLOY.md +16 -3
- package/docs/P2P-CRYPTO.md +105 -4
- package/docs/PIPELINE-DESIGN.md +35 -10
- package/docs/TROUBLESHOOTING.md +22 -3
- package/package.json +6 -6
- package/src/agent/agent.mjs +152 -5
- package/src/agent/allowed-commands.mjs +539 -15
- package/src/agent/crypto.mjs +238 -40
- package/src/agent/exec.mjs +60 -4
- package/src/agent/p2p-node.mjs +40 -0
- package/src/agent/p2p.mjs +323 -30
- package/src/agent/resolve.mjs +68 -3
- package/src/plugin/fleet-target.mjs +160 -15
- package/src/plugin/tools.mjs +319 -56
- package/src/relay/server.mjs +74 -11
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.
|
|
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}`, {
|
|
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
|
|
@@ -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
|
|
1634
|
-
*
|
|
1635
|
-
* a disposer would break the release's own
|
|
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({
|
|
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({
|
|
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
|
-
|
|
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({
|
|
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
|
-
{
|
|
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 {{
|
|
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
|
|
2553
|
-
|
|
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.
|
|
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'
|