@twinsearth/w2m-dsh-plugin 0.32.0 → 0.34.0
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 +111 -0
- package/docs/1.0.0-ACCEPTANCE.md +26 -15
- package/package.json +5 -4
- package/src/agent/authority-choice.mjs +172 -0
- package/src/plugin/tools.mjs +78 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,117 @@ 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.34.0] — 2026-10-15
|
|
8
|
+
|
|
9
|
+
**`requireAuthorityLevel`: the policy that makes 分权限 real, and why it is a flag rather than a default.**
|
|
10
|
+
|
|
11
|
+
`docs/1.0.0-ACCEPTANCE.md` §3.11 recorded the gap plainly — the gate is **off unless configured**, so a
|
|
12
|
+
default install dispatches exactly as it did before the permission model existed, and **"off unless
|
|
13
|
+
configured" cannot be part of a 1.0.0 that claims 分权限.**
|
|
14
|
+
|
|
15
|
+
`requireAuthorityLevel: true` (or `W2M_REQUIRE_AUTHORITY_LEVEL`) closes it for a machine that asks. Measured,
|
|
16
|
+
the whole truth table:
|
|
17
|
+
|
|
18
|
+
| `requireAuthorityLevel` | level | result |
|
|
19
|
+
|---|---|---|
|
|
20
|
+
| off | unset | **starts**, gate unenforced |
|
|
21
|
+
| off | chosen | starts, gate enforced |
|
|
22
|
+
| **on** | **unset** | **refuses to start** — `AUTHORITY_CHOICE_NOT_CHOSEN` |
|
|
23
|
+
| **on** | **unusable** | **refuses to start** — `AUTHORITY_CHOICE_UNKNOWN` |
|
|
24
|
+
| on | chosen | starts, gate enforced |
|
|
25
|
+
|
|
26
|
+
A refusal names the four choices, so it is fixable from the message alone, and a machine that opted in says
|
|
27
|
+
so in `w2m_status` — a reader can tell "asked and answered" from "never asked".
|
|
28
|
+
|
|
29
|
+
### Why opt-in, which is a deliberate limit rather than a hedge
|
|
30
|
+
|
|
31
|
+
Refusing unconditionally would be the honest reading of *"a permission system that is off is untested in
|
|
32
|
+
production"* — and it would also **stop every machine running an earlier version the moment it upgraded**,
|
|
33
|
+
because none of them has a level. That trade is one silent failure for a loud outage, and **an outage is not
|
|
34
|
+
a security property.**
|
|
35
|
+
|
|
36
|
+
**So the gap is narrowed, not closed, and the document says so** rather than letting the flag's existence
|
|
37
|
+
imply otherwise. A fleet that never sets it is exactly as unenforced as before.
|
|
38
|
+
|
|
39
|
+
### `W2M_REQUIRE_AUTHORITY_LEVEL=0` means off
|
|
40
|
+
|
|
41
|
+
Asserted, because a truthiness test would read the string `"0"` as set-and-therefore-on and **refuse to start
|
|
42
|
+
a machine whose operator explicitly wrote zero.**
|
|
43
|
+
|
|
44
|
+
### My own errors, both of them syntax rather than logic
|
|
45
|
+
|
|
46
|
+
* I wrote `await` inside a **non-async arrow** passed to `assert.rejects`, which does not parse. `node --check`
|
|
47
|
+
caught it immediately; the fix is to compute the state directory first, because making the arrow async would
|
|
48
|
+
change what it does.
|
|
49
|
+
* I asserted refusal codes against the **message text**; the codes live on `error.reason_code`, which is the
|
|
50
|
+
field a consumer branches on. **The code was correct and the assertion was looking in the wrong place** —
|
|
51
|
+
now it pins the field.
|
|
52
|
+
|
|
53
|
+
**6 new tests, 0 failures.**
|
|
54
|
+
|
|
55
|
+
## [0.33.0] — 2026-10-15
|
|
56
|
+
|
|
57
|
+
**The authority level is chosen by name. Nobody types a number.**
|
|
58
|
+
|
|
59
|
+
`actorAuthorityLevel` is an integer in `0..100` whose four bands have names and gaps. Asking a person to type
|
|
60
|
+
it asks them to hold all of that in their head, and **getting it wrong fails silently**: `69` and `70` look
|
|
61
|
+
equally arbitrary and land in different bands, so a machine configured one point low **refuses a dispatch it
|
|
62
|
+
should have run and never says a threshold was the reason.**
|
|
63
|
+
|
|
64
|
+
`src/agent/authority-choice.mjs` turns the number into a choice:
|
|
65
|
+
|
|
66
|
+
| Choice | Level | What picking it permits |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `ordinary` | **0** | create goals and session data within its own scope |
|
|
69
|
+
| `captain` | **50** | create team goals, manage team membership |
|
|
70
|
+
| `org_admin` | **70** | create organisation goals, register actors, set overrides |
|
|
71
|
+
| `system_admin` | **90** | create global goals, configure across organisations, override any lower constraint |
|
|
72
|
+
|
|
73
|
+
The options are **built from `AUTHORITY_BANDS`**, so a band added to the model appears here without anybody
|
|
74
|
+
remembering — and a band cannot be described in two places differently.
|
|
75
|
+
|
|
76
|
+
### A name resolves to its band FLOOR, and the consequence is stated
|
|
77
|
+
|
|
78
|
+
The floor is where the band's own description becomes true, and it is what `bandOf` answers with. Resolving to
|
|
79
|
+
a midpoint would mean **a choice labelled "captain" silently granting more than captain.**
|
|
80
|
+
|
|
81
|
+
The practical consequence is worth writing down because it decides what a machine may do: **`ordinary`
|
|
82
|
+
resolves to `0` and `dispatch_writing` costs `50`, so a machine whose operator picks "ordinary" cannot
|
|
83
|
+
dispatch a write at all** — it escalates and an approver must answer. Measured, all four:
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
ordinary level 0 -> write dispatch: needs_approval
|
|
87
|
+
captain level 50 -> write dispatch: allow
|
|
88
|
+
org_admin level 70 -> write dispatch: allow
|
|
89
|
+
system_admin level 90 -> write dispatch: allow
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Two vocabularies, and two different callers
|
|
93
|
+
|
|
94
|
+
**A number is refused by the selector**, because a selector that also accepts integers is a text field with a
|
|
95
|
+
helpful hint and every reason above applies again. **A number is accepted by the config**, because a profile
|
|
96
|
+
patch written by an earlier version must keep loading — and breaking every existing machine on upgrade is a
|
|
97
|
+
worse failure than accepting a number from a file nobody is looking at. The two rules are stated in one place
|
|
98
|
+
and tested against each other, including that the selector refuses `'captain '` while the config trims it.
|
|
99
|
+
|
|
100
|
+
### A misconfigured level no longer degrades in silence
|
|
101
|
+
|
|
102
|
+
`w2m_status` now reports `actor_authority` — whether it resolved, from which vocabulary, and **why not when it
|
|
103
|
+
did not, with the four choices attached**. A level that failed to parse used to become `null`, which reads
|
|
104
|
+
exactly like a machine that never chose one, and **those two need opposite responses.**
|
|
105
|
+
|
|
106
|
+
### Three of my own errors
|
|
107
|
+
|
|
108
|
+
* I asserted `UNKNOWN` for the integer `70`; it is `NOT_A_STRING`, because the codes distinguish "a number was
|
|
109
|
+
given" from "a string that did not match" — **on purpose, so a caller can message them differently.**
|
|
110
|
+
* I reused the selector's message in the config resolver, so a config author was told about names only while
|
|
111
|
+
**that function's own contract promised numbers too.** The implementation was changed, not the test.
|
|
112
|
+
* My captain test asserted success and instead reached the network, because it installed no fetch stub. **That
|
|
113
|
+
failure was the proof**: it did *not* throw `W2M_PERMISSION_*`. The assertion now checks the kind of failure
|
|
114
|
+
rather than the absence of one, which is what the test was always about.
|
|
115
|
+
|
|
116
|
+
**18 new tests, 0 failures.**
|
|
117
|
+
|
|
7
118
|
## [0.32.0] — 2026-10-15
|
|
8
119
|
|
|
9
120
|
**Widening real-machine verification: eight suites tried, none passed, and the cause is a hypothesis.**
|
package/docs/1.0.0-ACCEPTANCE.md
CHANGED
|
@@ -57,24 +57,35 @@ limiter down to `util`, **not** to add the edge.
|
|
|
57
57
|
| 3.8 | An escalated dispatch reaches a queue an approver can answer | `w2m_approvals`, asserted end to end | **yes** |
|
|
58
58
|
| 3.9 | The queue's front door meters **raising only** | `approval-gate.mjs`, wired in 0.31.0 | **yes** |
|
|
59
59
|
| 3.10 | Field-level memory acceptance is driven by the same model | `field-authority.mjs` | **mechanism yes, caller NO — see §4** |
|
|
60
|
-
| 3.11 | **The gate is ON by default** |
|
|
60
|
+
| 3.11 | **The gate is ON by default** | `requireAuthorityLevel`, asserted | **NARROWED — see below** |
|
|
61
|
+
| 3.12 | A level is chosen by name, never typed | `authority-choice.mjs`, asserted | **yes** |
|
|
62
|
+
| 3.13 | A misconfigured level is reported, not silently ignored | `w2m_status.actor_authority` | **yes** |
|
|
61
63
|
|
|
62
|
-
### `3.11`
|
|
64
|
+
### `3.11` — narrowed rather than closed, and the distinction is stated
|
|
63
65
|
|
|
64
66
|
`actorAuthorityLevel` is **unset by default**, and an unset level means the gate reports `enforced: false` and
|
|
65
|
-
changes nothing. So
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
67
|
+
changes nothing. So a default install dispatches exactly as it did before the permission model existed.
|
|
68
|
+
|
|
69
|
+
**0.34.0 adds `requireAuthorityLevel`** (or `W2M_REQUIRE_AUTHORITY_LEVEL`), which closes it for a machine that
|
|
70
|
+
asks for it to be closed. Measured, the whole table:
|
|
71
|
+
|
|
72
|
+
| `requireAuthorityLevel` | level | result |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| off | unset | **starts**, gate unenforced |
|
|
75
|
+
| off | chosen | starts, gate enforced |
|
|
76
|
+
| **on** | **unset** | **refuses to start** — `AUTHORITY_CHOICE_NOT_CHOSEN` |
|
|
77
|
+
| **on** | **unusable** | **refuses to start** — `AUTHORITY_CHOICE_UNKNOWN` |
|
|
78
|
+
| on | chosen | starts, gate enforced |
|
|
79
|
+
|
|
80
|
+
**Why it is opt-in, which is a deliberate limit rather than a hedge.** Refusing unconditionally would be the
|
|
81
|
+
honest reading of *"a permission system that is off is untested in production"* — and it would also **stop
|
|
82
|
+
every machine running an earlier version the moment it upgraded**, because none of them has a level. That
|
|
83
|
+
trades one silent failure for a loud outage, and **an outage is not a security property.**
|
|
84
|
+
|
|
85
|
+
**So the gap is narrowed, not closed.** A fleet that does not set this flag is exactly as unenforced as
|
|
86
|
+
before, and this document says so rather than letting the flag's existence imply otherwise. **The remaining
|
|
87
|
+
question for 1.0.0 is whether shipping a flag is enough, or whether the default must flip** — and that is a
|
|
88
|
+
decision about who this release is for, not a defect to fix.
|
|
78
89
|
|
|
79
90
|
---
|
|
80
91
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
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",
|
|
@@ -113,13 +113,13 @@
|
|
|
113
113
|
}
|
|
114
114
|
},
|
|
115
115
|
"scripts": {
|
|
116
|
-
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/layer-model.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",
|
|
116
|
+
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/layer-model.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",
|
|
117
117
|
"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",
|
|
118
118
|
"test:relay": "node --test --test-force-exit test/relay.test.mjs",
|
|
119
119
|
"test:agent": "node --test --test-force-exit test/agent.test.mjs test/memory.test.mjs",
|
|
120
120
|
"test:tools": "node --test --test-force-exit test/tools.test.mjs",
|
|
121
121
|
"test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
|
|
122
|
-
"test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/layer-model.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",
|
|
122
|
+
"test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/layer-model.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",
|
|
123
123
|
"pack": "node scripts/pack.mjs",
|
|
124
124
|
"verify:join": "node scripts/verify-join.mjs",
|
|
125
125
|
"lint": "eslint .",
|
|
@@ -148,7 +148,8 @@
|
|
|
148
148
|
"test:permission": "node --test --test-force-exit test/permission.test.mjs",
|
|
149
149
|
"test:packet": "node --test --test-force-exit test/machine-test-packet.test.mjs",
|
|
150
150
|
"test:field-authority": "node --test --test-force-exit test/field-authority.test.mjs",
|
|
151
|
-
"test:approval-gate": "node --test --test-force-exit test/approval-gate.test.mjs"
|
|
151
|
+
"test:approval-gate": "node --test --test-force-exit test/approval-gate.test.mjs",
|
|
152
|
+
"test:authority-choice": "node --test --test-force-exit test/authority-choice.test.mjs"
|
|
152
153
|
},
|
|
153
154
|
"keywords": [
|
|
154
155
|
"dsh-bundle",
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Choosing an authority level by name, so nobody has to remember what 70 means.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* `actorAuthorityLevel` is an integer in `0..100`, and the four bands it falls into have names, prose about
|
|
7
|
+
* what each may do, and *gaps*. Asking a person to type a number means asking them to hold all of that in
|
|
8
|
+
* their head, and the failure mode of getting it wrong is silent: `69` and `70` look equally arbitrary and
|
|
9
|
+
* land in different bands, and a machine configured one point low **refuses a dispatch it should have run**
|
|
10
|
+
* with no indication that a threshold was the reason.
|
|
11
|
+
*
|
|
12
|
+
* This module turns the number into a choice. A caller presents {@link AUTHORITY_CHOICES} — four names with
|
|
13
|
+
* the sentence each one means — and gets back the level. **The number is never asked for.**
|
|
14
|
+
*
|
|
15
|
+
* ## Why a name resolves to its band FLOOR
|
|
16
|
+
*
|
|
17
|
+
* Each band spans a range and this picks the bottom of it, deliberately. The floor is the level at which the
|
|
18
|
+
* band's own description becomes true, and it is what `bandOf` answers with: a machine at the floor is *in*
|
|
19
|
+
* that band and no higher. Resolving to the midpoint or the ceiling would mean a choice labelled "captain"
|
|
20
|
+
* silently granted more than captain.
|
|
21
|
+
*
|
|
22
|
+
* **The consequence is worth stating plainly, because it decides what a machine can do:** `ordinary` resolves
|
|
23
|
+
* to `0`, and `dispatch_writing` costs `50`. So a machine whose operator picks "ordinary" **cannot dispatch a
|
|
24
|
+
* write at all** — it escalates, and an approver must answer. That is the intended reading of the label. It
|
|
25
|
+
* is also why this is a policy decision rather than a convenience: **the name a person picks decides what the
|
|
26
|
+
* machine is allowed to do.**
|
|
27
|
+
*
|
|
28
|
+
* ## Why a number is refused rather than accepted alongside the names
|
|
29
|
+
*
|
|
30
|
+
* A selector that also accepted integers would be a text field with a helpful hint, and every reason above
|
|
31
|
+
* would apply again. Refusing them keeps one way in, and the refusal names the four that exist.
|
|
32
|
+
*/
|
|
33
|
+
import { AUTHORITY_BANDS, AUTHORITY_BAND_FLOORS, AUTHORITY_MAX, isAuthorityLevel } from './authority.mjs';
|
|
34
|
+
|
|
35
|
+
/** Why a choice was refused. */
|
|
36
|
+
export const CHOICE_REFUSAL = Object.freeze({
|
|
37
|
+
UNKNOWN: 'AUTHORITY_CHOICE_UNKNOWN',
|
|
38
|
+
NOT_A_STRING: 'AUTHORITY_CHOICE_NOT_A_STRING',
|
|
39
|
+
NOT_CHOSEN: 'AUTHORITY_CHOICE_NOT_CHOSEN',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The options to present, in ascending order, each with the sentence it stands for.
|
|
44
|
+
*
|
|
45
|
+
* Built from `AUTHORITY_BANDS` rather than written out, so a band added to the model appears here without
|
|
46
|
+
* anybody remembering to add it — and, more importantly, **a band cannot be described in two places
|
|
47
|
+
* differently**.
|
|
48
|
+
*/
|
|
49
|
+
export const AUTHORITY_CHOICES = Object.freeze(
|
|
50
|
+
AUTHORITY_BANDS.map((band) => ({
|
|
51
|
+
/** The value a caller stores. Never a number. */
|
|
52
|
+
value: band.name,
|
|
53
|
+
/** What to show a person. */
|
|
54
|
+
label: band.name.replace(/_/g, ' '),
|
|
55
|
+
/** The level this resolves to, and the reason it is that one. */
|
|
56
|
+
level: AUTHORITY_BAND_FLOORS[band.name.toUpperCase()],
|
|
57
|
+
/** What picking this permits, in the model's own words. */
|
|
58
|
+
may: band.may,
|
|
59
|
+
/** The range, so a caller can show it without reading the model. */
|
|
60
|
+
range: band.max === null ? `${band.min}+` : `${band.min}–${band.max}`,
|
|
61
|
+
})),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve a named choice to a level.
|
|
66
|
+
*
|
|
67
|
+
* @param {string|null|undefined} choice
|
|
68
|
+
* @returns {{ok: boolean, level: number|null, choice: string|null, code: string|null, detail: string,
|
|
69
|
+
* options: string[]}}
|
|
70
|
+
*/
|
|
71
|
+
export function resolveAuthorityChoice(choice) {
|
|
72
|
+
const options = AUTHORITY_CHOICES.map((c) => c.value);
|
|
73
|
+
|
|
74
|
+
if (choice === null || choice === undefined || choice === '') {
|
|
75
|
+
// Not the same as a wrong answer. "Nobody chose" is a state a caller has to be able to tell from "somebody
|
|
76
|
+
// chose badly", because only one of them is a mistake.
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
level: null,
|
|
80
|
+
choice: null,
|
|
81
|
+
code: CHOICE_REFUSAL.NOT_CHOSEN,
|
|
82
|
+
detail: `no authority level was chosen; pick one of ${options.join(', ')}`,
|
|
83
|
+
options,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (typeof choice !== 'string') {
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
level: null,
|
|
91
|
+
choice: null,
|
|
92
|
+
code: CHOICE_REFUSAL.NOT_A_STRING,
|
|
93
|
+
detail: `${JSON.stringify(choice)} is not a choice; pick one of ${options.join(', ')} by name`,
|
|
94
|
+
options,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const found = AUTHORITY_CHOICES.find((c) => c.value === choice);
|
|
99
|
+
if (found === undefined) {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
level: null,
|
|
103
|
+
choice: null,
|
|
104
|
+
code: CHOICE_REFUSAL.UNKNOWN,
|
|
105
|
+
detail: `${JSON.stringify(choice)} is not one of ${options.join(', ')}; if it looks like a number, note that levels are chosen by name and not typed`,
|
|
106
|
+
options,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
ok: true,
|
|
112
|
+
level: found.level,
|
|
113
|
+
choice: found.value,
|
|
114
|
+
code: null,
|
|
115
|
+
detail: `${found.value} resolves to level ${found.level} (${found.range}): ${found.may}`,
|
|
116
|
+
options,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolve a plugin configuration value, which may be a name or already a level.
|
|
122
|
+
*
|
|
123
|
+
* ## Why this one DOES accept a number, when the selector does not
|
|
124
|
+
*
|
|
125
|
+
* These are different callers with different constraints. A person choosing is asked to choose, and the whole
|
|
126
|
+
* point is that they are not asked for a number. A **profile patch** is written by hand or by an earlier
|
|
127
|
+
* version of this plugin, and a config that used to hold `70` must not start refusing to load because the
|
|
128
|
+
* interface changed — that would break every existing machine on upgrade, which is the failure this module's
|
|
129
|
+
* own docstring warns about.
|
|
130
|
+
*
|
|
131
|
+
* So: a `string` is resolved as a choice, an integer is accepted as-is **only if it is a usable level**, and
|
|
132
|
+
* anything else is refused with a message naming both vocabularies.
|
|
133
|
+
*
|
|
134
|
+
* @param {unknown} value
|
|
135
|
+
* @returns {{ok: boolean, level: number|null, source: string|null, code: string|null, detail: string}}
|
|
136
|
+
*/
|
|
137
|
+
export function resolveConfiguredLevel(value) {
|
|
138
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
139
|
+
const r = resolveAuthorityChoice(value.trim());
|
|
140
|
+
if (r.ok) return { ok: true, level: r.level, source: 'name', code: null, detail: r.detail };
|
|
141
|
+
// A config may hold EITHER vocabulary, so a string that is not a band name is not simply unknown -- the
|
|
142
|
+
// caller is told both what the names are and that an integer would also have worked. Reusing the
|
|
143
|
+
// selector's message here was the first version's mistake: it told a config author about names only,
|
|
144
|
+
// while the function's own contract promised numbers too.
|
|
145
|
+
return {
|
|
146
|
+
ok: false,
|
|
147
|
+
level: null,
|
|
148
|
+
source: null,
|
|
149
|
+
code: r.code === CHOICE_REFUSAL.NOT_CHOSEN ? CHOICE_REFUSAL.NOT_CHOSEN : CHOICE_REFUSAL.UNKNOWN,
|
|
150
|
+
detail: `${JSON.stringify(value)} is neither a choice nor a level; choose one of ${AUTHORITY_CHOICES.map((c) => c.value).join(', ')}, or a direct level as an integer 0..${AUTHORITY_MAX}`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (isAuthorityLevel(value)) {
|
|
154
|
+
return {
|
|
155
|
+
ok: true,
|
|
156
|
+
level: value,
|
|
157
|
+
source: 'number',
|
|
158
|
+
code: null,
|
|
159
|
+
detail: `level ${value} was configured directly; it is in the "${(AUTHORITY_BANDS.find((b) => value >= b.min && (b.max === null || value <= b.max)) ?? {}).name ?? 'unknown'}" band`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
if (value === null || value === undefined || value === '') {
|
|
163
|
+
return { ok: false, level: null, source: null, code: CHOICE_REFUSAL.NOT_CHOSEN, detail: 'no authority level is configured, so the permission gate is not enforced' };
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
ok: false,
|
|
167
|
+
level: null,
|
|
168
|
+
source: null,
|
|
169
|
+
code: CHOICE_REFUSAL.UNKNOWN,
|
|
170
|
+
detail: `${JSON.stringify(value)} is neither a choice nor a level; choose one of ${AUTHORITY_CHOICES.map((c) => c.value).join(', ')}, or an integer 0..${AUTHORITY_MAX}`,
|
|
171
|
+
};
|
|
172
|
+
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -132,6 +132,9 @@ import { createApprovalIndex } from '../agent/approval-index.mjs';
|
|
|
132
132
|
// v0.31.0: the approval queue's front door. Written in 0.30.0 and given a caller here.
|
|
133
133
|
import { createApprovalGate } from '../agent/approval-gate.mjs';
|
|
134
134
|
import { AUTHORITY_MAX, isAuthorityLevel } from '../agent/authority.mjs';
|
|
135
|
+
// v0.33.0: the level is chosen by NAME rather than typed. `resolveConfiguredLevel` also accepts a plain
|
|
136
|
+
// integer, because a profile patch written by an earlier version must keep loading.
|
|
137
|
+
import { AUTHORITY_CHOICES, resolveConfiguredLevel } from '../agent/authority-choice.mjs';
|
|
135
138
|
// v0.6.0: the device keypair and the offer's key distribution (`docs/P2P-CRYPTO.md` §4). Imported
|
|
136
139
|
// rather than reimplemented: the dispatcher and the executor have to derive the *same* keys, and two
|
|
137
140
|
// implementations of a handshake are two chances to disagree about it.
|
|
@@ -195,7 +198,7 @@ const MAX_STAGES = 16;
|
|
|
195
198
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
196
199
|
* overrides it at runtime for tests and packagers.
|
|
197
200
|
*/
|
|
198
|
-
const PLUGIN_VERSION = '0.
|
|
201
|
+
const PLUGIN_VERSION = '0.34.0';
|
|
199
202
|
|
|
200
203
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
201
204
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -700,11 +703,69 @@ function readConfig(config = {}) {
|
|
|
700
703
|
* null is not 0. An unset level means there is no actor to judge, and the gate reports that as
|
|
701
704
|
* unenforced rather than refusing every writing dispatch on a machine that never opted in.
|
|
702
705
|
*/
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
706
|
+
/**
|
|
707
|
+
* v0.33.0: the level, chosen by NAME or configured as a number.
|
|
708
|
+
*
|
|
709
|
+
* The plugin config takes either, through {@link resolveConfiguredLevel} — a name because nobody should
|
|
710
|
+
* have to remember what 70 means, and a number because a profile patch written by an earlier version
|
|
711
|
+
* must keep loading. `W2M_ACTOR_AUTHORITY` takes a name too, so a machine can be pointed at a band
|
|
712
|
+
* without editing a profile.
|
|
713
|
+
*
|
|
714
|
+
* `actorAuthorityChoice` carries the resolution — including WHY it failed, when it did — so `w2m_status`
|
|
715
|
+
* can report a level it refused instead of silently dispatching unenforced. A misconfigured level that
|
|
716
|
+
* degrades to `null` in silence is the failure this pair exists to prevent.
|
|
717
|
+
*/
|
|
718
|
+
...(() => {
|
|
719
|
+
const fromEnv = typeof process.env.W2M_ACTOR_AUTHORITY === 'string' && process.env.W2M_ACTOR_AUTHORITY.trim() !== ''
|
|
720
|
+
? process.env.W2M_ACTOR_AUTHORITY.trim()
|
|
721
|
+
: undefined;
|
|
722
|
+
const resolved = resolveConfiguredLevel(fromEnv !== undefined ? fromEnv : config.actorAuthorityLevel);
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* v0.34.0: the policy that makes 分权限 real, and why it is a policy rather than a default.
|
|
726
|
+
*
|
|
727
|
+
* `docs/1.0.0-ACCEPTANCE.md` §3.11 records the gap plainly: the gate is **off unless configured**, so a
|
|
728
|
+
* default install dispatches exactly as it did before the permission model existed, and **"off unless
|
|
729
|
+
* configured" cannot be part of a 1.0.0 that claims 分权限.**
|
|
730
|
+
*
|
|
731
|
+
* `requireAuthorityLevel: true` closes it: with the policy on, a machine that has not chosen a level
|
|
732
|
+
* **refuses to start** rather than dispatching unenforced. `W2M_REQUIRE_AUTHORITY_LEVEL` supplies the
|
|
733
|
+
* same policy from the environment, so a fleet can require it without editing every profile.
|
|
734
|
+
*
|
|
735
|
+
* ## Why it is opt-in, which is a deliberate limit
|
|
736
|
+
*
|
|
737
|
+
* Refusing unconditionally would be the honest reading of "a permission system that is off is
|
|
738
|
+
* untested" — and it would also **stop every machine running an earlier version the moment it
|
|
739
|
+
* upgraded**, since none of them has a level. That trades one silent failure for a loud outage, and an
|
|
740
|
+
* outage is not a security property.
|
|
741
|
+
*
|
|
742
|
+
* So the policy is per-machine and explicit. **The gap is narrowed, not closed**: a fleet that does not
|
|
743
|
+
* turn this on is exactly as unenforced as before, and this comment says so rather than letting the
|
|
744
|
+
* existence of the flag imply otherwise.
|
|
745
|
+
*/
|
|
746
|
+
const required = config.requireAuthorityLevel === true
|
|
747
|
+
|| (typeof process.env.W2M_REQUIRE_AUTHORITY_LEVEL === 'string' && process.env.W2M_REQUIRE_AUTHORITY_LEVEL.trim() !== '' && process.env.W2M_REQUIRE_AUTHORITY_LEVEL.trim() !== '0');
|
|
748
|
+
if (required && !resolved.ok) {
|
|
749
|
+
throw configError(
|
|
750
|
+
'actorAuthorityLevel',
|
|
751
|
+
`is required on this machine (requireAuthorityLevel is set) and ${resolved.ok ? '' : resolved.detail}`,
|
|
752
|
+
`choose one of ${AUTHORITY_CHOICES.map((c) => c.value).join(', ')} as \`actorAuthorityLevel\`, or as \`W2M_ACTOR_AUTHORITY\`; to dispatch without a level, unset requireAuthorityLevel`,
|
|
753
|
+
resolved.code ?? 'W2M_AUTHORITY_LEVEL_REQUIRED',
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
return {
|
|
758
|
+
actorAuthorityLevel: resolved.ok ? resolved.level : null,
|
|
759
|
+
actorAuthorityChoice: {
|
|
760
|
+
ok: resolved.ok,
|
|
761
|
+
level: resolved.level,
|
|
762
|
+
source: resolved.source,
|
|
763
|
+
reason_code: resolved.code,
|
|
764
|
+
detail: resolved.detail,
|
|
765
|
+
required,
|
|
766
|
+
},
|
|
767
|
+
};
|
|
768
|
+
})(),
|
|
708
769
|
defaultTimeoutMs: shared.values.defaultTimeoutMs,
|
|
709
770
|
sharedConfig: shared,
|
|
710
771
|
pairingCode: typeof config.pairingCode === 'string' && config.pairingCode.trim() !== ''
|
|
@@ -4519,6 +4580,17 @@ export async function apply(ctx, config = {}) {
|
|
|
4519
4580
|
: targetRoutes.reason,
|
|
4520
4581
|
sessions_with_a_target: defaultSelectionStore.list().length,
|
|
4521
4582
|
},
|
|
4583
|
+
/**
|
|
4584
|
+
* v0.33.0: how the authority level was resolved, and why it failed when it did.
|
|
4585
|
+
*
|
|
4586
|
+
* A misconfigured level used to degrade to `null` in silence, which reads exactly like a machine
|
|
4587
|
+
* that never chose one -- and those need opposite responses. This reports the difference, and
|
|
4588
|
+
* carries the four choices so a reader can fix it without being told what they are elsewhere.
|
|
4589
|
+
*/
|
|
4590
|
+
actor_authority: {
|
|
4591
|
+
...cfg.actorAuthorityChoice,
|
|
4592
|
+
...(cfg.actorAuthorityChoice.ok ? {} : { choices: AUTHORITY_CHOICES.map((c) => ({ value: c.value, may: c.may, range: c.range })) }),
|
|
4593
|
+
},
|
|
4522
4594
|
approvals: approvalsBlock,
|
|
4523
4595
|
notes: statusNotes(relay, rtt, cfg, p2pStatus, approvalsBlock),
|
|
4524
4596
|
},
|