@twinsearth/w2m-dsh-plugin 0.9.6 → 0.9.8
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 +117 -0
- package/ROADMAP.md +135 -0
- package/package.json +6 -4
- package/src/agent/agent.mjs +38 -32
- package/src/agent/authority.mjs +305 -0
- package/src/plugin/tools.mjs +161 -7
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,123 @@ All notable changes to this project are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
5
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.9.8] — 2026-10-14
|
|
8
|
+
|
|
9
|
+
**The numeric authority model — the layer four of the five roadmap layers depend on — plus a roadmap and a memory write that reports its own outcome.**
|
|
10
|
+
|
|
11
|
+
### `ROADMAP.md`
|
|
12
|
+
|
|
13
|
+
The operator's architecture document specifies five layers across v4.7.0–v4.13.0. It lives in the
|
|
14
|
+
repository now, and it states the delta honestly before it states the plan: this repository has **one
|
|
15
|
+
relay and five machines**, against a target of 1,000+ agents. Every release row carries both a
|
|
16
|
+
deliverable and **the observation that would falsify it**, because a row without the second is a feature
|
|
17
|
+
list rather than a plan.
|
|
18
|
+
|
|
19
|
+
It also refuses three things on the record: it does not adopt a named protocol (InterMesh, A2A, NLIP,
|
|
20
|
+
AIX, MMP) before a line of its wire format exists; it does not build BFT consensus among five machines on
|
|
21
|
+
one relay, where it would add latency and failure modes without adding trust; and it does not specify
|
|
22
|
+
1,000-agent behaviour, because the source document's own §6.4 reports 30–50 agents as the practical limit
|
|
23
|
+
and superlinear "coordination collapse" beyond 100 — **a design not measured at 50 cannot honestly
|
|
24
|
+
specify 1,000.**
|
|
25
|
+
|
|
26
|
+
### `src/agent/authority.mjs` — v4.7.0
|
|
27
|
+
|
|
28
|
+
An integer `authority_level` on every actor, the four bands (0–49 ordinary, 50–69 captain, 70–89 org
|
|
29
|
+
admin, 90+ system admin), `matching_exempt_level` as the Unix-root-style exemption, and a decision
|
|
30
|
+
function returning `allow | needs_approval | deny` **with the rule that produced it**.
|
|
31
|
+
|
|
32
|
+
Why an integer rather than role names: authority appears in four of the five layers — permissions, the
|
|
33
|
+
approval flow, committee voting weights, and the defence hierarchy where a Coordinator outranks an
|
|
34
|
+
Analyst. An enumerated set of names would be re-partitioned by each layer, and every re-partition is a
|
|
35
|
+
place for two of them to disagree.
|
|
36
|
+
|
|
37
|
+
Three decisions are load-bearing and each is pinned by a test:
|
|
38
|
+
|
|
39
|
+
- **A rule with no `matching_exempt_level` exempts nobody.** The tempting default is "absent means
|
|
40
|
+
unconstrained", and it is wrong in the one direction that matters: a rule author who forgets the field
|
|
41
|
+
would silently create an unrestricted action.
|
|
42
|
+
- **An exemption bypasses membership, not the level floor.** The Unix analogy is exact — root bypasses a
|
|
43
|
+
file's permission bits, not the requirement that the file exists. A rule that says both is
|
|
44
|
+
self-contradictory, and the strict half wins.
|
|
45
|
+
- **A derived actor never exceeds its parent.** No rule in the source design grants delegation, and the
|
|
46
|
+
default an implementation reaches for — "a captain's delegate is a captain" — turns one compromised
|
|
47
|
+
actor into an escalation path. Where the source is silent, the narrow reading is the safe one.
|
|
48
|
+
|
|
49
|
+
### `evaluateAll` — a design corrected by its own test
|
|
50
|
+
|
|
51
|
+
The first draft evaluated every rule and returned the strictest verdict. A test asserting that the rules
|
|
52
|
+
after a gate are not evaluated failed, and the failure was right. `decisive` must be **unique**, or a
|
|
53
|
+
list can hold several non-allow decisions and whichever the caller picks, the *stated* reason for a
|
|
54
|
+
refusal stops being the reason. And a gate is a gate whether it says `deny` or `needs_approval`: in both
|
|
55
|
+
cases the caller's next action is determined and the remaining rules are decoration. It now stops at the
|
|
56
|
+
first non-allow.
|
|
57
|
+
|
|
58
|
+
### `envelope.memory` — a silent write that now reports
|
|
59
|
+
|
|
60
|
+
`appendMemory`'s result was deliberately unchecked, so a memory write could not fail a task. That remains
|
|
61
|
+
true — but leaving it entirely *silent* was the right call for the control flow and the wrong one for the
|
|
62
|
+
reader: **a machine whose memory is not being written is indistinguishable from one that has nothing to
|
|
63
|
+
remember**, which is precisely the class of invisible failure this layer exists to end. The envelope now
|
|
64
|
+
carries `memory: {written, file, rotated}` or `{written: false, reason_code}`. It is not a comparable
|
|
65
|
+
field, so no verdict moves and two machines cannot disagree over it.
|
|
66
|
+
|
|
67
|
+
### Verified
|
|
68
|
+
|
|
69
|
+
- `test/authority.test.mjs` — **27 tests, 0 failures.** Every one is about a boundary, because the only
|
|
70
|
+
interesting thing an authority model can get wrong is being off by one: a level too permissive is an
|
|
71
|
+
escalation and one too strict is an outage, so the band edges and the exemption boundary are asserted
|
|
72
|
+
**from both sides** rather than by a middle value that would pass either way.
|
|
73
|
+
- Band partition is proven total and non-overlapping across 0..100 by walking every level, so a seam that
|
|
74
|
+
belongs to two bands — a bug that only appears once someone is assigned that level — cannot exist.
|
|
75
|
+
- `eslint .` — 0 errors on the new module and test.
|
|
76
|
+
|
|
77
|
+
## [0.9.7] — 2026-10-14
|
|
78
|
+
|
|
79
|
+
**`w2m_memory` gains the collective layer and the one write a machine may not make for itself.**
|
|
80
|
+
|
|
81
|
+
### `scope: "fleet"` — the collective memory
|
|
82
|
+
|
|
83
|
+
Reads the curated `fleet-memory.md` that §3.2 of the design puts on the archive host, beside the profile
|
|
84
|
+
that mounted this plugin. It returns the document **as text, with its section list**, and does not parse
|
|
85
|
+
it into facts — deliberately. Hand-written prose has no schema, so a parser would silently drop whatever
|
|
86
|
+
shape it did not recognise, and what it dropped would be exactly what a human judged worth writing down.
|
|
87
|
+
The section list is there so a reader can see what the collective memory covers before paying for all of
|
|
88
|
+
it.
|
|
89
|
+
|
|
90
|
+
An absent file is answered honestly rather than as an empty document: `MEMORY_NO_COLLECTIVE`, with the
|
|
91
|
+
path that was looked at. *"Nothing has been curated yet"* and *"the memory is empty"* are different
|
|
92
|
+
facts, and only one of them means someone should start writing.
|
|
93
|
+
|
|
94
|
+
### `record` — a conclusion, which only a reader may assert
|
|
95
|
+
|
|
96
|
+
`memoryRecord` always emits `conclusion: null`, because a machine that invented one would be asserting an
|
|
97
|
+
interpretation it cannot have. This is the path by which a reader supplies it: the conclusion is appended
|
|
98
|
+
as a **new record naming the task it is about**, never written into the existing line.
|
|
99
|
+
|
|
100
|
+
Append-only is the point. In-place editing would break the property that makes the log trustworthy — what
|
|
101
|
+
was written is what happened, and an amendment is a later statement *about* it — and it would destroy the
|
|
102
|
+
original text, which is the thing a reader most needs when a conclusion turns out to be wrong.
|
|
103
|
+
|
|
104
|
+
### A defect this found in its own first draft
|
|
105
|
+
|
|
106
|
+
`collectivePath()` was `path.join(resolveProfileDir(cfg), COLLECTIVE_FILE)`. `resolveProfileDir`
|
|
107
|
+
legitimately returns `null` — a bare test context, or a host whose layout it cannot read — and the first
|
|
108
|
+
version passed that straight into `path.join`, which threw `ERR_INVALID_ARG_TYPE`. **A read tool that
|
|
109
|
+
crashes on a missing directory is worse than one that says the directory is missing:** the caller was
|
|
110
|
+
asking a question, and an exception is not an answer. It now resolves to `null`, reports
|
|
111
|
+
`MEMORY_NO_COLLECTIVE`, and says which of the two reasons applies.
|
|
112
|
+
|
|
113
|
+
### Verified
|
|
114
|
+
|
|
115
|
+
- `eslint .` — 0 errors.
|
|
116
|
+
- The contract suites — `memory`, `tools`, `tool-cards`, `claims`, `loader-entry`, `update-wiring`,
|
|
117
|
+
`p2p-plugin` — **230 pass, 0 fail, 11 platform skips**.
|
|
118
|
+
- Every scope exercised against a real temporary profile: `fleet` found the seeded document and listed
|
|
119
|
+
its five sections; `record` wrote a conclusion against a task and reported that no task existed when
|
|
120
|
+
none did; an absent collective file produced `MEMORY_NO_COLLECTIVE` rather than a throw.
|
|
121
|
+
- The tool count is unchanged at nine, so this release moves no contract: the same nine tools, two of them
|
|
122
|
+
wider.
|
|
123
|
+
|
|
7
124
|
## [0.9.6] — 2026-10-14
|
|
8
125
|
|
|
9
126
|
**Individual memory: the fleet can now answer "what did we already try on this machine, and what happened?" — a question nothing in it could answer before.**
|
package/ROADMAP.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Fleet roadmap — from a 5-machine fleet to a governed multi-agent system
|
|
2
|
+
|
|
3
|
+
Status: **plan, with one consumer already written.** This file exists so the roadmap lives in the
|
|
4
|
+
repository rather than in a conversation, and so each version's deliverable can be measured against
|
|
5
|
+
something rather than remembered.
|
|
6
|
+
|
|
7
|
+
Source: the operator's architecture document (2026-10-14), which specifies five layers — coordination,
|
|
8
|
+
memory, authority/scheduling, committee governance, and defence — across releases v4.7.0 to v4.13.0.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## 0. What this repository already is, stated honestly
|
|
13
|
+
|
|
14
|
+
Before the roadmap, the delta, because a plan that does not say where it starts is a wish.
|
|
15
|
+
|
|
16
|
+
| Layer | This repository today | The document's target |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| Networking | one relay, one fleet, five machines; P2P direct path when NAT allows | InterMesh/A2A/NATS-MQTT/libp2p/NLIP federation |
|
|
19
|
+
| Memory | **individual per-machine (shipped 0.9.6), collective curated document (0.9.7), conclusions (0.9.7)** | three-layer memory with field-level acceptance, lineage, consolidation and forgetting |
|
|
20
|
+
| Authority | one operator token; write scope; a global approval rate limiter | numeric `authority_level`, `matching_exempt_level`, a pending-approval index |
|
|
21
|
+
| Governance | none | AURA seven-phase council, weighted voting, recursive Sentinel/MetaSentinel |
|
|
22
|
+
| Scale | five machines, tasks in the hundreds | 1,000+ agents, sub-10 ms coordination, "coordination collapse" mitigation |
|
|
23
|
+
| Defence | hash-only ledger, HMAC signing, BFT-free consensus | three-tier sensor/analyst/coordinator with BFT |
|
|
24
|
+
|
|
25
|
+
**The gap is four to six orders of magnitude in agent count.** Nothing below claims otherwise, and the
|
|
26
|
+
roadmap is ordered so that what is built first is what the larger system would still need.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## 1. The one thing to build first, and why it is the only honest starting point
|
|
31
|
+
|
|
32
|
+
The document's own structure gives the answer: `authority_level` appears in **four of its five layers**.
|
|
33
|
+
It is in the permission model (§4.1), the approval flow (§4.2), the committee's weighted voting (§5.3,
|
|
34
|
+
weight includes authority) and the defence hierarchy (§2.3, where a Coordinator outranks an Analyst).
|
|
35
|
+
Nothing in the roadmap can be built without it, and building any of the rest first would mean inventing
|
|
36
|
+
authority implicitly — twice, differently.
|
|
37
|
+
|
|
38
|
+
**And it already has a consumer waiting.** §3.3's P1 is *field-level acceptance*: each agent decides
|
|
39
|
+
per field what to take from a peer, rather than accepting or rejecting a whole message. That is a
|
|
40
|
+
permission decision about a *field*, which is exactly what a numeric authority model expresses. The
|
|
41
|
+
memory layer shipped in 0.9.6/0.9.7 accepts everything or nothing today; `authority_level` is what turns
|
|
42
|
+
it into P1. So this is not a foundation poured in advance of a use — the use is already written.
|
|
43
|
+
|
|
44
|
+
## 2. Release plan
|
|
45
|
+
|
|
46
|
+
Each row states its **deliverable** and its **falsifying test** — the observation that would show it is
|
|
47
|
+
not working. A row without the second is a feature list, not a plan.
|
|
48
|
+
|
|
49
|
+
### v4.7.0 — authority model ← **next**
|
|
50
|
+
|
|
51
|
+
**Deliverable.** `src/agent/authority.mjs`: an integer `authority_level` on every actor, the four bands
|
|
52
|
+
(0–49 ordinary, 50–69 captain, 70–89 org admin, 90+ system admin), a rule evaluator carrying
|
|
53
|
+
`matching_exempt_level`, and a decision function returning `allow | needs_approval | deny` with the rule
|
|
54
|
+
that produced it.
|
|
55
|
+
|
|
56
|
+
**Falsifying tests.**
|
|
57
|
+
- an actor at exactly `matching_exempt_level` bypasses the membership check, and one level below does not
|
|
58
|
+
(an off-by-one here is an escalation, so the boundary is tested from both sides);
|
|
59
|
+
- an actor never inherits another's level by acting on its behalf — delegation is not assumed, because
|
|
60
|
+
the document does not grant it and inventing it would be the worst possible default;
|
|
61
|
+
- a rule with no `matching_exempt_level` exempts nobody, rather than exempting everybody.
|
|
62
|
+
|
|
63
|
+
### v4.8.0 — memory: field-level acceptance (MMP P1/P2)
|
|
64
|
+
|
|
65
|
+
**Deliverable.** Extend the shipped memory layer: a CAT7 seven-field cognitive block, per-field
|
|
66
|
+
acceptance driven by §2's authority model, and lineage — each accepted claim records the content hash of
|
|
67
|
+
its parent, so a returned claim is identifiable as the receiver's own earlier thought.
|
|
68
|
+
|
|
69
|
+
**Falsifying tests.** a field refused by policy is *absent* from the stored block rather than null; a
|
|
70
|
+
claim's ancestor chain survives a round trip; a claim whose parent is missing is refused rather than
|
|
71
|
+
silently re-rooted.
|
|
72
|
+
|
|
73
|
+
### v4.9.0 — approval queue and scheduling
|
|
74
|
+
|
|
75
|
+
**Deliverable.** A cross-session pending-approval index (§4.2's "supervisor reviews every agent's pending
|
|
76
|
+
approvals from one queue"), with the already-shipped rate limiter as its front door. Scheduling stays
|
|
77
|
+
out of scope here — MARL is not buildable at five machines, and pretending otherwise would be the most
|
|
78
|
+
expensive kind of dishonesty in this roadmap.
|
|
79
|
+
|
|
80
|
+
### v4.10.0 — committee governance
|
|
81
|
+
|
|
82
|
+
**Deliverable.** The AURA six-role council as a *single* decision engine with the seven phases, weighted
|
|
83
|
+
voting whose weights come from v4.7.0's authority plus reputation, and **dissent retention** — the losing
|
|
84
|
+
arguments are stored, because §5.1's "preserves dissenting agents' arguments" is the property that makes
|
|
85
|
+
a decision auditable later.
|
|
86
|
+
|
|
87
|
+
**Falsifying tests.** a phase cannot be skipped; a vote's weights are reproducible from stored inputs; a
|
|
88
|
+
decision with recorded dissent is retrievable *with* the dissent.
|
|
89
|
+
|
|
90
|
+
### v4.11.0–v4.13.0 — scale, defence, integration
|
|
91
|
+
|
|
92
|
+
Named for completeness, not planned in detail, because planning them now would produce fiction. The
|
|
93
|
+
document's own §6.4 is the reason: it reports 30–50 agents as the practical limit of current frameworks
|
|
94
|
+
and describes superlinear "coordination collapse" beyond 100. **A design that has not been measured at 50
|
|
95
|
+
cannot honestly specify 1,000**, and the bitmask/sub-10 ms claims are targets to be tested, not facts to
|
|
96
|
+
be designed around.
|
|
97
|
+
|
|
98
|
+
## 3. What this roadmap deliberately refuses
|
|
99
|
+
|
|
100
|
+
- **It does not adopt a protocol by name.** InterMesh, A2A, NLIP, AIX and MMP are cited in the source
|
|
101
|
+
document; none is implemented here yet, and listing one as "chosen" before a line of its wire format
|
|
102
|
+
exists would be a claim no test could falsify. What is implementable now is the *semantics* they share
|
|
103
|
+
— identity, authority, field-level acceptance, lineage — and that is what v4.7.0 and v4.8.0 build.
|
|
104
|
+
- **It does not build consensus where there is nothing to agree on.** BFT among five machines on one
|
|
105
|
+
relay adds latency and failure modes without adding trust. It becomes meaningful when there are
|
|
106
|
+
mutually-distrusting organisations, which is a v4.12.0 precondition, not a v4.7.0 one.
|
|
107
|
+
- **It does not put a governance layer above a fleet that has no disagreements yet.** A council that
|
|
108
|
+
decides nothing is indistinguishable from a delay. The council earns its place when two fleets want the
|
|
109
|
+
same machine — and that is measurable, so it can wait for the measurement.
|
|
110
|
+
- **It does not scale the agent count by declaring it scaled.** §6.4's collapse is a real reported
|
|
111
|
+
phenomenon; the response here is a benchmark before a design, not the reverse.
|
|
112
|
+
|
|
113
|
+
## 4. Where the numbers in the source document are treated as claims, not facts
|
|
114
|
+
|
|
115
|
+
Several figures in the source (20–40% faster convergence, 45% storage reduction, 37% stability
|
|
116
|
+
improvement, 85–120 ms decision latency at 100+ agents, 35–42% cache hit rates, 35–78% token reduction)
|
|
117
|
+
come from cited literature on other systems. They are recorded as *expectations to test at this fleet's
|
|
118
|
+
scale*, and no roadmap item above is justified by them. The one number this repository has measured
|
|
119
|
+
itself — the ~50 s task ceiling, from `heartbeatIntervalMs × missedHeartbeats + leaseGraceMs` — is in
|
|
120
|
+
`fleet-memory.md` with its three measurements, and it is the reason every long job here is started
|
|
121
|
+
detached.
|
|
122
|
+
|
|
123
|
+
## 5. Relationship to the memory layer already shipped
|
|
124
|
+
|
|
125
|
+
The shipped layer is not a detour from this roadmap; it is §3.1's L2 and part of L3, built early because
|
|
126
|
+
it was cheap and the gap it closed had been paid for repeatedly. Concretely:
|
|
127
|
+
|
|
128
|
+
- `memory.jsonl` per machine = **L2 individual memory**, persisted, owned by the machine.
|
|
129
|
+
- `fleet-memory.md` on the archive host = **L3 collective memory**, curated rather than generated.
|
|
130
|
+
- `w2m_memory` = the retrieval entry point for both, plus `record` for conclusions.
|
|
131
|
+
- **L1 conversation memory** and §3.2's *emergent* collective memory (environment traces) are **not**
|
|
132
|
+
built.
|
|
133
|
+
|
|
134
|
+
The honest summary: the memory layer is at the point where the next thing it needs is authority, which is
|
|
135
|
+
why v4.7.0 is the next thing this roadmap builds.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.8",
|
|
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",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"README.md",
|
|
29
29
|
"CHANGELOG.md",
|
|
30
30
|
"HANDOFF.md",
|
|
31
|
+
"ROADMAP.md",
|
|
31
32
|
"LICENSE",
|
|
32
33
|
"PROTOCOL.md",
|
|
33
34
|
"PROTOCOL-v0.1.2.md",
|
|
@@ -111,13 +112,13 @@
|
|
|
111
112
|
}
|
|
112
113
|
},
|
|
113
114
|
"scripts": {
|
|
114
|
-
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/memory.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 test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
|
|
115
|
+
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/memory.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 test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
|
|
115
116
|
"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",
|
|
116
117
|
"test:relay": "node --test --test-force-exit test/relay.test.mjs",
|
|
117
118
|
"test:agent": "node --test --test-force-exit test/agent.test.mjs test/memory.test.mjs",
|
|
118
119
|
"test:tools": "node --test --test-force-exit test/tools.test.mjs",
|
|
119
120
|
"test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
|
|
120
|
-
"test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/memory.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 test/p2p-crypto.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
|
|
121
|
+
"test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/memory.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 test/p2p-crypto.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/chatops-fleet-join.test.mjs",
|
|
121
122
|
"pack": "node scripts/pack.mjs",
|
|
122
123
|
"verify:join": "node scripts/verify-join.mjs",
|
|
123
124
|
"lint": "eslint .",
|
|
@@ -126,7 +127,8 @@
|
|
|
126
127
|
"format:check": "prettier --check .",
|
|
127
128
|
"verify": "node scripts/check-entrypoints.mjs && node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/tools.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/p2p-node.test.mjs test/p2p-agent.test.mjs test/client-bundle.test.mjs test/loader-entry.test.mjs",
|
|
128
129
|
"test:p2p": "node --test --test-force-exit test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/stun-server.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs",
|
|
129
|
-
"test:memory": "node --test --test-force-exit test/memory.test.mjs"
|
|
130
|
+
"test:memory": "node --test --test-force-exit test/memory.test.mjs",
|
|
131
|
+
"test:authority": "node --test --test-force-exit test/authority.test.mjs"
|
|
130
132
|
},
|
|
131
133
|
"keywords": [
|
|
132
134
|
"dsh-bundle",
|
package/src/agent/agent.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
2
|
* The Localside agent: everything between "Rabbit hands us an offer" and
|
|
3
3
|
* "Rabbit has our envelope".
|
|
4
4
|
*
|
|
@@ -48,7 +48,7 @@ import {
|
|
|
48
48
|
resolveProjectCwd,
|
|
49
49
|
} from './git.mjs';
|
|
50
50
|
import { createSpool } from './spool.mjs';
|
|
51
|
-
import { appendMemory, memoryRecord } from './memory.mjs';
|
|
51
|
+
import { MEMORY_FILE, appendMemory, memoryRecord } from './memory.mjs';
|
|
52
52
|
import { matchAllowedCommand, parseAllowedCommands } from './allowed-commands.mjs';
|
|
53
53
|
import { CRYPTO_REASON, DEVICE_KEY_FILE, DeviceKey, PeerKeyStore } from './crypto.mjs';
|
|
54
54
|
import { DEFAULT_P2P_MODE, P2P_DEFAULTS, P2PNode, normalizeP2PMode, normalizeP2PPort } from './p2p-node.mjs';
|
|
@@ -1094,7 +1094,7 @@ export function createAgent(options) {
|
|
|
1094
1094
|
// Loaded here, at construction, rather than lazily at `start()`: the key file is also what a
|
|
1095
1095
|
// *dispatcher* publishes in an offer, and "which key will this machine present" must not depend on
|
|
1096
1096
|
// whether a particular code path happened to have run yet. `loadOrCreate` is the documented rule
|
|
1097
|
-
// ("generated the first time a machine pairs"), and a failure is never fatal
|
|
1097
|
+
// ("generated the first time a machine pairs"), and a failure is never fatal - see `deviceKeyError`.
|
|
1098
1098
|
const deviceKeyOutcome = loadDeviceKey(options.deviceKey ?? null, stateDir, identity.machine_id, identityPath);
|
|
1099
1099
|
/** @type {object|null} This machine's static keypair, or null when there is none. */
|
|
1100
1100
|
const deviceKey = deviceKeyOutcome.key;
|
|
@@ -1103,7 +1103,7 @@ export function createAgent(options) {
|
|
|
1103
1103
|
/**
|
|
1104
1104
|
* A key adopted from the pre-v0.6.2 location (`--state/device.key`). Reported once at startup,
|
|
1105
1105
|
* because a machine whose key moved is a machine whose public key a peer may have learned under the
|
|
1106
|
-
* old file
|
|
1106
|
+
* old file - silence here would make "my peers stopped talking to me" have no visible cause.
|
|
1107
1107
|
*/
|
|
1108
1108
|
const deviceKeyMigratedFrom = deviceKeyOutcome.migratedFrom ?? null;
|
|
1109
1109
|
/**
|
|
@@ -1120,7 +1120,7 @@ export function createAgent(options) {
|
|
|
1120
1120
|
* What each task's direct channel actually was, for the result envelope.
|
|
1121
1121
|
*
|
|
1122
1122
|
* Keyed by `task_id`, alongside the channel map rather than on the channel itself, because the
|
|
1123
|
-
* envelope is built *after* `planDirectDelivery` may have released the channel
|
|
1123
|
+
* envelope is built *after* `planDirectDelivery` may have released the channel - and
|
|
1124
1124
|
* `p2p.encrypted` has to describe the channel that carried this attempt, not whichever channel
|
|
1125
1125
|
* happens to be live when the envelope is written.
|
|
1126
1126
|
*
|
|
@@ -1166,7 +1166,7 @@ export function createAgent(options) {
|
|
|
1166
1166
|
const p2pPeers = new Map();
|
|
1167
1167
|
|
|
1168
1168
|
/**
|
|
1169
|
-
* One reverse-dial attempt per `task_id`
|
|
1169
|
+
* One reverse-dial attempt per `task_id` - the executor's own half of a two-way punch.
|
|
1170
1170
|
*
|
|
1171
1171
|
* A record exists for every offer this agent takes on, including the ones it decides *not* to
|
|
1172
1172
|
* dial for, and that is deliberate: "we did not try, because a channel was already live" and "we
|
|
@@ -1444,8 +1444,8 @@ export function createAgent(options) {
|
|
|
1444
1444
|
* happened rather than what was possible. `reason` is the named code from that same channel, so
|
|
1445
1445
|
* `p2p.encrypted: false` always arrives with a *why*.
|
|
1446
1446
|
*
|
|
1447
|
-
* It is called from both places a channel becomes a task's channel
|
|
1448
|
-
* and the dial that opened one
|
|
1447
|
+
* It is called from both places a channel becomes a task's channel - the offer that arrived on one
|
|
1448
|
+
* and the dial that opened one - and the most recent call wins, which is right: a task's result
|
|
1449
1449
|
* travels over the channel that is live *now*.
|
|
1450
1450
|
*
|
|
1451
1451
|
* @param {string} taskId
|
|
@@ -1479,7 +1479,7 @@ export function createAgent(options) {
|
|
|
1479
1479
|
*
|
|
1480
1480
|
* Without this, a task executed entirely over the relay reports `encrypted: false` with no
|
|
1481
1481
|
* explanation, and a reader cannot tell "this machine has no crypto" from "no direct channel was the
|
|
1482
|
-
* answer for this task"
|
|
1482
|
+
* answer for this task" - entirely different operational facts. The reason is the *same* string
|
|
1483
1483
|
* `offerTransportOf` puts in `p2p.reason`, deliberately: one sentence, decided in one place.
|
|
1484
1484
|
*
|
|
1485
1485
|
* It never overwrites a channel's own record: once a channel carried something for this task, which
|
|
@@ -2118,7 +2118,7 @@ export function createAgent(options) {
|
|
|
2118
2118
|
* Signing headers for one request, or nothing at all.
|
|
2119
2119
|
*
|
|
2120
2120
|
* The signed `path` is the **API-relative** path plus its query
|
|
2121
|
-
* (`/v1/stream?machine_id
|
|
2121
|
+
* (`/v1/stream?machine_id= - seq=5`), never the URL as sent.
|
|
2122
2122
|
*
|
|
2123
2123
|
* That looks backwards -- surely the signature should cover the bytes on the
|
|
2124
2124
|
* wire? -- so here is the case that settles it. With `rabbitUrl =
|
|
@@ -2283,7 +2283,7 @@ export function createAgent(options) {
|
|
|
2283
2283
|
* * a **pairing code** is the operator saying *"let this machine in"*, and it is the only way to
|
|
2284
2284
|
* place a device in a fleet the device could not compute for itself;
|
|
2285
2285
|
* * a **fleet key** is the account saying *"this machine is mine"*, and the relay derives the
|
|
2286
|
-
* fleet from it
|
|
2286
|
+
* fleet from it - so it can only ever reach a fleet its holder is already entitled to.
|
|
2287
2287
|
*
|
|
2288
2288
|
* Passing neither remains allowed: the relay answers `PAIRING_INVALID` and the caller reports it,
|
|
2289
2289
|
* which is the pre-v0.8.1 behaviour for an unpaired machine.
|
|
@@ -2298,8 +2298,7 @@ export function createAgent(options) {
|
|
|
2298
2298
|
/**
|
|
2299
2299
|
* Send only the discriminator that was actually given.
|
|
2300
2300
|
*
|
|
2301
|
-
* `pairing_code: null` would be *present*, and a presence check reads a null as "provided"
|
|
2302
|
-
* the same trap that made `createILinkChannel` pass `{ fetch: undefined }` and throw on mount in
|
|
2301
|
+
* `pairing_code: null` would be *present*, and a presence check reads a null as "provided" - * the same trap that made `createILinkChannel` pass `{ fetch: undefined }` and throw on mount in
|
|
2303
2302
|
* v0.7.0. An absent code must be an absent key, not a null one.
|
|
2304
2303
|
*/
|
|
2305
2304
|
if (typeof pairingCode === 'string' && pairingCode !== '') body.pairing_code = pairingCode;
|
|
@@ -2419,7 +2418,7 @@ export function createAgent(options) {
|
|
|
2419
2418
|
* states, and a bound nobody measures is a claim: this is the number that says
|
|
2420
2419
|
* whether the result path really stayed inside it.
|
|
2421
2420
|
* * `result_error` -- why the direct copy of *this result* did not land, when one was attempted
|
|
2422
|
-
* and not acknowledged (`P2P_ACK_TIMEOUT:
|
|
2421
|
+
* and not acknowledged (`P2P_ACK_TIMEOUT: - ). Null otherwise; this is the
|
|
2423
2422
|
* `delivery.error` that `p2pDeliveryMark` has always carried and the envelope
|
|
2424
2423
|
* never reported.
|
|
2425
2424
|
*
|
|
@@ -2438,8 +2437,7 @@ export function createAgent(options) {
|
|
|
2438
2437
|
*
|
|
2439
2438
|
* Read from what the agent *recorded* when it held the channel, not from a live object: by the
|
|
2440
2439
|
* time the envelope is built the channel may have been released, and `p2p.encrypted` has to
|
|
2441
|
-
* describe the channel that carried this attempt. `null` means no channel was ever involved
|
|
2442
|
-
* a task executed entirely over the relay — which reports `encrypted: false` because that is the
|
|
2440
|
+
* describe the channel that carried this attempt. `null` means no channel was ever involved - * a task executed entirely over the relay - which reports `encrypted: false` because that is the
|
|
2443
2441
|
* truth about this task's data plane, whatever this machine's keypair could have done.
|
|
2444
2442
|
*/
|
|
2445
2443
|
const encryption = encryptionFor(offer?.task_id);
|
|
@@ -2653,17 +2651,25 @@ export function createAgent(options) {
|
|
|
2653
2651
|
* The write happens **after** the envelope is complete, so the record it stores is the same one the
|
|
2654
2652
|
* relay will judge -- not a second, independently assembled opinion about what happened.
|
|
2655
2653
|
*
|
|
2656
|
-
* `appendMemory` never throws and its result
|
|
2657
|
-
*
|
|
2658
|
-
*
|
|
2659
|
-
*
|
|
2654
|
+
* `appendMemory` never throws and its result never changes the verdict: the task is the work and
|
|
2655
|
+
* this is a note about it, so a full disk or an unwritable directory must not turn a successful
|
|
2656
|
+
* command into a failed task. A memory that can fail work is worse than no memory.
|
|
2657
|
+
*
|
|
2658
|
+
* v0.9.8: its *outcome* is nonetheless reported, as `memory` on the envelope. Leaving it entirely
|
|
2659
|
+
* silent was the right call for the control flow and the wrong one for the reader -- a machine whose
|
|
2660
|
+
* memory is not being written is indistinguishable from one that has nothing to remember, which is
|
|
2661
|
+
* exactly the class of invisible failure this layer exists to end. One optional field, no verdict
|
|
2662
|
+
* moved: it is not a comparable field, so two machines cannot disagree over it.
|
|
2660
2663
|
*/
|
|
2661
|
-
appendMemory(stateDir, memoryRecord({
|
|
2664
|
+
const memoryWrite = appendMemory(stateDir, memoryRecord({
|
|
2662
2665
|
envelope,
|
|
2663
2666
|
commandArgv,
|
|
2664
2667
|
cwdRel,
|
|
2665
2668
|
ms: (Date.parse(endedAt) - Date.parse(startedAt)) || 0,
|
|
2666
2669
|
}));
|
|
2670
|
+
envelope.memory = memoryWrite.ok === true
|
|
2671
|
+
? { written: true, file: MEMORY_FILE, rotated: memoryWrite.rotated === true }
|
|
2672
|
+
: { written: false, reason_code: memoryWrite.error };
|
|
2667
2673
|
|
|
2668
2674
|
return buildEnvelope(envelope);
|
|
2669
2675
|
}
|
|
@@ -3220,7 +3226,7 @@ export function createAgent(options) {
|
|
|
3220
3226
|
}
|
|
3221
3227
|
|
|
3222
3228
|
if (channelFor(offer?.task_id) === null) {
|
|
3223
|
-
// No channel at all, so nothing about this task's data plane was ever sealed
|
|
3229
|
+
// No channel at all, so nothing about this task's data plane was ever sealed - and the reason
|
|
3224
3230
|
// recorded here is the one `offerTransportOf` already decided for `p2p.reason`.
|
|
3225
3231
|
rememberNoChannel(offer?.task_id, offerTransportOf(offer, p2pStatusSnapshot()).reason);
|
|
3226
3232
|
return p2pDeliveryMark();
|
|
@@ -3282,7 +3288,7 @@ export function createAgent(options) {
|
|
|
3282
3288
|
|
|
3283
3289
|
const startedAt = process.hrtime.bigint();
|
|
3284
3290
|
// `machine_id` travels with the notice because the dispatcher has to be able to say *which*
|
|
3285
|
-
// machine finished without waiting for the relay's copy
|
|
3291
|
+
// machine finished without waiting for the relay's copy - the plugin names its direct-result
|
|
3286
3292
|
// inbox file after it. The frame still carries no envelope: `result_path` is decided by the
|
|
3287
3293
|
// acknowledgement this frame is waiting for, and `envelope_sha256` covers `result_path`, so
|
|
3288
3294
|
// sending the envelope first and stamping it afterwards would break the hash it is signed with.
|
|
@@ -3415,7 +3421,7 @@ export function createAgent(options) {
|
|
|
3415
3421
|
code = null;
|
|
3416
3422
|
}
|
|
3417
3423
|
const error = new Error(`stream HTTP ${response.status} ${body.slice(0, 200)}`.trim());
|
|
3418
|
-
// Preserve the relay's own code (SIGNATURE_REQUIRED, SIGNATURE_EXPIRED,
|
|
3424
|
+
// Preserve the relay's own code (SIGNATURE_REQUIRED, SIGNATURE_EXPIRED, -
|
|
3419
3425
|
// so a signed link can be diagnosed without guessing.
|
|
3420
3426
|
error.code = code ?? `HTTP_${response.status}`;
|
|
3421
3427
|
state.lastRelayError = {
|
|
@@ -3568,7 +3574,7 @@ export function createAgent(options) {
|
|
|
3568
3574
|
// peer tried to put on the wire is overwritten, so a relay frame can never claim the
|
|
3569
3575
|
// direct path -- the only thing that sets `'p2p'` here is the channel having delivered it.
|
|
3570
3576
|
const offer = { ...payload, p2p_transport: source.transport };
|
|
3571
|
-
// v0.6.0: the block's `static_keys` is the *entire* key-distribution mechanism
|
|
3577
|
+
// v0.6.0: the block's `static_keys` is the *entire* key-distribution mechanism - a machine
|
|
3572
3578
|
// learns the keys of exactly the peers it was asked to talk to, from the offer that asked it,
|
|
3573
3579
|
// and never from a config file. Learned here, before the offer is queued, because the reverse
|
|
3574
3580
|
// dial starts inside `handleOffer` and a key merged any later would miss the handshake this
|
|
@@ -4127,8 +4133,8 @@ export function createAgent(options) {
|
|
|
4127
4133
|
/**
|
|
4128
4134
|
* Load this machine's static keypair, or explain why there is none (v0.6.0).
|
|
4129
4135
|
*
|
|
4130
|
-
* Never throws. A machine whose key file is unreadable is still a working W2M machine
|
|
4131
|
-
* still travels over the relay
|
|
4136
|
+
* Never throws. A machine whose key file is unreadable is still a working W2M machine - every task
|
|
4137
|
+
* still travels over the relay - and `agent.state.p2p.encryption.error` names the file that could not
|
|
4132
4138
|
* be read so an operator can fix it. Refusing to start an agent because a *security upgrade* could not
|
|
4133
4139
|
* complete is exactly the fleet-losing failure `docs/P2P-CRYPTO.md` §4 forbids.
|
|
4134
4140
|
*
|
|
@@ -4138,15 +4144,15 @@ export function createAgent(options) {
|
|
|
4138
4144
|
* used to break it: it loaded from the `--state` directory, justified by a comment claiming that
|
|
4139
4145
|
* `identity.mjs` puts `device.json` there "when the agent is run with `--state`". That claim is false.
|
|
4140
4146
|
* `device.json` is fixed at `<DSH_HOME>/xclient/device.json` by the identity contract and `--state`
|
|
4141
|
-
* does not move it
|
|
4147
|
+
* does not move it - which is why an agent run as `--state ~/.dsh/xclient/localside-mac` had its
|
|
4142
4148
|
* identity one directory *above* its key.
|
|
4143
4149
|
*
|
|
4144
4150
|
* The measured consequence, on one machine running both halves of W2M: the plugin (which loads the key
|
|
4145
4151
|
* correctly, from `dirname(device.json)`) and the agent each generated a *different* pair, and both
|
|
4146
|
-
* announced to the one `/v1/peer/{machine_id}` slot
|
|
4152
|
+
* announced to the one `/v1/peer/{machine_id}` slot - the relay's record alternated between the two
|
|
4147
4153
|
* nodes' ports every few seconds, and the `static_key` a dispatcher read was one node's or the other's.
|
|
4148
4154
|
* A dispatcher that learns the wrong key cannot arm a handshake, so every direct channel came back
|
|
4149
|
-
* `P2P_CRYPTO_PLAINTEXT_PEER`
|
|
4155
|
+
* `P2P_CRYPTO_PLAINTEXT_PEER` - reported as "the peer is an older build" when both ends were the same
|
|
4150
4156
|
* build on the same host.
|
|
4151
4157
|
*
|
|
4152
4158
|
* MIGRATION. `DeviceKey.load` warns that "silently generating a replacement key would make every peer
|
|
@@ -4168,7 +4174,7 @@ function loadDeviceKey(injected, stateDir, machineId, deviceFile = undefined) {
|
|
|
4168
4174
|
return { key: injected, error: null, migratedFrom: null };
|
|
4169
4175
|
}
|
|
4170
4176
|
|
|
4171
|
-
// Beside `device.json`
|
|
4177
|
+
// Beside `device.json` - the documented rule. `identityPath` is the path the caller actually
|
|
4172
4178
|
// loaded, never a fresh global resolution: resolving `DSH_HOME` here would make a test (or any
|
|
4173
4179
|
// caller with an injected identity) read and *create* a key in the real user's home.
|
|
4174
4180
|
let canonicalDir = null;
|
|
@@ -4179,7 +4185,7 @@ function loadDeviceKey(injected, stateDir, machineId, deviceFile = undefined) {
|
|
|
4179
4185
|
/**
|
|
4180
4186
|
* Candidates in order. `mayCreate` is the whole subtlety: only a location that is *the* documented
|
|
4181
4187
|
* home may mint a key. A legacy directory is consulted to adopt a key that is already there, never
|
|
4182
|
-
* to create one
|
|
4188
|
+
* to create one - `DeviceKey.load` warns that generating a replacement "would make every peer that
|
|
4183
4189
|
* knows the old one refuse this machine", so an upgrade must not silently change this machine's
|
|
4184
4190
|
* public key.
|
|
4185
4191
|
*
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Numeric authority: who may do what, expressed as an integer.
|
|
3
|
+
*
|
|
4
|
+
* ## Why an integer rather than a list of names
|
|
5
|
+
*
|
|
6
|
+
* The roadmap has authority appearing in four of its five layers -- the permission model, the approval
|
|
7
|
+
* flow, committee voting weights, and the defence hierarchy where a Coordinator outranks an Analyst. An
|
|
8
|
+
* enumerated set of role names would have to be re-partitioned by each of those layers, and each
|
|
9
|
+
* re-partition would be a place for two of them to disagree. An integer orders every actor on one axis,
|
|
10
|
+
* so "does A outrank B" is a comparison rather than a lookup table that has to be kept in step.
|
|
11
|
+
*
|
|
12
|
+
* The model is Unix-like on purpose: `matching_exempt_level` mirrors root's exemption from file
|
|
13
|
+
* permission checks. That is a *deliberate* hole in the rules, so it is the most carefully bounded thing
|
|
14
|
+
* in this file -- see `evaluate`.
|
|
15
|
+
*
|
|
16
|
+
* ## The four bands
|
|
17
|
+
*
|
|
18
|
+
* | Band | Range | May |
|
|
19
|
+
* |---|---|---|
|
|
20
|
+
* | ordinary | 0–49 | create goals and session data within its own scope |
|
|
21
|
+
* | captain | 50–69 | create team goals, manage team membership |
|
|
22
|
+
* | org admin | 70–89 | create organisation goals, register actors, set overrides |
|
|
23
|
+
* | system admin | 90+ | create global goals, configure across organisations, override any lower constraint |
|
|
24
|
+
*
|
|
25
|
+
* ## What this module deliberately does not do
|
|
26
|
+
*
|
|
27
|
+
* It does not decide *what* an action is, only whether an actor's level satisfies a stated rule. Action
|
|
28
|
+
* classification -- is this a write, a scope change, a registration -- belongs to whatever knows the
|
|
29
|
+
* action, and putting it here would mean this file had to be edited every time a new action appeared.
|
|
30
|
+
* That is how an authority model becomes a bottleneck and then gets bypassed.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** The bands, as data rather than as branches, so a reader can enumerate them. */
|
|
34
|
+
export const AUTHORITY_BANDS = Object.freeze([
|
|
35
|
+
{ name: 'ordinary', min: 0, max: 49, may: 'create goals and session data within its own scope' },
|
|
36
|
+
{ name: 'captain', min: 50, max: 69, may: 'create team goals, manage team membership' },
|
|
37
|
+
{ name: 'org_admin', min: 70, max: 89, may: 'create organisation goals, register actors, set overrides' },
|
|
38
|
+
{ name: 'system_admin', min: 90, max: null, may: 'create global goals, configure across organisations, override any lower constraint' },
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/** The highest level this model will accept. Above it, a level is a mistake rather than a promotion. */
|
|
42
|
+
export const AUTHORITY_MAX = 100;
|
|
43
|
+
|
|
44
|
+
/** Verdicts an authority check can reach. */
|
|
45
|
+
export const AUTHORITY_VERDICT = Object.freeze({
|
|
46
|
+
ALLOW: 'allow',
|
|
47
|
+
NEEDS_APPROVAL: 'needs_approval',
|
|
48
|
+
DENY: 'deny',
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Is this a usable authority level?
|
|
53
|
+
*
|
|
54
|
+
* Rejects non-integers as well as out-of-range values. `50.5` is not "between captain and org admin"; it
|
|
55
|
+
* is a value nobody defined, and accepting it would make band boundaries behave differently for actors
|
|
56
|
+
* that are supposed to be equal.
|
|
57
|
+
*
|
|
58
|
+
* @param {unknown} level
|
|
59
|
+
* @returns {boolean}
|
|
60
|
+
*/
|
|
61
|
+
export function isAuthorityLevel(level) {
|
|
62
|
+
return Number.isInteger(level) && level >= 0 && level <= AUTHORITY_MAX;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Which band a level falls in, or `null` for a value that is not a level.
|
|
67
|
+
*
|
|
68
|
+
* @param {unknown} level
|
|
69
|
+
* @returns {{name: string, min: number, max: number|null, may: string}|null}
|
|
70
|
+
*/
|
|
71
|
+
export function bandOf(level) {
|
|
72
|
+
if (!isAuthorityLevel(level)) return null;
|
|
73
|
+
for (const band of AUTHORITY_BANDS) {
|
|
74
|
+
if (level >= band.min && (band.max === null || level <= band.max)) return band;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A rule about an action.
|
|
81
|
+
*
|
|
82
|
+
* `minLevel` is the floor for performing it at all. `matchingExemptLevel`, when present, is the level at
|
|
83
|
+
* or above which the *membership* part of the rule is skipped -- the Unix-root analogy. `scope` names what
|
|
84
|
+
* the action is performed against, so a refusal can say which rule refused it.
|
|
85
|
+
*
|
|
86
|
+
* @typedef {object} AuthorityRule
|
|
87
|
+
* @property {string} action
|
|
88
|
+
* @property {number} minLevel
|
|
89
|
+
* @property {number|null} [matchingExemptLevel]
|
|
90
|
+
* @property {string|null} [scope]
|
|
91
|
+
* @property {string} [why]
|
|
92
|
+
*/
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Cheap structural validation, separate from evaluation.
|
|
96
|
+
*
|
|
97
|
+
* A malformed rule is a configuration defect and should be caught where it is written, not silently
|
|
98
|
+
* treated as a rule that permits nothing -- "denied" and "misconfigured" look identical from the outside
|
|
99
|
+
* and only one of them means someone should fix something.
|
|
100
|
+
*
|
|
101
|
+
* @param {unknown} rule
|
|
102
|
+
* @returns {string|null} a reason, or `null` when the rule is well formed.
|
|
103
|
+
*/
|
|
104
|
+
export function ruleProblem(rule) {
|
|
105
|
+
if (rule === null || typeof rule !== 'object') return 'rule must be an object';
|
|
106
|
+
if (typeof rule.action !== 'string' || rule.action.trim() === '') return 'rule.action must be a non-empty string';
|
|
107
|
+
if (!isAuthorityLevel(rule.minLevel)) return `rule.minLevel must be an integer 0..${AUTHORITY_MAX}`;
|
|
108
|
+
if (rule.matchingExemptLevel !== undefined && rule.matchingExemptLevel !== null
|
|
109
|
+
&& !isAuthorityLevel(rule.matchingExemptLevel)) {
|
|
110
|
+
return `rule.matchingExemptLevel must be an integer 0..${AUTHORITY_MAX} or null`;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Evaluate one rule against one actor.
|
|
117
|
+
*
|
|
118
|
+
* The order of the three checks is the whole security property, so it is stated rather than implied:
|
|
119
|
+
*
|
|
120
|
+
* 1. **Exemption**, and only when the rule names one. An exempt actor skips the *membership* check --
|
|
121
|
+
* not the level check, and not the existence of the rule. This is the Unix-root analogy: root
|
|
122
|
+
* bypasses a file's permission bits, not the fact that the file must exist.
|
|
123
|
+
* 2. **Membership**, when the rule has a scope and the caller supplied one. An actor outside the scope
|
|
124
|
+
* needs a supervisor, not a refusal, which is why this yields `needs_approval` rather than `deny`.
|
|
125
|
+
* 3. **Level**, the plain floor.
|
|
126
|
+
*
|
|
127
|
+
* A rule with no `matchingExemptLevel` exempts **nobody**. That is the decision most worth stating: the
|
|
128
|
+
* tempting default is "absent means unconstrained", and it is wrong in the one direction that matters --
|
|
129
|
+
* a rule author who forgets the field would silently create an unrestricted action.
|
|
130
|
+
*
|
|
131
|
+
* @param {AuthorityRule} rule
|
|
132
|
+
* @param {{level: number, inScope?: boolean|null, id?: string|null}} actor
|
|
133
|
+
* @returns {{verdict: string, action: string|null, level: number|null, exempt: boolean, reason: string}}
|
|
134
|
+
*/
|
|
135
|
+
export function evaluate(rule, actor) {
|
|
136
|
+
const problem = ruleProblem(rule);
|
|
137
|
+
if (problem !== null) {
|
|
138
|
+
return {
|
|
139
|
+
verdict: AUTHORITY_VERDICT.DENY,
|
|
140
|
+
action: rule?.action ?? null,
|
|
141
|
+
level: actor?.level ?? null,
|
|
142
|
+
exempt: false,
|
|
143
|
+
reason: `malformed rule: ${problem}`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
if (!isAuthorityLevel(actor?.level)) {
|
|
147
|
+
return {
|
|
148
|
+
verdict: AUTHORITY_VERDICT.DENY,
|
|
149
|
+
action: rule.action,
|
|
150
|
+
level: actor?.level ?? null,
|
|
151
|
+
exempt: false,
|
|
152
|
+
reason: `actor has no usable authority level (got ${JSON.stringify(actor?.level ?? null)})`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const exemptLevel = rule.matchingExemptLevel ?? null;
|
|
157
|
+
const exempt = exemptLevel !== null && actor.level >= exemptLevel;
|
|
158
|
+
|
|
159
|
+
// 2. Membership, skipped entirely for an exempt actor.
|
|
160
|
+
if (!exempt && rule.scope !== undefined && rule.scope !== null && actor.inScope === false) {
|
|
161
|
+
return {
|
|
162
|
+
verdict: AUTHORITY_VERDICT.NEEDS_APPROVAL,
|
|
163
|
+
action: rule.action,
|
|
164
|
+
level: actor.level,
|
|
165
|
+
exempt: false,
|
|
166
|
+
reason: `outside the scope of "${rule.action}" (${rule.scope}); a supervisor within it may approve`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 3. The floor. Checked even for an exempt actor -- see the header.
|
|
171
|
+
if (actor.level < rule.minLevel) {
|
|
172
|
+
return {
|
|
173
|
+
verdict: AUTHORITY_VERDICT.NEEDS_APPROVAL,
|
|
174
|
+
action: rule.action,
|
|
175
|
+
level: actor.level,
|
|
176
|
+
exempt,
|
|
177
|
+
reason: `level ${actor.level} is below the minimum ${rule.minLevel} for "${rule.action}"`,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
verdict: AUTHORITY_VERDICT.ALLOW,
|
|
183
|
+
action: rule.action,
|
|
184
|
+
level: actor.level,
|
|
185
|
+
exempt,
|
|
186
|
+
reason: exempt
|
|
187
|
+
? `allowed: level ${actor.level} meets the exemption ${exemptLevel} for "${rule.action}"`
|
|
188
|
+
: `allowed: level ${actor.level} meets the minimum ${rule.minLevel} for "${rule.action}"`,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Evaluate several rules, stopping at the first one that is not a plain allow.
|
|
194
|
+
*
|
|
195
|
+
* ## Why it stops, rather than evaluating everything
|
|
196
|
+
*
|
|
197
|
+
* An earlier draft evaluated all rules and returned the strictest verdict. It is the more obvious design
|
|
198
|
+
* and the less useful one, for two reasons that both matter to the person reading the answer:
|
|
199
|
+
*
|
|
200
|
+
* * **`decisive` must be unique.** With every rule evaluated, a list can hold several non-allow
|
|
201
|
+
* decisions and the caller has to pick one -- and whichever it picks, the *stated* reason for a
|
|
202
|
+
* refusal stops being the reason. An operator asking "why was this not allowed" needs one rule.
|
|
203
|
+
* * **The unevaluated rules may not even apply.** A rule is evaluated against an actor, and an actor's
|
|
204
|
+
* scope can be learned from the outcome of an earlier rule; continuing past a gate would evaluate
|
|
205
|
+
* later rules against a context the gate had already made moot.
|
|
206
|
+
*
|
|
207
|
+
* Stopping at the *first* non-allow -- not merely at the first deny -- follows from the same argument. A
|
|
208
|
+
* gate is a gate whether it says `deny` or `needs_approval`, and in both cases the caller's next action
|
|
209
|
+
* is determined and the rest of the list is decoration.
|
|
210
|
+
*
|
|
211
|
+
* @param {AuthorityRule[]} rules
|
|
212
|
+
* @param {{level: number, inScope?: boolean|null, id?: string|null}} actor
|
|
213
|
+
* @returns {{verdict: string, decisions: object[], decisive: object|null}}
|
|
214
|
+
*/
|
|
215
|
+
export function evaluateAll(rules, actor) {
|
|
216
|
+
const decisions = [];
|
|
217
|
+
for (const rule of Array.isArray(rules) ? rules : []) {
|
|
218
|
+
const decision = evaluate(rule, actor);
|
|
219
|
+
decisions.push(decision);
|
|
220
|
+
if (decision.verdict !== AUTHORITY_VERDICT.ALLOW) {
|
|
221
|
+
return { verdict: decision.verdict, decisions, decisive: decision };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return { verdict: AUTHORITY_VERDICT.ALLOW, decisions, decisive: null };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The level required to sign off on a refusal.
|
|
229
|
+
*
|
|
230
|
+
* One band up, floored at the captain band, because the document's approval flow is a chain --
|
|
231
|
+
* captain, then org admin, then system admin -- and a chain that can be satisfied by the actor's own peer
|
|
232
|
+
* group is not a chain. An actor already at the top band has no higher approver, so the top band signs
|
|
233
|
+
* for itself; saying otherwise would make a system-admin action unapprovable.
|
|
234
|
+
*
|
|
235
|
+
* @param {number} level
|
|
236
|
+
* @returns {number|null}
|
|
237
|
+
*/
|
|
238
|
+
export function requiredApproverLevel(level) {
|
|
239
|
+
const band = bandOf(level);
|
|
240
|
+
if (band === null) return null;
|
|
241
|
+
if (band.name === 'system_admin') return 90;
|
|
242
|
+
const higher = AUTHORITY_BANDS[AUTHORITY_BANDS.indexOf(band) + 1];
|
|
243
|
+
return higher === undefined ? null : higher.min;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Is `approver` allowed to sign off on `subject`'s pending action?
|
|
248
|
+
*
|
|
249
|
+
* Strictly higher, not equal-or-higher. Two captains approving each other is a peer ring, and the whole
|
|
250
|
+
* point of the chain is that a decision can be escalated past the level that wanted it. Self-approval is
|
|
251
|
+
* refused for the same reason, stated separately because it is the case an implementation is most likely
|
|
252
|
+
* to let through by comparing only levels.
|
|
253
|
+
*
|
|
254
|
+
* @param {{level: number, id?: string|null}} approver
|
|
255
|
+
* @param {{level: number, id?: string|null}} subject
|
|
256
|
+
* @returns {{ok: boolean, reason: string, required: number|null}}
|
|
257
|
+
*/
|
|
258
|
+
export function mayApprove(approver, subject) {
|
|
259
|
+
const required = requiredApproverLevel(subject?.level);
|
|
260
|
+
if (required === null) {
|
|
261
|
+
return { ok: false, required: null, reason: `subject has no usable authority level (got ${JSON.stringify(subject?.level ?? null)})` };
|
|
262
|
+
}
|
|
263
|
+
if (!isAuthorityLevel(approver?.level)) {
|
|
264
|
+
return { ok: false, required, reason: `approver has no usable authority level (got ${JSON.stringify(approver?.level ?? null)})` };
|
|
265
|
+
}
|
|
266
|
+
if (approver.id !== undefined && approver.id !== null && approver.id === subject?.id) {
|
|
267
|
+
return { ok: false, required, reason: 'an actor may not approve its own request' };
|
|
268
|
+
}
|
|
269
|
+
if (approver.level < required) {
|
|
270
|
+
return { ok: false, required, reason: `approver level ${approver.level} is below the required ${required}` };
|
|
271
|
+
}
|
|
272
|
+
return { ok: true, required, reason: `approver level ${approver.level} meets the required ${required}` };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Compose a child actor's authority from its parent's.
|
|
277
|
+
*
|
|
278
|
+
* **This never increases it.** No rule in the source design grants delegation, and the default an
|
|
279
|
+
* implementation reaches for -- "a captain's delegate is a captain" -- turns any single compromised
|
|
280
|
+
* actor into an escalation path. Where the source is silent, the safe reading is the narrow one, and the
|
|
281
|
+
* `reason` says which of the two rules applied so a later reader can tell a deliberate cap from a bug.
|
|
282
|
+
*
|
|
283
|
+
* @param {{level: number, id?: string|null}} parent
|
|
284
|
+
* @param {{level?: number, cap?: number|null, id?: string|null}} child
|
|
285
|
+
* @returns {{level: number|null, capped: boolean, reason: string}}
|
|
286
|
+
*/
|
|
287
|
+
export function deriveAuthority(parent, child = {}) {
|
|
288
|
+
if (!isAuthorityLevel(parent?.level)) {
|
|
289
|
+
return { level: null, capped: false, reason: `parent has no usable authority level (got ${JSON.stringify(parent?.level ?? null)})` };
|
|
290
|
+
}
|
|
291
|
+
if (child.level !== undefined && !isAuthorityLevel(child.level)) {
|
|
292
|
+
return { level: null, capped: false, reason: `child level is not usable (got ${JSON.stringify(child.level)})` };
|
|
293
|
+
}
|
|
294
|
+
const asked = child.level ?? parent.level;
|
|
295
|
+
const cap = isAuthorityLevel(child.cap) ? child.cap : parent.level;
|
|
296
|
+
const ceiling = Math.min(parent.level, cap);
|
|
297
|
+
const level = Math.min(asked, ceiling);
|
|
298
|
+
return {
|
|
299
|
+
level,
|
|
300
|
+
capped: level < asked,
|
|
301
|
+
reason: level < asked
|
|
302
|
+
? `reduced from ${asked} to ${level}: a derived actor never exceeds its parent (${parent.level}) or its cap (${cap})`
|
|
303
|
+
: `granted ${level}: within parent (${parent.level}) and cap (${cap})`,
|
|
304
|
+
};
|
|
305
|
+
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -97,7 +97,7 @@ import path from 'node:path';
|
|
|
97
97
|
// file here: the reader carries the rules that matter -- newest-first by file position, a malformed line
|
|
98
98
|
// skipped rather than fatal, and the rotated generation reachable -- and a second implementation would
|
|
99
99
|
// drift from the writer's shape without anything failing.
|
|
100
|
-
import { readMemorySync } from '../agent/memory.mjs';
|
|
100
|
+
import { appendMemory, readMemorySync } from '../agent/memory.mjs';
|
|
101
101
|
|
|
102
102
|
// The base-address rules live in one place (§2), shared with the agent side. Importing the - rather
|
|
103
103
|
// than re-deriving them her - is what stops the plugin and the agent from disagreeing about what
|
|
@@ -185,7 +185,7 @@ const MAX_STAGES = 16;
|
|
|
185
185
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
186
186
|
* overrides it at runtime for tests and packagers.
|
|
187
187
|
*/
|
|
188
|
-
const PLUGIN_VERSION = '0.9.
|
|
188
|
+
const PLUGIN_VERSION = '0.9.8';
|
|
189
189
|
|
|
190
190
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
191
191
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -4687,15 +4687,48 @@ export async function apply(ctx, config = {}) {
|
|
|
4687
4687
|
* fleet's roster still comes from the relay, so the answer distinguishes "no such machine" from "that
|
|
4688
4688
|
* machine keeps its own memory and this build cannot fetch it".
|
|
4689
4689
|
*/
|
|
4690
|
+
/**
|
|
4691
|
+
* The collective layer's filename, beside the profile that mounted this plugin.
|
|
4692
|
+
*
|
|
4693
|
+
* §3.2 of the design puts curated fleet knowledge on the archive host. This is the read half of it, and
|
|
4694
|
+
* it deliberately reads a *markdown document* rather than a store: the design's own argument is that a
|
|
4695
|
+
* memory which is merely a log is one nobody reads, so the collective layer is written for a person and
|
|
4696
|
+
* this tool returns it as text rather than pretending to have parsed it into facts. A parser over
|
|
4697
|
+
* hand-written prose would silently drop whatever it did not recognise, and the dropped part would be
|
|
4698
|
+
* exactly the part a human thought was worth writing down.
|
|
4699
|
+
*/
|
|
4700
|
+
const COLLECTIVE_FILE = 'fleet-memory.md';
|
|
4701
|
+
/**
|
|
4702
|
+
* The collective document's path, or `null` when there is no profile to resolve it against.
|
|
4703
|
+
*
|
|
4704
|
+
* `resolveProfileDir` legitimately returns `null` -- a bare test context, or a host whose layout it
|
|
4705
|
+
* cannot read -- and the first version of this passed that straight into `path.join`, which threw
|
|
4706
|
+
* `ERR_INVALID_ARG_TYPE`. A read tool that crashes on a missing directory is worse than one that says
|
|
4707
|
+
* the directory is missing: the caller was asking a question, and an exception is not an answer.
|
|
4708
|
+
*/
|
|
4709
|
+
const collectivePath = () => {
|
|
4710
|
+
const dir = resolveProfileDir(cfg);
|
|
4711
|
+
return typeof dir === 'string' && dir !== '' ? path.join(dir, COLLECTIVE_FILE) : null;
|
|
4712
|
+
};
|
|
4713
|
+
|
|
4690
4714
|
ctx.tools.register(defineTool({
|
|
4691
4715
|
name: 'w2m_memory',
|
|
4692
4716
|
description:
|
|
4693
|
-
'Ask what
|
|
4694
|
-
'
|
|
4695
|
-
'
|
|
4696
|
-
'
|
|
4697
|
-
'
|
|
4717
|
+
'Ask what the fleet already knows and what this machine has already done. scope="local" reads ' +
|
|
4718
|
+
'this machine\'s individual memory: one line per finished task with the command, exit code, ' +
|
|
4719
|
+
'duration, transport and the tails of stdout/stderr. scope="fleet" reads the curated collective ' +
|
|
4720
|
+
'memory: measured facts with their dates, decisions with their reasoning, and open problems. The ' +
|
|
4721
|
+
'relay keeps only hashes, so these files are the only place a command\'s output survives. Use it ' +
|
|
4722
|
+
'before re-running work -- it is what turns a dozen rediscovery dispatches into one lookup.',
|
|
4698
4723
|
parameters: {
|
|
4724
|
+
scope: {
|
|
4725
|
+
type: 'string',
|
|
4726
|
+
enum: ['local', 'fleet'],
|
|
4727
|
+
description:
|
|
4728
|
+
'local (default) = what this machine did; fleet = what the fleet knows, from the curated ' +
|
|
4729
|
+
'collective memory.',
|
|
4730
|
+
default: 'local',
|
|
4731
|
+
},
|
|
4699
4732
|
machine: {
|
|
4700
4733
|
type: 'string',
|
|
4701
4734
|
description:
|
|
@@ -4714,6 +4747,13 @@ export async function apply(ctx, config = {}) {
|
|
|
4714
4747
|
'memory question about anything older than the current file needs this.',
|
|
4715
4748
|
default: false,
|
|
4716
4749
|
},
|
|
4750
|
+
record: {
|
|
4751
|
+
type: 'string',
|
|
4752
|
+
description:
|
|
4753
|
+
'Record a conclusion against the most recent task: what its result meant. This is the only ' +
|
|
4754
|
+
'field a machine is not allowed to write itself -- the machine stores the fact, a reader ' +
|
|
4755
|
+
'asserts the meaning. Written as a new record so nothing already written is rewritten.',
|
|
4756
|
+
},
|
|
4717
4757
|
},
|
|
4718
4758
|
output: jsonOutput,
|
|
4719
4759
|
async execute(args, exec) {
|
|
@@ -4726,6 +4766,120 @@ export async function apply(ctx, config = {}) {
|
|
|
4726
4766
|
const self = identity?.machine_name ?? null;
|
|
4727
4767
|
const selfId = identity?.machine_id ?? null;
|
|
4728
4768
|
|
|
4769
|
+
/**
|
|
4770
|
+
* The collective layer, returned as the document it is.
|
|
4771
|
+
*
|
|
4772
|
+
* No parsing, on purpose. The file is written for a person -- facts with their evidence and the date
|
|
4773
|
+
* they were measured, decisions with the reasoning that produced them -- and a parser over prose
|
|
4774
|
+
* would silently drop whatever shape it did not expect. What it dropped would be exactly what a
|
|
4775
|
+
* human judged worth writing down, so the honest answer is the text plus where it came from.
|
|
4776
|
+
*/
|
|
4777
|
+
if (args?.scope === 'fleet') {
|
|
4778
|
+
const file = collectivePath();
|
|
4779
|
+
let text = null;
|
|
4780
|
+
let error = null;
|
|
4781
|
+
if (file === null) {
|
|
4782
|
+
error = 'MEMORY_NO_COLLECTIVE';
|
|
4783
|
+
} else {
|
|
4784
|
+
try {
|
|
4785
|
+
text = await fs.readFile(file, 'utf8');
|
|
4786
|
+
} catch (readError) {
|
|
4787
|
+
error = readError?.code === 'ENOENT' ? 'MEMORY_NO_COLLECTIVE' : `MEMORY_READ_FAILED: ${readError?.code}`;
|
|
4788
|
+
}
|
|
4789
|
+
}
|
|
4790
|
+
const headings = text === null
|
|
4791
|
+
? []
|
|
4792
|
+
: text.split('\n').filter((line) => /^#{1,3} /.test(line)).map((line) => line.replace(/^#+\s*/, '').trim());
|
|
4793
|
+
return JSON.stringify(
|
|
4794
|
+
{
|
|
4795
|
+
ok: true,
|
|
4796
|
+
scope: 'fleet',
|
|
4797
|
+
path: file,
|
|
4798
|
+
found: text !== null,
|
|
4799
|
+
bytes: text === null ? null : Buffer.byteLength(text, 'utf8'),
|
|
4800
|
+
// The section list is what makes a long document usable: a reader can see what the collective
|
|
4801
|
+
// memory covers without paying for the whole of it first.
|
|
4802
|
+
sections: headings,
|
|
4803
|
+
body: text,
|
|
4804
|
+
...(error === null ? {} : { reason_code: error }),
|
|
4805
|
+
notes: [
|
|
4806
|
+
error === 'MEMORY_NO_COLLECTIVE'
|
|
4807
|
+
? (file === null
|
|
4808
|
+
? 'no profile directory could be resolved, so the collective memory has no known location'
|
|
4809
|
+
: `no collective memory at ${file}; it is written by hand on the archive host, and its absence means nothing has been curated there yet`)
|
|
4810
|
+
: null,
|
|
4811
|
+
'this document is curated, not generated: every fact in it should carry the date it was measured and how',
|
|
4812
|
+
].filter((n) => n !== null),
|
|
4813
|
+
},
|
|
4814
|
+
null,
|
|
4815
|
+
2,
|
|
4816
|
+
);
|
|
4817
|
+
}
|
|
4818
|
+
|
|
4819
|
+
/**
|
|
4820
|
+
* Recording a conclusion.
|
|
4821
|
+
*
|
|
4822
|
+
* The one field a machine may not write for itself. `memoryRecord` always emits `conclusion: null`
|
|
4823
|
+
* because a machine that invented one would be asserting an interpretation it cannot have; this is
|
|
4824
|
+
* the path by which a reader -- a person, or the agent acting on their instruction -- supplies it.
|
|
4825
|
+
*
|
|
4826
|
+
* Appended as a new record rather than by rewriting the existing line. The log is append-only, and
|
|
4827
|
+
* in-place editing would break the property that makes it trustworthy: what was written is what
|
|
4828
|
+
* happened, and an amendment is a later statement about it. Rewriting would also lose the original
|
|
4829
|
+
* text, which is the thing a reader most needs when a conclusion turns out to be wrong.
|
|
4830
|
+
*/
|
|
4831
|
+
const conclusionText = typeof args?.record === 'string' && args.record.trim() !== '' ? args.record.trim() : null;
|
|
4832
|
+
if (conclusionText !== null) {
|
|
4833
|
+
const stateDirForRecord = cfg.stateDir ?? null;
|
|
4834
|
+
if (stateDirForRecord === null) {
|
|
4835
|
+
return JSON.stringify(
|
|
4836
|
+
{
|
|
4837
|
+
ok: false,
|
|
4838
|
+
scope: 'local',
|
|
4839
|
+
reason_code: 'MEMORY_NO_STATE_DIR',
|
|
4840
|
+
detail: 'this plugin has no stateDir configured, so a conclusion has nowhere to be written',
|
|
4841
|
+
},
|
|
4842
|
+
null,
|
|
4843
|
+
2,
|
|
4844
|
+
);
|
|
4845
|
+
}
|
|
4846
|
+
const recent = readMemorySync(stateDirForRecord, { limit: 1 });
|
|
4847
|
+
const subject = recent.records[0] ?? null;
|
|
4848
|
+
const written = appendMemory(stateDirForRecord, {
|
|
4849
|
+
at: new Date().toISOString(),
|
|
4850
|
+
kind: 'conclusion',
|
|
4851
|
+
task_id: subject?.task_id ?? null,
|
|
4852
|
+
machine_id: selfId,
|
|
4853
|
+
machine_name: self,
|
|
4854
|
+
// The conclusion is about the record named by `task_id`; the fields below repeat enough of it
|
|
4855
|
+
// that a reader does not have to join two lines to know what is being talked about.
|
|
4856
|
+
about: subject === null
|
|
4857
|
+
? null
|
|
4858
|
+
: { argv: subject.argv ?? null, exit_code: subject.exit_code ?? null, at: subject.at ?? null },
|
|
4859
|
+
conclusion: conclusionText,
|
|
4860
|
+
tags: [],
|
|
4861
|
+
});
|
|
4862
|
+
return JSON.stringify(
|
|
4863
|
+
{
|
|
4864
|
+
ok: written.ok === true,
|
|
4865
|
+
scope: 'local',
|
|
4866
|
+
recorded: written.ok === true,
|
|
4867
|
+
task_id: subject?.task_id ?? null,
|
|
4868
|
+
conclusion: conclusionText,
|
|
4869
|
+
path: written.ok === true ? written.path : null,
|
|
4870
|
+
...(written.ok === true ? {} : { reason_code: written.error }),
|
|
4871
|
+
notes: [
|
|
4872
|
+
subject === null
|
|
4873
|
+
? 'no task has been recorded here yet, so this conclusion names no task; it is still kept, with about: null'
|
|
4874
|
+
: `recorded against task ${subject.task_id}`,
|
|
4875
|
+
'appended, never rewritten: the original record is left as it was written',
|
|
4876
|
+
],
|
|
4877
|
+
},
|
|
4878
|
+
null,
|
|
4879
|
+
2,
|
|
4880
|
+
);
|
|
4881
|
+
}
|
|
4882
|
+
|
|
4729
4883
|
// A query about another machine is answered honestly rather than with an empty list, which would
|
|
4730
4884
|
// read as "that machine has done nothing" -- the opposite conclusion from "this build cannot see".
|
|
4731
4885
|
if (requested !== null && requested !== self && requested !== selfId) {
|