@twinsearth/w2m-dsh-plugin 0.5.8 → 0.5.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +98 -6
- package/CONTRIBUTING.md +14 -0
- package/package.json +3 -3
- package/src/agent/allowed-commands.mjs +67 -5
- package/src/agent/exec.mjs +60 -4
- package/src/plugin/tools.mjs +295 -30
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,99 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
|
+
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [0.5.9] — 2026-10-11
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
* **A release pre-flight, as one entry point: `node scripts/preflight.mjs`.** A release is now
|
|
12
|
+
something you start by running it, and a green run is the precondition for the version bump. It
|
|
13
|
+
checks, in order: that every gate it delegates to exists on disk; that lint is at 0 errors; that the
|
|
14
|
+
mojibake scan is clean over the whole tree; that the suite declared in `package.json#scripts.test`
|
|
15
|
+
runs and passes; that the **packed tarball** — not the tree — contains every path `package.json#files`
|
|
16
|
+
names, names the tree's version, carries no forbidden path (`node_modules`, `test/`, `scripts/`,
|
|
17
|
+
`.git`, `*.proof-backup`/`*.orig`/`*.bak`) and registers its eight tools when executed from the
|
|
18
|
+
extracted copy; that the version, bundle-patch and document claims in `test/claims.test.mjs` still
|
|
19
|
+
hold; and that the CHANGELOG's title sits above its newest entry and its entries run newest-first.
|
|
20
|
+
Every check reports `PASS`, `FAIL` or `UNVERIFIABLE` **with a name and a reason**, and the run fails
|
|
21
|
+
if any registered check produces no result at all: "the gate ran" is itself a check, and the ledger
|
|
22
|
+
that enforces it is itself exercised on every run against a silent check, a throwing check and an
|
|
23
|
+
honest one — because a self-authored gate that reports success while executing zero checks is a
|
|
24
|
+
defect this project studied in another one. The first draft of that ledger counted a coerced `FAIL`
|
|
25
|
+
as a produced result, and the self-test caught it on its first run.
|
|
26
|
+
* **A wiring check that would have caught two of the defects below.** Every `test/*.test.mjs` on disk
|
|
27
|
+
must be named by some npm script, and every path an npm script names must exist. Measured now: 36
|
|
28
|
+
suites on disk, 36 named by a script, 0 orphans, 0 named-but-absent.
|
|
29
|
+
* **A CI compatibility gate** (`.github/workflows/compat.yml`, with its own section in
|
|
30
|
+
`CONTRIBUTING.md`). Three jobs. *mount-and-boot* builds a pinned host in `RUNNER_TEMP`
|
|
31
|
+
(`@deepseek-ai/dsh@0.2.0-rc.2`, `dsh-app-boot`, `dsh-client-modules`, `cordis@4.0.4`,
|
|
32
|
+
`cordis-plugin-loader@1.0.5`), runs the existing `test/loader-entry.test.mjs` against it **unmodified**
|
|
33
|
+
and classifies the TAP capture with nine named assertions, of which a `SKIP` counts as a failure.
|
|
34
|
+
*bare-import* packs, installs into an empty project, and asserts `dependencies === {}`, that no host
|
|
35
|
+
package resolves, and that the root export is a callable `apply`. *uninstall* asserts the composition
|
|
36
|
+
is byte-identical before and after, and documents why the manifest key set is not compared. Triggers:
|
|
37
|
+
PRs to main, pushes to main, `v*` tags, weekly, and dispatch; `contents: read`, no secrets. No-browser
|
|
38
|
+
is the single entry on `ACCEPTED_UNVERIFIABLE` and reports `UNVERIFIABLE` with its reason rather than
|
|
39
|
+
`PASS`. Validated end to end against a synthetic installation built with the workflow's own commands
|
|
40
|
+
(10/10 pass) and in the failing direction with `W2M_ROUTE_DEFECT=1`, which reports `FAIL` naming the
|
|
41
|
+
route assertion and the fiber sweep — the same both-directions rule the rest of this release follows.
|
|
42
|
+
* **Refusal codes, and the exposure boundary written down as executable text.** 26 codes in three
|
|
43
|
+
greppable tables (`REFUSAL_CODE` in `src/plugin/tools.mjs`, `MATCH_REFUSAL` in
|
|
44
|
+
`src/agent/allowed-commands.mjs`, `EXEC_REFUSAL` in `src/agent/exec.mjs`), with
|
|
45
|
+
`MATCH_REFUSAL.NOT_ALLOWED === REFUSAL.COMMAND_NOT_ALLOWED` asserted so the two ends of a dispatch
|
|
46
|
+
cannot drift apart. The defect this closes is that `applied: false` conflated three different facts:
|
|
47
|
+
a stored selection naming no machine was indistinguishable from "the whole fleet ran it", and that
|
|
48
|
+
state now has its own code (`SELECTION_UNUSABLE`). Every refusal carries `reason_code` and a `detail`
|
|
49
|
+
saying what would change it. The boundary itself — no tool parameter may name a credential, an
|
|
50
|
+
allow-list, a key path or a state directory — is stated in `src/plugin/tools.mjs`'s header and pinned
|
|
51
|
+
by four canaries run across all eight tools, proving that no result and no request body carries a
|
|
52
|
+
token. Proven by 17 mutations, 17 red, in a scratch copy outside the repository.
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
|
|
56
|
+
* **`CHANGELOG.md` had carried a mangled character into the published package.** v0.5.8's own entry —
|
|
57
|
+
the one describing the mojibake repair — contained the two garbled CJK sequences it was complaining
|
|
58
|
+
about, reproduced literally inside backticks. `CHANGELOG.md` is in `package.json#files`, so v0.5.8
|
|
59
|
+
shipped the damage it was apologising for, and `scripts/scan-mojibake.mjs` was red on the tree. The
|
|
60
|
+
line is ASCII now and the scan is clean over the whole tree.
|
|
61
|
+
* **The CHANGELOG's title and introduction were in the middle of the file.** The v0.5.8 entry was
|
|
62
|
+
prepended above `# Changelog` itself, which is the same mistake as the out-of-order entries v0.5.8
|
|
63
|
+
fixed, one level up: "newest first" was true of the version headings while the document's own header
|
|
64
|
+
had been pushed to line 21. The entry is below the title again, and the pre-flight now asserts the
|
|
65
|
+
shape rather than only the order.
|
|
66
|
+
* **The entire declared unit suite had not been running.** Wiring the new suites into
|
|
67
|
+
`package.json#scripts.test` and `#scripts.test:all` dropped one space, producing the path
|
|
68
|
+
`test/claims.test.mjstest/fleet-target.test.mjs`. `node --test` exits 1 on a missing path, so
|
|
69
|
+
`npm test` was failing immediately and *no* unit suite — not just `fleet-target` — was running. This
|
|
70
|
+
one is mine: it came from the edit that wired the new suites in, and it means the declared unit suite
|
|
71
|
+
was not being run at all between that edit and this release.
|
|
72
|
+
* **`test/p2p-crypto.test.mjs` was named by no script at all.** 33 tests, green, and wired into
|
|
73
|
+
nothing — the exact defect v0.5.7's release note identified in someone else's repository. A suite
|
|
74
|
+
that never runs protects nothing, and that is now asserted rather than assumed. It is in `test` and
|
|
75
|
+
`test:all`.
|
|
76
|
+
|
|
77
|
+
### Notes
|
|
78
|
+
|
|
79
|
+
* This release exists because three releases in one hour — v0.5.6, v0.5.7, v0.5.8 — each fixed a
|
|
80
|
+
defect the previous one introduced. **Every one of those defects was found after the tag was pushed**,
|
|
81
|
+
by a gate that already existed and simply was not run before the release. The gates were never
|
|
82
|
+
broken; the process around them was missing. That process is this release's subject.
|
|
83
|
+
* One check is `UNVERIFIABLE` on this machine and is reported as such rather than passed quietly: the
|
|
84
|
+
host has no `%DSH_HOME%\dsh-runtimes\`, while `src/agent/resolve.mjs` looks for the bundled runtime at
|
|
85
|
+
`dsh-runtimes/dsh-primary-runtime/dependencies` and the application bundle carries it at
|
|
86
|
+
`E:\DS\resources\runtime\primary-runtime\dependencies` instead. The suite's bundled-python claim is
|
|
87
|
+
measured against a throwaway home with the documented layout, and the host mismatch is printed next
|
|
88
|
+
to the verdict, as an open question rather than a silent pass.
|
|
89
|
+
* Two checks are known to be sensitive to a loaded machine rather than to the artifact, and both are
|
|
90
|
+
named here rather than discovered later: `test/relay.test.mjs`'s `runs a complete signed task end to
|
|
91
|
+
end`, whose `lease_until` is computed by the relay during the request and recomputed by the test
|
|
92
|
+
after the await, so the two can land in different seconds; and the capability probe in
|
|
93
|
+
`test/agent.test.mjs`, which measures the host's own runtime layout as well as the resolver. A red
|
|
94
|
+
run naming only these is a re-run, not a release defect — and a report that hides them behind a green
|
|
95
|
+
verdict is the kind of green this release exists to avoid.
|
|
96
|
+
|
|
1
97
|
## [0.5.8] — 2026-10-11
|
|
2
98
|
|
|
3
99
|
### Fixed
|
|
@@ -8,7 +104,8 @@
|
|
|
8
104
|
newest-first again, and the anchor is the top of the file rather than a version I have to remember.
|
|
9
105
|
* **Mojibake in shipped code.** `src/agent/mdns-wire.mjs` is packed, and its comments carried an em dash
|
|
10
106
|
and a section sign that had been decoded as the wrong encoding and re-saved, so a reader would have
|
|
11
|
-
seen
|
|
107
|
+
seen the two garbled CJK sequences where the RFC references belong. The same damage was in
|
|
108
|
+
`test/mdns-discover.test.mjs`.
|
|
12
109
|
All five mDNS files are ASCII now, and the repository's own mojibake scan is clean.
|
|
13
110
|
|
|
14
111
|
### Notes
|
|
@@ -18,11 +115,6 @@
|
|
|
18
115
|
the gates working, but it is also a release process with no pre-flight of its own — which is the next
|
|
19
116
|
thing this project should fix rather than the next thing it should be proud of.
|
|
20
117
|
* No runtime change: the code in v0.5.8 is the code in v0.5.6.
|
|
21
|
-
# Changelog
|
|
22
|
-
|
|
23
|
-
All notable changes to this project are documented here. The format follows
|
|
24
|
-
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
25
|
-
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
26
118
|
|
|
27
119
|
## [0.5.7] — 2026-10-11
|
|
28
120
|
|
package/CONTRIBUTING.md
CHANGED
|
@@ -49,6 +49,16 @@ throwaway profile under a throwaway `DSH_HOME` and boots it with the real `runPr
|
|
|
49
49
|
|
|
50
50
|
Each line is checkable against a diff.
|
|
51
51
|
|
|
52
|
+
0. **A release starts with `node scripts/preflight.mjs`, and a green run is the precondition for a
|
|
53
|
+
version bump.** `scripts/preflight.mjs` is the only sanctioned way to start a release: it runs the
|
|
54
|
+
lint, the mojibake scan, the suite declared in `package.json#scripts.test`, the pack plus the
|
|
55
|
+
assertions against the **packed tarball**, and the claim checks in `test/claims.test.mjs`, in that
|
|
56
|
+
order, and prints `PASS`/`FAIL`/`UNVERIFIABLE` with a reason for each. It exits non-zero on any
|
|
57
|
+
failure, and also when a check produces no result at all — "the gate ran" is itself checked. Do not
|
|
58
|
+
bump a version, tag, or publish on a red or unread run; `UNVERIFIABLE` is not a pass either, so read
|
|
59
|
+
the reason it prints. The three releases of 2026-10-11 (v0.5.6, v0.5.7, v0.5.8) each fixed a defect
|
|
60
|
+
the previous one introduced, every one of them found *after* the tag was pushed by a gate nobody had
|
|
61
|
+
run. This line exists so that does not happen again.
|
|
52
62
|
1. **Zero runtime dependencies.** `dependencies === {}` in `package.json`. Node built-ins only.
|
|
53
63
|
2. **Argv execution, never a shell string.** A command is an array of arguments passed to
|
|
54
64
|
`spawn`/`execFile` with no shell. No `exec`, no `shell: true`, no string interpolation into a
|
|
@@ -121,3 +131,7 @@ Therefore:
|
|
|
121
131
|
|
|
122
132
|
Do not open a public issue for a vulnerability. See [SECURITY.md](SECURITY.md) for the private
|
|
123
133
|
channel and what to include.
|
|
134
|
+
|
|
135
|
+
## The compatibility gate
|
|
136
|
+
|
|
137
|
+
`.github/workflows/compat.yml` is the CI half of rule 7, on every pull request, every push to `main`, every `v*` tag and a weekly schedule: it packs the tree, installs the tarball into a scratch profile with the host's own `dsh plugin add`, and asserts the deterministic artifact — the plugin's row composed by the real Loader, all eight `w2m_*` tools registered, no failed fiber anywhere in the composed tree, the client half activated through the real module system — plus a bare import with no host present and an install/uninstall that returns a profile to its previous composition; **a red compat run blocks a release** exactly as a red preflight does, and its `UNVERIFIABLE` outcomes are never a pass — read the reason it prints.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.9",
|
|
4
4
|
"description": "DSH: Windows2MacOS — drive the same project on online Windows and macOS machines from one DeepSeek account, across networks and regions. P2P hole punching is the default path (relay + shared server as rendezvous, ledger and fallback), with a bundled STUN responder, Tailscale/public-VPS/tunnel shapes, Rabbit relay + per-machine Localside + eight DSH tools (replicate / split), nine aggregate verdicts and signed requests.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -99,13 +99,13 @@
|
|
|
99
99
|
}
|
|
100
100
|
},
|
|
101
101
|
"scripts": {
|
|
102
|
-
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/tools.test.mjs test/claims.test.
|
|
102
|
+
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs",
|
|
103
103
|
"test:e2e": "node --test --test-force-exit test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/p2p-agent.test.mjs",
|
|
104
104
|
"test:relay": "node --test test/relay.test.mjs",
|
|
105
105
|
"test:agent": "node --test test/agent.test.mjs",
|
|
106
106
|
"test:tools": "node --test test/tools.test.mjs",
|
|
107
107
|
"test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
|
|
108
|
-
"test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/tools.test.mjs test/claims.test.
|
|
108
|
+
"test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs",
|
|
109
109
|
"pack": "node scripts/pack.mjs",
|
|
110
110
|
"lint": "eslint .",
|
|
111
111
|
"lint:fix": "eslint . --fix",
|
|
@@ -86,6 +86,49 @@ function sameName(a, b) {
|
|
|
86
86
|
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* The stable codes this matcher refuses with.
|
|
91
|
+
*
|
|
92
|
+
* `COMMAND_NOT_ALLOWED` is deliberately the **same literal** the agent writes into a result
|
|
93
|
+
* envelope's `refusal_reason` (`REFUSAL.COMMAND_NOT_ALLOWED` in `src/agent/agent.mjs`) and the same
|
|
94
|
+
* one the plugin's dispatch pre-flight reports: the whole reason this matcher is shared is that the
|
|
95
|
+
* two ends must not be able to disagree about *why* a command was refused, and two spellings of one
|
|
96
|
+
* refusal would be a disagreement of exactly that kind.
|
|
97
|
+
*
|
|
98
|
+
* `COMMAND_ARGV_EMPTY` is the matcher's own precondition. It is a separate code because it is a
|
|
99
|
+
* separate fault — a caller that passed nothing has a bug, not a policy problem — and collapsing it
|
|
100
|
+
* into `COMMAND_NOT_ALLOWED` would send the reader to the allow-list to fix a caller.
|
|
101
|
+
*/
|
|
102
|
+
export const MATCH_REFUSAL = Object.freeze({
|
|
103
|
+
ARGV_EMPTY: 'COMMAND_ARGV_EMPTY',
|
|
104
|
+
NOT_ALLOWED: 'COMMAND_NOT_ALLOWED',
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Why this argv is not covered by this list, and what would change it.
|
|
109
|
+
*
|
|
110
|
+
* The entries are echoed because they are the *only* thing that would make the same call succeed,
|
|
111
|
+
* and a caller reading this deserves them without a second call. They are configuration, never
|
|
112
|
+
* credentials: the parse path refuses a secret-looking key before a list ever reaches here.
|
|
113
|
+
*
|
|
114
|
+
* @param {string[]} argv
|
|
115
|
+
* @param {string[][]} prefixes
|
|
116
|
+
* @returns {string}
|
|
117
|
+
*/
|
|
118
|
+
function allowListMissDetail(argv, prefixes) {
|
|
119
|
+
if (prefixes.length === 0) {
|
|
120
|
+
return (
|
|
121
|
+
'the configured allow-list is empty, so every command is refused; add an entry naming what ' +
|
|
122
|
+
`this machine may run - an entry of \`${argv[0]}\` would allow \`${argv.join(' ')}\``
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
const entries = prefixes.map((prefix) => prefix.join(' ')).join(', ');
|
|
126
|
+
return (
|
|
127
|
+
`no configured entry is a prefix of this argv; the allow-list entries are ${entries}. Add one ` +
|
|
128
|
+
`that covers \`${argv.join(' ')}\`, or run a command the list already covers`
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
89
132
|
/**
|
|
90
133
|
* Default-deny prefix match.
|
|
91
134
|
*
|
|
@@ -93,13 +136,27 @@ function sameName(a, b) {
|
|
|
93
136
|
* `['node','--test','--reporter=tap']`; `['node','-e','...']` and a bare
|
|
94
137
|
* `['node']` are refused.
|
|
95
138
|
*
|
|
139
|
+
* A refusal is a **named** one: `reason_code` is a stable, greppable code and `detail` says what
|
|
140
|
+
* would change it. `allowed` and `prefix` keep the shape this function has always returned, because
|
|
141
|
+
* both callers read exactly those two fields and the codes are additive.
|
|
142
|
+
*
|
|
96
143
|
* @param {string[]} argv
|
|
97
144
|
* @param {string[][]} prefixes
|
|
98
|
-
* @returns {{allowed: boolean, prefix: string[]|null}}
|
|
145
|
+
* @returns {{allowed: boolean, prefix: string[]|null, reason_code: string|null, detail: string|null}}
|
|
99
146
|
*/
|
|
100
147
|
export function matchAllowedCommand(argv, prefixes) {
|
|
101
|
-
|
|
102
|
-
|
|
148
|
+
const entries = Array.isArray(prefixes) ? prefixes : [];
|
|
149
|
+
if (!Array.isArray(argv) || argv.length === 0) {
|
|
150
|
+
return {
|
|
151
|
+
allowed: false,
|
|
152
|
+
prefix: null,
|
|
153
|
+
reason_code: MATCH_REFUSAL.ARGV_EMPTY,
|
|
154
|
+
detail:
|
|
155
|
+
'there is no argv to compare, and this matcher is default-deny; pass a non-empty array of ' +
|
|
156
|
+
'strings whose first element is the executable',
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
for (const prefix of entries) {
|
|
103
160
|
if (!Array.isArray(prefix) || prefix.length === 0) continue;
|
|
104
161
|
if (argv.length < prefix.length) continue;
|
|
105
162
|
if (!sameName(argv[0], prefix[0]) && !sameName(execName(argv[0]), prefix[0])) continue;
|
|
@@ -110,7 +167,12 @@ export function matchAllowedCommand(argv, prefixes) {
|
|
|
110
167
|
break;
|
|
111
168
|
}
|
|
112
169
|
}
|
|
113
|
-
if (ok) return { allowed: true, prefix };
|
|
170
|
+
if (ok) return { allowed: true, prefix, reason_code: null, detail: null };
|
|
114
171
|
}
|
|
115
|
-
return {
|
|
172
|
+
return {
|
|
173
|
+
allowed: false,
|
|
174
|
+
prefix: null,
|
|
175
|
+
reason_code: MATCH_REFUSAL.NOT_ALLOWED,
|
|
176
|
+
detail: allowListMissDetail(argv, entries.filter((prefix) => Array.isArray(prefix) && prefix.length > 0)),
|
|
177
|
+
};
|
|
116
178
|
}
|
package/src/agent/exec.mjs
CHANGED
|
@@ -31,6 +31,46 @@ export const WARN_OUTPUT_TRUNCATED = 'OUTPUT_TRUNCATED';
|
|
|
31
31
|
/** Warning code emitted when the executable could not be started at all. */
|
|
32
32
|
export const WARN_PATH_INVALID = 'PATH_INVALID';
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The stable codes this gate refuses with.
|
|
36
|
+
*
|
|
37
|
+
* These are the last check before `spawn`, and they are what makes one invariant legible: **no shell,
|
|
38
|
+
* ever**. A string command, an empty argv and a non-string element are all refused here, and until
|
|
39
|
+
* now each was a bare `TypeError` with no code — a caller could see *that* the execution gate said no
|
|
40
|
+
* and not *which* rule said it. `code` and `detail` are attached rather than replacing the type, so
|
|
41
|
+
* `assert.throws(..., TypeError)` keeps meaning what it meant and `parseAllowedCommands`' own
|
|
42
|
+
* `err.code = 'ALLOWED_COMMANDS_INVALID'` stays the house convention rather than an exception.
|
|
43
|
+
*/
|
|
44
|
+
export const EXEC_REFUSAL = Object.freeze({
|
|
45
|
+
/**
|
|
46
|
+
* `argv` was not a non-empty array. This is the "no shell string" rule: `runArgv('node --test')`
|
|
47
|
+
* is refused because that string would have to be split by *something* to become a process, and the
|
|
48
|
+
* something would be a shell.
|
|
49
|
+
*/
|
|
50
|
+
ARGV_NOT_ARRAY: 'EXEC_ARGV_NOT_ARRAY',
|
|
51
|
+
/** An element of `argv` was not a string; nothing here coerces, because coercion is a re-parse. */
|
|
52
|
+
ARGV_ENTRY_NOT_STRING: 'EXEC_ARGV_ENTRY_NOT_STRING',
|
|
53
|
+
/** `argv[0]` was the empty string, which names no executable. */
|
|
54
|
+
ARGV_EXECUTABLE_EMPTY: 'EXEC_ARGV_EXECUTABLE_EMPTY',
|
|
55
|
+
/** The process could not be started at all (ENOENT, EACCES, …); the OS code stays in `code`. */
|
|
56
|
+
SPAWN_FAILED: 'EXEC_SPAWN_FAILED',
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build the refusal `runArgv` throws: a real `TypeError`, carrying a stable code and a remedy.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} code One of {@link EXEC_REFUSAL}.
|
|
63
|
+
* @param {string} message What was refused and why it cannot be run.
|
|
64
|
+
* @param {string} detail What would make the same call acceptable.
|
|
65
|
+
* @returns {TypeError}
|
|
66
|
+
*/
|
|
67
|
+
function execRefusal(code, message, detail) {
|
|
68
|
+
const error = new TypeError(message);
|
|
69
|
+
error.code = code;
|
|
70
|
+
error.detail = detail;
|
|
71
|
+
return error;
|
|
72
|
+
}
|
|
73
|
+
|
|
34
74
|
/**
|
|
35
75
|
* Lowercase hex SHA-256 of a Buffer/string.
|
|
36
76
|
*
|
|
@@ -67,7 +107,9 @@ export const EMPTY_SHA256 = sha256Hex(Buffer.alloc(0));
|
|
|
67
107
|
* @property {number} duration_ms Wall-clock duration.
|
|
68
108
|
* @property {boolean} timed_out We killed it because `timeoutMs` elapsed.
|
|
69
109
|
* @property {boolean} cancelled We killed it because the signal aborted.
|
|
70
|
-
* @property {{code: string, message: string}|null} spawn_error
|
|
110
|
+
* @property {{code: string, reason_code: string, message: string}|null} spawn_error
|
|
111
|
+
* `code` is the OS error code (ENOENT, EACCES) or `SPAWN_FAILED`; `reason_code` is this gate's own
|
|
112
|
+
* stable code, so a reader can tell "the execution gate refused" from "the operating system did".
|
|
71
113
|
* @property {string[]} warnings
|
|
72
114
|
*/
|
|
73
115
|
|
|
@@ -143,16 +185,28 @@ function createCollector(maxOutputBytes) {
|
|
|
143
185
|
*/
|
|
144
186
|
export function runArgv(argv, options = {}) {
|
|
145
187
|
if (!Array.isArray(argv) || argv.length === 0) {
|
|
146
|
-
throw
|
|
188
|
+
throw execRefusal(
|
|
189
|
+
EXEC_REFUSAL.ARGV_NOT_ARRAY,
|
|
190
|
+
'runArgv: argv must be a non-empty array of strings',
|
|
191
|
+
'pass the command and its arguments as separate array elements, for example ["node","--test"] - a command line in one string is refused because splitting it would need a shell',
|
|
192
|
+
);
|
|
147
193
|
}
|
|
148
194
|
for (const part of argv) {
|
|
149
195
|
if (typeof part !== 'string') {
|
|
150
|
-
throw
|
|
196
|
+
throw execRefusal(
|
|
197
|
+
EXEC_REFUSAL.ARGV_ENTRY_NOT_STRING,
|
|
151
198
|
`runArgv: argv entries must be strings, got ${part === null ? 'null' : typeof part}`,
|
|
199
|
+
'stringify the argument at the caller, so this gate never has to re-parse a value it did not receive',
|
|
152
200
|
);
|
|
153
201
|
}
|
|
154
202
|
}
|
|
155
|
-
if (argv[0] === '')
|
|
203
|
+
if (argv[0] === '') {
|
|
204
|
+
throw execRefusal(
|
|
205
|
+
EXEC_REFUSAL.ARGV_EXECUTABLE_EMPTY,
|
|
206
|
+
'runArgv: argv[0] must not be empty',
|
|
207
|
+
'name the executable in argv[0], for example "node" or an absolute path to it',
|
|
208
|
+
);
|
|
209
|
+
}
|
|
156
210
|
|
|
157
211
|
const {
|
|
158
212
|
cwd = process.cwd(),
|
|
@@ -265,6 +319,7 @@ export function runArgv(argv, options = {}) {
|
|
|
265
319
|
} catch (error) {
|
|
266
320
|
finish(null, null, {
|
|
267
321
|
code: error?.code ?? 'SPAWN_FAILED',
|
|
322
|
+
reason_code: EXEC_REFUSAL.SPAWN_FAILED,
|
|
268
323
|
message: String(error?.message ?? error),
|
|
269
324
|
});
|
|
270
325
|
return;
|
|
@@ -276,6 +331,7 @@ export function runArgv(argv, options = {}) {
|
|
|
276
331
|
child.on('error', (error) => {
|
|
277
332
|
finish(null, null, {
|
|
278
333
|
code: error?.code ?? 'SPAWN_FAILED',
|
|
334
|
+
reason_code: EXEC_REFUSAL.SPAWN_FAILED,
|
|
279
335
|
message: String(error?.message ?? error),
|
|
280
336
|
});
|
|
281
337
|
});
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -30,6 +30,49 @@
|
|
|
30
30
|
* * This file never reads `.credentials.yaml`, and never hard-codes an endpoint: every URL comes
|
|
31
31
|
* from `config.rabbitUrl` or from `device.json`'s `rabbit_url`.
|
|
32
32
|
*
|
|
33
|
+
* # The exposure boundary: what an agent-facing tool may not do
|
|
34
|
+
*
|
|
35
|
+
* Everything in this file is callable by a model, against a fleet whose machines run commands under
|
|
36
|
+
* their own allow-lists, and it holds an operator token that authorises dispatch fleet-wide. That
|
|
37
|
+
* makes the *tool surface* a boundary, and this section is the boundary written down as it actually
|
|
38
|
+
* is — not as it would sound best. `test/tool-cards.test.mjs` asserts the parts of it that can be
|
|
39
|
+
* asserted mechanically, and a change that crosses it is meant to turn that suite red.
|
|
40
|
+
*
|
|
41
|
+
* **No tool parameter names a credential, an allow-list, a key path, or a state directory.** There is
|
|
42
|
+
* no parameter through which a caller can set a token, a secret, `allowedCommands`, `p2pMode`, a
|
|
43
|
+
* `device.key` path, or another machine's identity file. `w2m_run`'s only machine-naming parameter is
|
|
44
|
+
* `executor_machine_id`, and it *narrows* one `broadcast` to a machine the relay already lists; it
|
|
45
|
+
* cannot name a machine the relay does not hold, and it cannot hand that machine anything new.
|
|
46
|
+
*
|
|
47
|
+
* **No tool can widen what a machine will run.** `allowedCommands` is read in `readConfig` from the
|
|
48
|
+
* host config and the shared `.w2m.json` / `machine.json` layers, is never written by anything here,
|
|
49
|
+
* and is applied in one direction only: {@link checkAllowedCommand} returns a refusal or `null`, and
|
|
50
|
+
* there is no path in this file that adds an entry to the list. Each target machine's own agent
|
|
51
|
+
* re-decides the same gate against its own list, so a dispatcher that lost this check would still not
|
|
52
|
+
* widen anything — the direction the shared matcher exists to keep honest (`src/agent/allowed-commands.mjs`).
|
|
53
|
+
*
|
|
54
|
+
* **No tool can change a machine's `p2pMode`.** The mode is resolved once, in `readConfig` at
|
|
55
|
+
* `apply()` time, from the config object or `W2M_P2P_MODE`, and every dispatch publishes it as a fact
|
|
56
|
+
* (`p2p: {mode}`) rather than accepting one. The *executor's* mode is its own local setting: an agent
|
|
57
|
+
* configured `direct` refuses a relay-delivered offer with `P2P_UNAVAILABLE` rather than accepting the
|
|
58
|
+
* weaker path, which is the one place a dispatcher could otherwise have quietly downgraded a peer.
|
|
59
|
+
*
|
|
60
|
+
* **No tool writes another machine's identity or keys.** `device.json` is read (`readDevice`) and
|
|
61
|
+
* `device.key` is loaded (`DeviceKey.load`) — neither is ever written here. What a dispatch publishes
|
|
62
|
+
* is this machine's own **public** key inside `p2p.static_keys`, so the executor can authenticate a
|
|
63
|
+
* dial; the private half never leaves `src/agent/crypto.mjs`.
|
|
64
|
+
*
|
|
65
|
+
* **No tool result carries a token or a key.** `w2m_status` reports `paired`,
|
|
66
|
+
* `operatorToken_configured` and `pairingCode_configured` as booleans and never the values. The
|
|
67
|
+
* operator token travels only in the `Authorization` header of `POST /v1/task` — never in the request
|
|
68
|
+
* body and never in an answer — and `direct_pushes[].static_key` is the literal marker `'published'`,
|
|
69
|
+
* not the key it describes.
|
|
70
|
+
*
|
|
71
|
+
* What an agent-facing tool *may* do across machines is bounded and stated: ask the machines the relay
|
|
72
|
+
* leased a task to run one argv inside their own allow-lists, in their own copy of the project,
|
|
73
|
+
* read-only unless `write: true` names a `write_scope`, and report what they said — refusals
|
|
74
|
+
* included, as values.
|
|
75
|
+
*
|
|
33
76
|
* # `@deepseek-ai/dsh-tools` and the offline fallback
|
|
34
77
|
*
|
|
35
78
|
* `defineTool` is imported lazily, at `apply()` time, through {@link loadDefineTool}. When the
|
|
@@ -134,7 +177,7 @@ const MAX_STAGES = 16;
|
|
|
134
177
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
135
178
|
* overrides it at runtime for tests and packagers.
|
|
136
179
|
*/
|
|
137
|
-
const PLUGIN_VERSION = '0.5.
|
|
180
|
+
const PLUGIN_VERSION = '0.5.9';
|
|
138
181
|
|
|
139
182
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
140
183
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -167,6 +210,88 @@ const RETRYABLE_STATUS = new Set([429, 502, 503, 504]);
|
|
|
167
210
|
// Errors
|
|
168
211
|
// ---------------------------------------------------------------------------------------------
|
|
169
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Every stable code a **gate in this file** refuses with, in one greppable place.
|
|
215
|
+
*
|
|
216
|
+
* The nine aggregate verdicts never reduce an outcome to a boolean; the gates that run *before* any
|
|
217
|
+
* comparison owe a caller the same courtesy, and this table is that debt paid. Each code names what
|
|
218
|
+
* was refused, and every refusal site also carries a `detail` saying what would change it, because a
|
|
219
|
+
* caller that cannot act on a refusal has been told nothing.
|
|
220
|
+
*
|
|
221
|
+
* Two of these are the agent's own vocabulary, deliberately shared rather than re-spelled:
|
|
222
|
+
* `COMMAND_NOT_ALLOWED` (`REFUSAL.COMMAND_NOT_ALLOWED` in `src/agent/agent.mjs`, which is also what
|
|
223
|
+
* `src/agent/allowed-commands.mjs` returns) and the `P2P_FRAME_*` family that already travelled into
|
|
224
|
+
* `w2m_status` as message prefixes.
|
|
225
|
+
*
|
|
226
|
+
* A code is added only when it names a *distinct remedy*. `W2M_CONFIG` still covers a malformed
|
|
227
|
+
* setting at large; the credential gate below has its own code because its remedy — set
|
|
228
|
+
* `operatorToken` — is unlike any other configuration fault's.
|
|
229
|
+
*/
|
|
230
|
+
export const REFUSAL_CODE = Object.freeze({
|
|
231
|
+
/** The dispatch pre-flight found the command outside this plugin's `allowedCommands`. */
|
|
232
|
+
COMMAND_NOT_ALLOWED: 'COMMAND_NOT_ALLOWED',
|
|
233
|
+
/**
|
|
234
|
+
* The configured `allowedCommands` could not be parsed, so the control could not be evaluated.
|
|
235
|
+
* Distinct from the code above: the remedy is to fix the list, not the command.
|
|
236
|
+
*/
|
|
237
|
+
ALLOWED_COMMANDS_UNPARSEABLE: 'ALLOWED_COMMANDS_UNPARSEABLE',
|
|
238
|
+
/** No operator credential is configured, so `POST /v1/task` cannot be authorised at all. */
|
|
239
|
+
OPERATOR_TOKEN_MISSING: 'OPERATOR_TOKEN_MISSING',
|
|
240
|
+
/** No `device.json` exists where this machine's identity was looked for. */
|
|
241
|
+
DEVICE_NOT_PAIRED: 'DEVICE_NOT_PAIRED',
|
|
242
|
+
/** `device.json` exists but could not be read as JSON. */
|
|
243
|
+
DEVICE_UNREADABLE: 'DEVICE_UNREADABLE',
|
|
244
|
+
/** `device.json` parsed but carries no `device_token`, so the device never completed pairing. */
|
|
245
|
+
DEVICE_TOKEN_ABSENT: 'DEVICE_TOKEN_ABSENT',
|
|
246
|
+
/** The relay returned a lease with no `machine_id`, so there is nobody to address an offer to. */
|
|
247
|
+
P2P_LEASE_NO_MACHINE_ID: 'P2P_LEASE_NO_MACHINE_ID',
|
|
248
|
+
/** The lease is not in a runnable state, so the relay never handed this machine the command. */
|
|
249
|
+
P2P_LEASE_NOT_RUNNABLE: 'P2P_LEASE_NOT_RUNNABLE',
|
|
250
|
+
/** A direct dial to a leased machine failed; `direct_pushes[].error` carries the node's own code. */
|
|
251
|
+
P2P_DIAL_FAILED: 'P2P_DIAL_FAILED',
|
|
252
|
+
/** The best-effort offer push threw rather than returning a refusal. */
|
|
253
|
+
P2P_PUSH_THREW: 'P2P_PUSH_THREW',
|
|
254
|
+
/** A frame arrived over a direct channel that is not a JSON document. */
|
|
255
|
+
P2P_FRAME_NOT_JSON: 'P2P_FRAME_NOT_JSON',
|
|
256
|
+
/** A frame arrived that parsed to something other than a JSON object. */
|
|
257
|
+
P2P_FRAME_NOT_AN_OBJECT: 'P2P_FRAME_NOT_AN_OBJECT',
|
|
258
|
+
/** A result frame carried no `task_id`, so it could not be stored or acknowledged. */
|
|
259
|
+
P2P_FRAME_NO_TASK_ID: 'P2P_FRAME_NO_TASK_ID',
|
|
260
|
+
/** A result frame carried no `machine_id`, so two machines' answers could collide on one file. */
|
|
261
|
+
P2P_FRAME_NO_MACHINE_ID: 'P2P_FRAME_NO_MACHINE_ID',
|
|
262
|
+
/** A result arrived with no `stateDir` to write it to. */
|
|
263
|
+
P2P_NO_STATE_DIR: 'P2P_NO_STATE_DIR',
|
|
264
|
+
/** Storing a result frame failed, so the result exists only on the sender. */
|
|
265
|
+
P2P_INBOX_WRITE_FAILED: 'P2P_INBOX_WRITE_FAILED',
|
|
266
|
+
/**
|
|
267
|
+
* The composer's selected target routed this dispatch. Not a refusal — a named *reason*, so the
|
|
268
|
+
* routing decision is greppable in the answer instead of being a bare `applied: true`.
|
|
269
|
+
*/
|
|
270
|
+
SELECTION_APPLIED: 'SELECTION_APPLIED',
|
|
271
|
+
/** Nothing is selected for this session, so the dispatch went to the whole fleet. */
|
|
272
|
+
SELECTION_NONE: 'SELECTION_NONE',
|
|
273
|
+
/** A selection exists but names no `machine_id`, so none of it could be applied. */
|
|
274
|
+
SELECTION_UNUSABLE: 'SELECTION_UNUSABLE',
|
|
275
|
+
/** The call named its own `executor_machine_id`, which wins over the session selection. */
|
|
276
|
+
SELECTION_OVERRIDDEN_BY_CALLER: 'SELECTION_OVERRIDDEN_BY_CALLER',
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The leading `CODE:` token of a message one of this project's own modules produced, or `null`.
|
|
281
|
+
*
|
|
282
|
+
* Every coded failure in this tree is written as `'CODE: what happened'`, so the code is already in
|
|
283
|
+
* the string; this lifts it back out for the places that must publish it as a field too. It refuses
|
|
284
|
+
* to invent one: a message with no such prefix returns `null` and the caller supplies its own code.
|
|
285
|
+
*
|
|
286
|
+
* @param {unknown} message
|
|
287
|
+
* @returns {string|null}
|
|
288
|
+
*/
|
|
289
|
+
function reasonCodeOf(message) {
|
|
290
|
+
if (typeof message !== 'string') return null;
|
|
291
|
+
const match = /^([A-Z][A-Z0-9_]{2,}):/.exec(message);
|
|
292
|
+
return match ? match[1] : null;
|
|
293
|
+
}
|
|
294
|
+
|
|
170
295
|
/**
|
|
171
296
|
* An error a caller can act on, carrying a stable machine-readable code.
|
|
172
297
|
*
|
|
@@ -177,12 +302,16 @@ class W2MError extends Error {
|
|
|
177
302
|
/**
|
|
178
303
|
* @param {string} code Stable code, e.g. `W2M_CONFIG`.
|
|
179
304
|
* @param {string} message What went wrong and, when known, how to fix it.
|
|
180
|
-
* @param {{hint?: string, detail?: object, cause?: unknown}} [options]
|
|
305
|
+
* @param {{hint?: string, detail?: object, cause?: unknown, reason_code?: string}} [options]
|
|
306
|
+
* `reason_code` names the *refusal* when `code` only names its class. `W2M_CONFIG` says "a
|
|
307
|
+
* setting is wrong"; `OPERATOR_TOKEN_MISSING` says which gate refused and, with `hint`, what
|
|
308
|
+
* would change it. It defaults to `code`, so every existing error keeps one obvious code.
|
|
181
309
|
*/
|
|
182
310
|
constructor(code, message, options = {}) {
|
|
183
311
|
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
|
184
312
|
this.name = 'W2MError';
|
|
185
313
|
this.code = code;
|
|
314
|
+
this.reason_code = options.reason_code ?? code;
|
|
186
315
|
this.hint = options.hint;
|
|
187
316
|
this.detail = options.detail;
|
|
188
317
|
}
|
|
@@ -198,10 +327,14 @@ class W2MError extends Error {
|
|
|
198
327
|
* @param {string} setting Dotted setting name, e.g. `rabbitUrl`.
|
|
199
328
|
* @param {string} why Why the value was refused.
|
|
200
329
|
* @param {string} hint What to set.
|
|
330
|
+
* @param {string} [reasonCode] The refusal's own code, when this is a gate rather than a typo.
|
|
201
331
|
* @returns {W2MError}
|
|
202
332
|
*/
|
|
203
|
-
function configError(setting, why, hint) {
|
|
204
|
-
return new W2MError('W2M_CONFIG', `W2M_CONFIG: configuration \`${setting}\` ${why}; ${hint}`, {
|
|
333
|
+
function configError(setting, why, hint, reasonCode) {
|
|
334
|
+
return new W2MError('W2M_CONFIG', `W2M_CONFIG: configuration \`${setting}\` ${why}; ${hint}`, {
|
|
335
|
+
hint,
|
|
336
|
+
...(reasonCode === undefined ? {} : { reason_code: reasonCode }),
|
|
337
|
+
});
|
|
205
338
|
}
|
|
206
339
|
|
|
207
340
|
// ---------------------------------------------------------------------------------------------
|
|
@@ -954,15 +1087,27 @@ function canonicalJson(value) {
|
|
|
954
1087
|
* acknowledgement is keyed by `task_id`, and two machines answering the same task must not overwrite
|
|
955
1088
|
* each other — so it is refused by name instead of being written somewhere surprising.
|
|
956
1089
|
*
|
|
1090
|
+
* Both halves of the refusal are returned: `reason` is the `CODE: sentence` string this has always
|
|
1091
|
+
* produced (it is what `w2m_status` shows), `reason_code` is the same code as a field, so a reader
|
|
1092
|
+
* that wants to branch on the refusal does not have to split a message.
|
|
1093
|
+
*
|
|
957
1094
|
* @param {object} frame
|
|
958
|
-
* @returns {string|null} The reason it is unusable, or null.
|
|
1095
|
+
* @returns {{reason_code: string, reason: string}|null} The reason it is unusable, or null.
|
|
959
1096
|
*/
|
|
960
1097
|
function inboxKeyProblem(frame) {
|
|
961
1098
|
const taskId = frame?.task_id;
|
|
962
1099
|
const machineId = frame?.machine_id;
|
|
963
|
-
if (typeof taskId !== 'string' || taskId.trim() === '')
|
|
1100
|
+
if (typeof taskId !== 'string' || taskId.trim() === '') {
|
|
1101
|
+
return {
|
|
1102
|
+
reason_code: REFUSAL_CODE.P2P_FRAME_NO_TASK_ID,
|
|
1103
|
+
reason: 'P2P_FRAME_NO_TASK_ID: `task_id` is missing or empty',
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
964
1106
|
if (typeof machineId !== 'string' || machineId.trim() === '') {
|
|
965
|
-
return
|
|
1107
|
+
return {
|
|
1108
|
+
reason_code: REFUSAL_CODE.P2P_FRAME_NO_MACHINE_ID,
|
|
1109
|
+
reason: 'P2P_FRAME_NO_MACHINE_ID: `machine_id` is missing or empty',
|
|
1110
|
+
};
|
|
966
1111
|
}
|
|
967
1112
|
return null;
|
|
968
1113
|
}
|
|
@@ -1198,11 +1343,21 @@ function offerP2PFor(published, { taskId, machineId, peerKey = null }) {
|
|
|
1198
1343
|
* @param {string|null} options.originMachineId
|
|
1199
1344
|
* @param {string|null} [options.peerKey] The target's static public key (v0.6.0), added to the
|
|
1200
1345
|
* frame's `p2p.static_keys` under that machine's id.
|
|
1201
|
-
* @returns {{frame: object|null, skipped: string|null}}
|
|
1346
|
+
* @returns {{frame: object|null, skipped: {reason_code: string, message: string}|null}} `skipped` is
|
|
1347
|
+
* a named refusal, not a sentence: a lease the relay did not address to a machine is a different
|
|
1348
|
+
* fault from one it decided must not run the command.
|
|
1202
1349
|
*/
|
|
1203
1350
|
function buildOfferFrame({ taskId, lease, payload, originMachineId, peerKey = null }) {
|
|
1204
1351
|
const machineId = typeof lease?.machine_id === 'string' && lease.machine_id !== '' ? lease.machine_id : null;
|
|
1205
|
-
if (machineId === null)
|
|
1352
|
+
if (machineId === null) {
|
|
1353
|
+
return {
|
|
1354
|
+
frame: null,
|
|
1355
|
+
skipped: {
|
|
1356
|
+
reason_code: REFUSAL_CODE.P2P_LEASE_NO_MACHINE_ID,
|
|
1357
|
+
message: 'the relay returned a lease with no machine_id',
|
|
1358
|
+
},
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1206
1361
|
|
|
1207
1362
|
const index = Number.isInteger(lease?.index) ? lease.index : 0;
|
|
1208
1363
|
// The relay's first attempt is 1, and a lease that never says otherwise is a first attempt.
|
|
@@ -1263,8 +1418,9 @@ function buildOfferFrame({ taskId, lease, payload, originMachineId, peerKey = nu
|
|
|
1263
1418
|
* well because the *dispatcher* is the initiator here and needs the executor's key to handshake.
|
|
1264
1419
|
* `peerKeys` is the node's own store, taught the same key so the accept side can verify the
|
|
1265
1420
|
* executor's handshake later without a second relay lookup.
|
|
1266
|
-
* @returns {Promise<{ok: boolean,
|
|
1267
|
-
* encrypted: boolean, encryption_reason: string|null}>}
|
|
1421
|
+
* @returns {Promise<{ok: boolean, reason_code: string|null, error: string|null, session: number|null,
|
|
1422
|
+
* reused: boolean, encrypted: boolean, encryption_reason: string|null}>} `reason_code` is null on a
|
|
1423
|
+
* delivered push and names the refusal otherwise, so the caller never has to read `error` to branch.
|
|
1268
1424
|
*/
|
|
1269
1425
|
async function pushOffer(node, frame, crypto = {}) {
|
|
1270
1426
|
const session = Number.isInteger(frame?.p2p?.session) && frame.p2p.session > 0 ? frame.p2p.session : undefined;
|
|
@@ -1288,6 +1444,9 @@ async function pushOffer(node, frame, crypto = {}) {
|
|
|
1288
1444
|
if (!dialled.ok) {
|
|
1289
1445
|
return {
|
|
1290
1446
|
ok: false,
|
|
1447
|
+
// The node writes its own refusal as `CODE: sentence`; the code is lifted out so the push
|
|
1448
|
+
// reports the *same* code as a field instead of making the reader parse a message.
|
|
1449
|
+
reason_code: reasonCodeOf(dialled.error) ?? REFUSAL_CODE.P2P_DIAL_FAILED,
|
|
1291
1450
|
error: dialled.error ?? 'P2P_DIAL_FAILED',
|
|
1292
1451
|
session: dialled.session ?? null,
|
|
1293
1452
|
reused: false,
|
|
@@ -1300,6 +1459,7 @@ async function pushOffer(node, frame, crypto = {}) {
|
|
|
1300
1459
|
await channel.send(JSON.stringify(frame));
|
|
1301
1460
|
return {
|
|
1302
1461
|
ok: true,
|
|
1462
|
+
reason_code: null,
|
|
1303
1463
|
error: null,
|
|
1304
1464
|
session: dialled.session ?? null,
|
|
1305
1465
|
reused: dialled.reused === true,
|
|
@@ -1314,6 +1474,7 @@ async function pushOffer(node, frame, crypto = {}) {
|
|
|
1314
1474
|
} catch (error) {
|
|
1315
1475
|
return {
|
|
1316
1476
|
ok: false,
|
|
1477
|
+
reason_code: REFUSAL_CODE.P2P_PUSH_THREW,
|
|
1317
1478
|
error: `P2P_PUSH_THREW: ${error instanceof Error ? error.message : String(error)}`,
|
|
1318
1479
|
session: session ?? null,
|
|
1319
1480
|
reused: false,
|
|
@@ -1342,7 +1503,9 @@ async function pushOffer(node, frame, crypto = {}) {
|
|
|
1342
1503
|
*
|
|
1343
1504
|
* @param {P2PNode} node
|
|
1344
1505
|
* @param {object} options
|
|
1345
|
-
* @returns {Promise<Array<object>>} One entry per lease considered, in lease order.
|
|
1506
|
+
* @returns {Promise<Array<object>>} One entry per lease considered, in lease order. A machine that was
|
|
1507
|
+
* not pushed to carries `ok: false` **and** a `reason_code` naming which gate refused it — this
|
|
1508
|
+
* block is a tool result, so a bare boolean here would be exactly the reduction the verdicts refuse.
|
|
1346
1509
|
*/
|
|
1347
1510
|
async function pushOffers(node, { taskId, leases, payload, originMachineId, getJson = null }) {
|
|
1348
1511
|
const out = [];
|
|
@@ -1350,27 +1513,44 @@ async function pushOffers(node, { taskId, leases, payload, originMachineId, getJ
|
|
|
1350
1513
|
const machineId = typeof lease?.machine_id === 'string' && lease.machine_id !== '' ? lease.machine_id : null;
|
|
1351
1514
|
const state = lease?.state;
|
|
1352
1515
|
if (machineId === null) {
|
|
1353
|
-
out.push({
|
|
1516
|
+
out.push({
|
|
1517
|
+
machine_id: null,
|
|
1518
|
+
ok: false,
|
|
1519
|
+
reason_code: REFUSAL_CODE.P2P_LEASE_NO_MACHINE_ID,
|
|
1520
|
+
error: 'the relay returned a lease with no machine_id',
|
|
1521
|
+
});
|
|
1354
1522
|
continue;
|
|
1355
1523
|
}
|
|
1356
1524
|
if (state !== 'queued' && state !== 'offered') {
|
|
1357
1525
|
out.push({
|
|
1358
1526
|
machine_id: machineId,
|
|
1359
1527
|
ok: false,
|
|
1528
|
+
reason_code: REFUSAL_CODE.P2P_LEASE_NOT_RUNNABLE,
|
|
1360
1529
|
error: `lease state is ${JSON.stringify(state ?? null)}, so this machine was not handed the command`,
|
|
1530
|
+
// What would change it: nothing the dispatcher can do. The relay decided, and the honest
|
|
1531
|
+
// report is that the decision was the relay's rather than a delivery failure.
|
|
1532
|
+
detail:
|
|
1533
|
+
'only leases in a runnable state (queued, offered) are pushed; this lease is refused or ' +
|
|
1534
|
+
'observing, so it was never handed the command and the relay\'s own SSE offer is the only delivery',
|
|
1361
1535
|
});
|
|
1362
1536
|
continue;
|
|
1363
1537
|
}
|
|
1364
1538
|
const peerKey = await readPeerStaticKey(getJson, machineId);
|
|
1365
1539
|
const built = buildOfferFrame({ taskId, lease, payload, originMachineId, peerKey });
|
|
1366
1540
|
if (built.frame === null) {
|
|
1367
|
-
out.push({
|
|
1541
|
+
out.push({
|
|
1542
|
+
machine_id: machineId,
|
|
1543
|
+
ok: false,
|
|
1544
|
+
reason_code: built.skipped.reason_code,
|
|
1545
|
+
error: built.skipped.message,
|
|
1546
|
+
});
|
|
1368
1547
|
continue;
|
|
1369
1548
|
}
|
|
1370
1549
|
const result = await pushOffer(node, built.frame, { peerKey, peerKeys: node?.peerKeys ?? null });
|
|
1371
1550
|
out.push({
|
|
1372
1551
|
machine_id: machineId,
|
|
1373
1552
|
ok: result.ok,
|
|
1553
|
+
reason_code: result.ok ? null : (result.reason_code ?? REFUSAL_CODE.P2P_DIAL_FAILED),
|
|
1374
1554
|
error: result.error,
|
|
1375
1555
|
dedupe_key: built.frame.dedupe_key === null ? null : 'relay-formula',
|
|
1376
1556
|
// The session this push and the executor's reverse dial meet on, so a machine's own
|
|
@@ -1678,11 +1858,19 @@ async function handleP2PMessage(runtime, event) {
|
|
|
1678
1858
|
try {
|
|
1679
1859
|
frame = JSON.parse(raw);
|
|
1680
1860
|
} catch {
|
|
1681
|
-
runtime.events.refused.push({
|
|
1861
|
+
runtime.events.refused.push({
|
|
1862
|
+
reason_code: REFUSAL_CODE.P2P_FRAME_NOT_JSON,
|
|
1863
|
+
reason: 'P2P_FRAME_NOT_JSON: the payload is not a JSON document',
|
|
1864
|
+
bytes: raw.length,
|
|
1865
|
+
});
|
|
1682
1866
|
return;
|
|
1683
1867
|
}
|
|
1684
1868
|
if (frame === null || typeof frame !== 'object' || Array.isArray(frame)) {
|
|
1685
|
-
runtime.events.refused.push({
|
|
1869
|
+
runtime.events.refused.push({
|
|
1870
|
+
reason_code: REFUSAL_CODE.P2P_FRAME_NOT_AN_OBJECT,
|
|
1871
|
+
reason: 'P2P_FRAME_NOT_AN_OBJECT: expected a JSON object',
|
|
1872
|
+
type: typeof frame,
|
|
1873
|
+
});
|
|
1686
1874
|
return;
|
|
1687
1875
|
}
|
|
1688
1876
|
if (frame.type !== 'task.result') {
|
|
@@ -1693,11 +1881,14 @@ async function handleP2PMessage(runtime, event) {
|
|
|
1693
1881
|
|
|
1694
1882
|
const problem = inboxKeyProblem(frame);
|
|
1695
1883
|
if (problem !== null) {
|
|
1696
|
-
|
|
1884
|
+
// `reason_code` travels beside `reason` so `w2m_status`'s inbox block is greppable by code rather
|
|
1885
|
+
// than by string prefix; this is a refusal at a boundary a user reads.
|
|
1886
|
+
runtime.events.refused.push({ reason_code: problem.reason_code, reason: problem.reason, type: 'task.result' });
|
|
1697
1887
|
return;
|
|
1698
1888
|
}
|
|
1699
1889
|
if (runtime.inboxDir === null) {
|
|
1700
1890
|
runtime.events.refused.push({
|
|
1891
|
+
reason_code: REFUSAL_CODE.P2P_NO_STATE_DIR,
|
|
1701
1892
|
reason: 'P2P_NO_STATE_DIR: stateDir is not set, so a received result has nowhere to be written',
|
|
1702
1893
|
type: 'task.result',
|
|
1703
1894
|
task_id: frame.task_id,
|
|
@@ -1725,7 +1916,12 @@ async function handleP2PMessage(runtime, event) {
|
|
|
1725
1916
|
} catch (error) {
|
|
1726
1917
|
// eslint-disable-next-line require-atomic-updates -- one message at a time; the write is the only await
|
|
1727
1918
|
runtime.events.last_error = `P2P_INBOX_WRITE_FAILED: ${error instanceof Error ? error.message : String(error)}`;
|
|
1728
|
-
runtime.events.refused.push({
|
|
1919
|
+
runtime.events.refused.push({
|
|
1920
|
+
reason_code: REFUSAL_CODE.P2P_INBOX_WRITE_FAILED,
|
|
1921
|
+
reason: runtime.events.last_error,
|
|
1922
|
+
type: 'task.result',
|
|
1923
|
+
task_id: frame.task_id,
|
|
1924
|
+
});
|
|
1729
1925
|
return;
|
|
1730
1926
|
}
|
|
1731
1927
|
runtime.events.results += 1;
|
|
@@ -1886,6 +2082,10 @@ function requireOperatorToken(cfg) {
|
|
|
1886
2082
|
'`POST /v1/task` requires the operator token',
|
|
1887
2083
|
'set `operatorToken` in this plugin\'s profile patch (or `W2M_OPERATOR_TOKEN` in the environment) ' +
|
|
1888
2084
|
'to the value the relay printed at startup, or read it from `<state>/operator-token.txt`',
|
|
2085
|
+
// The gate's own code. `W2M_CONFIG` names the class of the fault; this names the refusal, and it
|
|
2086
|
+
// is the one credential this plugin holds, so "why was my dispatch refused" must not have to be
|
|
2087
|
+
// answered by reading a message.
|
|
2088
|
+
REFUSAL_CODE.OPERATOR_TOKEN_MISSING,
|
|
1889
2089
|
);
|
|
1890
2090
|
}
|
|
1891
2091
|
|
|
@@ -2358,6 +2558,10 @@ async function readDevice(cfg) {
|
|
|
2358
2558
|
/**
|
|
2359
2559
|
* Require a `device_token`, naming where it was looked for when it is absent.
|
|
2360
2560
|
*
|
|
2561
|
+
* The `code` stays `W2M_NO_TOKEN` for every case — one class, "this machine cannot authenticate" —
|
|
2562
|
+
* while `reason_code` names which of the three it is, because the three have different remedies: pair
|
|
2563
|
+
* the agent, repair the file, or finish a pairing that never completed.
|
|
2564
|
+
*
|
|
2361
2565
|
* @param {{machine_id: string|null, device_token: string|null, path: string|null, present: boolean, error: string|null}} device
|
|
2362
2566
|
* @returns {string}
|
|
2363
2567
|
*/
|
|
@@ -2368,20 +2572,23 @@ function requireToken(device) {
|
|
|
2368
2572
|
'W2M_NO_TOKEN',
|
|
2369
2573
|
`W2M_NO_TOKEN: no paired device at \`${device.path ?? 'device.json'}\` - the file does not exist; ` +
|
|
2370
2574
|
'start the agent once so it can pair, or set `stateDir` to the directory holding an already-paired device.json',
|
|
2371
|
-
{
|
|
2575
|
+
{
|
|
2576
|
+
hint: 'start the agent once so it can pair, or set `stateDir` to the directory holding an already-paired device.json',
|
|
2577
|
+
reason_code: REFUSAL_CODE.DEVICE_NOT_PAIRED,
|
|
2578
|
+
},
|
|
2372
2579
|
);
|
|
2373
2580
|
}
|
|
2374
2581
|
if (device.error) {
|
|
2375
2582
|
throw new W2MError(
|
|
2376
2583
|
'W2M_NO_TOKEN',
|
|
2377
2584
|
`W2M_NO_TOKEN: could not read a device token from \`${device.path}\` - ${device.error}; repair or delete the file and pair again`,
|
|
2378
|
-
{ hint: 'repair or delete the file and pair again' },
|
|
2585
|
+
{ hint: 'repair or delete the file and pair again', reason_code: REFUSAL_CODE.DEVICE_UNREADABLE },
|
|
2379
2586
|
);
|
|
2380
2587
|
}
|
|
2381
2588
|
throw new W2MError(
|
|
2382
2589
|
'W2M_NO_TOKEN',
|
|
2383
2590
|
`W2M_NO_TOKEN: \`${device.path}\` has no \`device_token\` - the device has not completed POST /v1/pair yet; run the agent once to pair it`,
|
|
2384
|
-
{ hint: 'the device has not completed POST /v1/pair yet; run the agent once to pair it' },
|
|
2591
|
+
{ hint: 'the device has not completed POST /v1/pair yet; run the agent once to pair it', reason_code: REFUSAL_CODE.DEVICE_TOKEN_ABSENT },
|
|
2385
2592
|
);
|
|
2386
2593
|
}
|
|
2387
2594
|
|
|
@@ -2529,9 +2736,14 @@ function normalizeExecToken(value) {
|
|
|
2529
2736
|
* documented config `['node --test','git status --porcelain']` refused `['git','rev-parse','HEAD']`
|
|
2530
2737
|
* -- stricter than the gate, which is the direction that silently loses work.
|
|
2531
2738
|
*
|
|
2739
|
+
* A refusal names itself: `reason_code` is the matcher's own code (so the two ends cannot report one
|
|
2740
|
+
* refusal under two spellings) and `detail` says what would change it. The two ways this control
|
|
2741
|
+
* refuses are kept apart — a command outside the list and a list that cannot be parsed have
|
|
2742
|
+
* different remedies, and sharing one code would send the reader to the wrong one.
|
|
2743
|
+
*
|
|
2532
2744
|
* @param {string[]} allowedCommands Entries as configured, e.g. `['node --test']`.
|
|
2533
2745
|
* @param {string[]} argv
|
|
2534
|
-
* @returns {{
|
|
2746
|
+
* @returns {{allowed: string[], reason_code: string, message: string, detail: string}|null}
|
|
2535
2747
|
*/
|
|
2536
2748
|
function checkAllowedCommand(allowedCommands, argv) {
|
|
2537
2749
|
if (allowedCommands.length === 0) return null;
|
|
@@ -2542,20 +2754,32 @@ function checkAllowedCommand(allowedCommands, argv) {
|
|
|
2542
2754
|
// Fail closed: an unparseable list is not an empty one, and a control that cannot be evaluated
|
|
2543
2755
|
// must refuse rather than wave the command through.
|
|
2544
2756
|
return {
|
|
2545
|
-
code: 'COMMAND_NOT_ALLOWED',
|
|
2546
|
-
message: `the plugin's allowedCommands could not be parsed (${error?.message ?? String(error)})`,
|
|
2547
2757
|
allowed: [],
|
|
2758
|
+
reason_code: REFUSAL_CODE.ALLOWED_COMMANDS_UNPARSEABLE,
|
|
2759
|
+
message: `the plugin's allowedCommands could not be parsed (${error?.message ?? String(error)})`,
|
|
2760
|
+
detail:
|
|
2761
|
+
'the configured list is not a JSON array of command prefixes (entries are split on ' +
|
|
2762
|
+
'whitespace, and commas are not separators); fix `allowedCommands` in the profile patch, ' +
|
|
2763
|
+
'.w2m.json or machine.json - until it parses, this control cannot be evaluated and every ' +
|
|
2764
|
+
'dispatch is refused',
|
|
2548
2765
|
};
|
|
2549
2766
|
}
|
|
2550
2767
|
// Prefixes "as written": the configured entries, whitespace-normalised, in a stable order.
|
|
2551
2768
|
const allowed = prefixes.map((prefix) => prefix.join(' ')).sort();
|
|
2552
2769
|
const entries = prefixes.map((prefix) => [normalizeExecToken(prefix[0]), ...prefix.slice(1)]);
|
|
2553
2770
|
const command = [normalizeExecToken(argv[0]), ...argv.slice(1)];
|
|
2554
|
-
|
|
2771
|
+
const match = matchAllowedCommand(command, entries);
|
|
2772
|
+
if (match.allowed) return null;
|
|
2555
2773
|
return {
|
|
2556
|
-
code: 'COMMAND_NOT_ALLOWED',
|
|
2557
|
-
message: `\`${argv.join(' ')}\` is not in this plugin's allowedCommands (${allowed.join(', ')})`,
|
|
2558
2774
|
allowed,
|
|
2775
|
+
// The shared matcher's code, not a second copy of it: `COMMAND_NOT_ALLOWED` is the literal the
|
|
2776
|
+
// agent writes into its own envelope, so a machine's refusal and this pre-flight's refusal read
|
|
2777
|
+
// the same to whoever has to act on them.
|
|
2778
|
+
reason_code: match.reason_code ?? REFUSAL_CODE.COMMAND_NOT_ALLOWED,
|
|
2779
|
+
message: `\`${argv.join(' ')}\` is not in this plugin's allowedCommands (${allowed.join(', ')})`,
|
|
2780
|
+
detail:
|
|
2781
|
+
match.detail ??
|
|
2782
|
+
`add \`${argv.join(' ')}\` (or a prefix of it) to allowedCommands, or run a command the list already covers`,
|
|
2559
2783
|
};
|
|
2560
2784
|
}
|
|
2561
2785
|
|
|
@@ -3280,6 +3504,38 @@ export async function apply(ctx, config = {}) {
|
|
|
3280
3504
|
? null
|
|
3281
3505
|
: routedBy.applied_as;
|
|
3282
3506
|
|
|
3507
|
+
/**
|
|
3508
|
+
* *Why* the selection did or did not route this task, as a code rather than a bare boolean.
|
|
3509
|
+
*
|
|
3510
|
+
* `applied` alone conflates three different situations an operator has to tell apart: nothing
|
|
3511
|
+
* was selected, the selection was overridden by the call's own `executor_machine_id`, or a
|
|
3512
|
+
* selection exists that names no machine at all and therefore routed nothing. The last one is
|
|
3513
|
+
* the one that used to be invisible — a stored selection with no `machine_id` produces
|
|
3514
|
+
* `applied: false` and nothing else, which looks exactly like "the whole fleet ran it".
|
|
3515
|
+
*/
|
|
3516
|
+
const selectionReason = selection === null
|
|
3517
|
+
? {
|
|
3518
|
+
reason_code: REFUSAL_CODE.SELECTION_NONE,
|
|
3519
|
+
detail: 'no machine is selected for this session, so this dispatch was not narrowed to one',
|
|
3520
|
+
}
|
|
3521
|
+
: selectionAppliedAs !== null
|
|
3522
|
+
? {
|
|
3523
|
+
reason_code: REFUSAL_CODE.SELECTION_APPLIED,
|
|
3524
|
+
detail: `the composer's selected machine routed this dispatch, sent as ${selectionAppliedAs}=${selection.machine_id}`,
|
|
3525
|
+
}
|
|
3526
|
+
: mode === 'broadcast' && executorMachineId !== null
|
|
3527
|
+
? {
|
|
3528
|
+
reason_code: REFUSAL_CODE.SELECTION_OVERRIDDEN_BY_CALLER,
|
|
3529
|
+
detail: `this call named executor_machine_id=${executorMachineId}, which wins over the session selection and was sent instead`,
|
|
3530
|
+
}
|
|
3531
|
+
: {
|
|
3532
|
+
reason_code: REFUSAL_CODE.SELECTION_UNUSABLE,
|
|
3533
|
+
detail:
|
|
3534
|
+
'a machine is selected for this session but the selection names no machine_id, so ' +
|
|
3535
|
+
'nothing could be applied and the dispatch was not narrowed; pick a machine in the ' +
|
|
3536
|
+
'composer again, or clear the selection',
|
|
3537
|
+
};
|
|
3538
|
+
|
|
3283
3539
|
// Plugin-side allow-list check: only when one is configured. See checkAllowedCommand.
|
|
3284
3540
|
//
|
|
3285
3541
|
// **Every** stage is checked, not just the first. Checking only `command_argv` would let a
|
|
@@ -3291,14 +3547,21 @@ export async function apply(ctx, config = {}) {
|
|
|
3291
3547
|
for (const [stageIndex, stageArgv] of commandsToCheck.entries()) {
|
|
3292
3548
|
const refused = checkAllowedCommand(cfg.allowedCommands, stageArgv);
|
|
3293
3549
|
if (refused) {
|
|
3550
|
+
// The refusal is a *value*, like every other verdict this project reports, and it carries
|
|
3551
|
+
// the code twice on purpose: `refusal_reason` is the field a caller and a report already
|
|
3552
|
+
// read, `reason_code` is the same code under the name the rest of the gates now use, and
|
|
3553
|
+
// `detail` is what would change it. No request is made, so this result is the only trace
|
|
3554
|
+
// of the decision the caller gets.
|
|
3294
3555
|
return JSON.stringify(
|
|
3295
3556
|
{
|
|
3296
3557
|
ok: false,
|
|
3297
3558
|
state: 'refused',
|
|
3298
|
-
refusal_reason: refused.
|
|
3559
|
+
refusal_reason: refused.reason_code,
|
|
3560
|
+
reason_code: refused.reason_code,
|
|
3299
3561
|
message: commandsToCheck.length > 1
|
|
3300
3562
|
? `stage ${stageIndex}: ${refused.message}`
|
|
3301
3563
|
: refused.message,
|
|
3564
|
+
detail: refused.detail,
|
|
3302
3565
|
stage_index: commandsToCheck.length > 1 ? stageIndex : null,
|
|
3303
3566
|
allowed: refused.allowed,
|
|
3304
3567
|
},
|
|
@@ -3504,15 +3767,17 @@ export async function apply(ctx, config = {}) {
|
|
|
3504
3767
|
// v0.6.1: whether this dispatch was narrowed to the composer's selected machine, and to
|
|
3505
3768
|
// which one. Published because a dispatch that ran somewhere other than where the operator
|
|
3506
3769
|
// pointed is the one failure this feature could hide, and a caller that cannot see the
|
|
3507
|
-
// routing cannot report it.
|
|
3770
|
+
// routing cannot report it. `applied` is kept as the boolean it has always been; the code
|
|
3771
|
+
// and detail beside it say which of the four situations produced it.
|
|
3508
3772
|
selection: selection === null
|
|
3509
|
-
? { applied: false, field: null, machine_id: null, machine_name: null, session: sessionKey }
|
|
3773
|
+
? { applied: false, field: null, machine_id: null, machine_name: null, session: sessionKey, ...selectionReason }
|
|
3510
3774
|
: {
|
|
3511
3775
|
applied: selectionAppliedAs !== null,
|
|
3512
3776
|
field: selectionAppliedAs,
|
|
3513
3777
|
machine_id: selection.machine_id,
|
|
3514
3778
|
machine_name: selection.machine_name,
|
|
3515
3779
|
session: sessionKey,
|
|
3780
|
+
...selectionReason,
|
|
3516
3781
|
},
|
|
3517
3782
|
note: anchors.base_tree === null
|
|
3518
3783
|
? 'this machine\'s working tree could not be fingerprinted, so peers have no anchor to match and the aggregate will be unverifiable'
|