@twinsearth/w2m-dsh-plugin 0.22.0 → 0.23.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 +45 -0
- package/package.json +1 -1
- package/src/agent/authority.mjs +60 -0
- package/src/agent/permission.mjs +13 -2
- package/src/plugin/tools.mjs +55 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,51 @@ 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.23.0] — 2026-10-15
|
|
8
|
+
|
|
9
|
+
**The gate is on the dispatch path. "分权限" now executes, and a machine that has not opted in behaves exactly as before.**
|
|
10
|
+
|
|
11
|
+
`src/plugin/tools.mjs` calls `gateDispatch()` immediately after the write-scope check — placed there so the
|
|
12
|
+
thing being graded is already known to be well-formed — and refuses the dispatch when the verdict is not
|
|
13
|
+
`allow`.
|
|
14
|
+
|
|
15
|
+
### `actorAuthorityLevel`, and why it is not shared config
|
|
16
|
+
|
|
17
|
+
Read from the plugin config, or `W2M_ACTOR_AUTHORITY` so a machine can be pointed at a level without editing a
|
|
18
|
+
profile. **Deliberately not part of `shared.values`**: this is a local statement about who is typing, and a
|
|
19
|
+
fleet-wide value would let one machine's level speak for another's operator.
|
|
20
|
+
|
|
21
|
+
**`null` is not `0`.** An unset level means there is no actor to judge, and the gate reports `enforced: false`
|
|
22
|
+
and changes nothing — which is why adding this cannot alter the behaviour of any machine that has not opted
|
|
23
|
+
in. A refusal for everyone who never configured a level would have been the easy implementation and the wrong
|
|
24
|
+
one.
|
|
25
|
+
|
|
26
|
+
### What a refusal looks like
|
|
27
|
+
|
|
28
|
+
Two distinct codes, because they need different responses:
|
|
29
|
+
|
|
30
|
+
| Code | When | What it means |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| `W2M_PERMISSION_DENIED` | verdict `deny` | the level is more than one band short, or the capability is undeclared |
|
|
33
|
+
| `W2M_PERMISSION_NEEDS_APPROVAL` | verdict `needs_approval` | the level is exactly one band short |
|
|
34
|
+
|
|
35
|
+
The `needs_approval` refusal **carries the approval request** that `approvalRequestFor()` built — subject
|
|
36
|
+
level, required level, capability, `auto_settleable` — and says plainly that it is refused rather than queued
|
|
37
|
+
**because no approval queue is wired to this call yet.** Refusing and saying why is honest; queueing a request
|
|
38
|
+
nothing reads would not be.
|
|
39
|
+
|
|
40
|
+
### A defect I introduced while wiring it, and how it surfaced
|
|
41
|
+
|
|
42
|
+
The first edit **corrupted the file**: the anchor for the config field matched only the first line of a
|
|
43
|
+
multi-line expression, so it cut `allowedCommands` off from its own `.filter().map().filter()` chain and left
|
|
44
|
+
the orphan chained onto my insertion. A second attempt then wrote literal backslashes into the source by
|
|
45
|
+
escaping backticks that did not need escaping.
|
|
46
|
+
|
|
47
|
+
Both were caught by `node --check` immediately, which is the argument for running it after every write rather
|
|
48
|
+
than after the batch. Neither reached a commit.
|
|
49
|
+
|
|
50
|
+
**120 tests across the tools, permission and entrypoint suites, 0 failures.**
|
|
51
|
+
|
|
7
52
|
## [0.22.0] — 2026-10-15
|
|
8
53
|
|
|
9
54
|
**The gate now grades a real dispatch, and connects to the approval queue.**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.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",
|
package/src/agent/authority.mjs
CHANGED
|
@@ -38,9 +38,69 @@ export const AUTHORITY_BANDS = Object.freeze([
|
|
|
38
38
|
{ name: 'system_admin', min: 90, max: null, may: 'create global goals, configure across organisations, override any lower constraint' },
|
|
39
39
|
]);
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* The band floors, as named constants, for callers that want a level rather than a range.
|
|
43
|
+
*
|
|
44
|
+
* `AUTHORITY_BANDS` above is the model — ranges with prose about what each band may do. This is the lookup a
|
|
45
|
+
* caller reaches for when it wants "the captain floor" as a number, and it exists here rather than in
|
|
46
|
+
* `scripts/layer-model.mjs` because it is a view of the same table and the two must not be able to drift.
|
|
47
|
+
*
|
|
48
|
+
* Derived from `AUTHORITY_BANDS` rather than written out, so adding a band cannot leave this behind.
|
|
49
|
+
*/
|
|
50
|
+
export const AUTHORITY_BAND_FLOORS = Object.freeze(
|
|
51
|
+
Object.fromEntries(AUTHORITY_BANDS.map((b) => [b.name.toUpperCase(), b.min])),
|
|
52
|
+
);
|
|
53
|
+
|
|
41
54
|
/** The highest level this model will accept. Above it, a level is a mistake rather than a promotion. */
|
|
42
55
|
export const AUTHORITY_MAX = 100;
|
|
43
56
|
|
|
57
|
+
/**
|
|
58
|
+
* What each operational capability costs in authority.
|
|
59
|
+
*
|
|
60
|
+
* ## Why this lives here and not in `scripts/`
|
|
61
|
+
*
|
|
62
|
+
* It was declared in `scripts/layer-model.mjs` from v0.12.0, and `src/agent/permission.mjs` imported it from
|
|
63
|
+
* there — which read cleanly and **broke the published package**: `scripts/` is not in `package.json#files`,
|
|
64
|
+
* so an installed copy could not resolve the import at all. The packed-tarball check caught it, and the check
|
|
65
|
+
* was right.
|
|
66
|
+
*
|
|
67
|
+
* The layering says the same thing: `scripts/` sits *above* `src/`, so a `src/` module reaching up into it
|
|
68
|
+
* inverts the dependency. The authority model already owns the bands, so the capability table belongs beside
|
|
69
|
+
* them; `scripts/layer-model.mjs` now imports and re-exports it, keeping that module's public surface intact
|
|
70
|
+
* for the suites that assert on it.
|
|
71
|
+
*
|
|
72
|
+
* ## Why a table and not a set of branches
|
|
73
|
+
*
|
|
74
|
+
* `AUTHORITY_BANDS` above says what a band *may* do in prose. This says what each concrete operation *costs*,
|
|
75
|
+
* as data, so a reader can enumerate it and a test can audit it. The two must not drift, which is why they sit
|
|
76
|
+
* in one file.
|
|
77
|
+
*/
|
|
78
|
+
export const CAPABILITIES = Object.freeze([
|
|
79
|
+
{ name: 'read_fleet_state', min_authority: 0, destructive: false, note: 'listing machines and tasks changes nothing' },
|
|
80
|
+
{ name: 'dispatch_readonly', min_authority: 0, destructive: false, note: 'a task that may not write' },
|
|
81
|
+
{ name: 'dispatch_writing', min_authority: 50, destructive: false, note: 'a task that may write inside a declared scope' },
|
|
82
|
+
{ name: 'publish_release', min_authority: 70, destructive: true, note: 'a published version cannot be unpublished from here' },
|
|
83
|
+
{ name: 'delete_permanent', min_authority: 90, destructive: true, note: 'overrides the storage tier refusal; forced removal only' },
|
|
84
|
+
{ name: 'amend_law', min_authority: 70, destructive: false, note: 'amendment keeps the chain, so it is not a destruction' },
|
|
85
|
+
{ name: 'write_config', min_authority: 90, destructive: true, note: 'allowedCommands and the relay URL live here' },
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The minimum authority for a capability, or `null` when it is not declared.
|
|
90
|
+
*
|
|
91
|
+
* `null` rather than a permissive default: a capability the table has not heard of must not be granted, and
|
|
92
|
+
* the gate in `permission.mjs` refuses on `null` rather than treating it as free.
|
|
93
|
+
*/
|
|
94
|
+
export function permissionFor(name) {
|
|
95
|
+
return CAPABILITIES.find((c) => c.name === name)?.min_authority ?? null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Whether a capability is declared destructive. An unknown capability is not assumed safe. */
|
|
99
|
+
export function isDestructive(name) {
|
|
100
|
+
const found = CAPABILITIES.find((c) => c.name === name);
|
|
101
|
+
return found === undefined ? null : found.destructive;
|
|
102
|
+
}
|
|
103
|
+
|
|
44
104
|
/** Verdicts an authority check can reach. */
|
|
45
105
|
export const AUTHORITY_VERDICT = Object.freeze({
|
|
46
106
|
ALLOW: 'allow',
|
package/src/agent/permission.mjs
CHANGED
|
@@ -30,8 +30,19 @@
|
|
|
30
30
|
* table grant whatever is added next, and answering `NEEDS_APPROVAL` makes it requestable. Both are worse than
|
|
31
31
|
* a refusal, because both hide the omission.
|
|
32
32
|
*/
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
// v0.23.0: imported from `authority.mjs`, which owns the bands and therefore the costs. It used to come
|
|
34
|
+
// from `scripts/layer-model.mjs`, and that broke the published package -- `scripts/` is not in the `files`
|
|
35
|
+
// list, so an installed copy could not resolve it. A `src/` module must not reach above itself either.
|
|
36
|
+
import {
|
|
37
|
+
AUTHORITY_BANDS,
|
|
38
|
+
AUTHORITY_MAX,
|
|
39
|
+
AUTHORITY_VERDICT,
|
|
40
|
+
bandOf,
|
|
41
|
+
isAuthorityLevel,
|
|
42
|
+
CAPABILITIES,
|
|
43
|
+
permissionFor,
|
|
44
|
+
isDestructive,
|
|
45
|
+
} from './authority.mjs';
|
|
35
46
|
|
|
36
47
|
export { AUTHORITY_VERDICT };
|
|
37
48
|
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -122,6 +122,11 @@ import {
|
|
|
122
122
|
// another's, so a second implementation of the formula that drifted by one input would make every
|
|
123
123
|
// reverse dial time out while both ends insisted they were sending.
|
|
124
124
|
import { mintPunchId, punchSession } from '../agent/p2p.mjs';
|
|
125
|
+
// v0.22.0: the permission gate. Imported rather than reimplemented for the same reason as everything else
|
|
126
|
+
// here -- a second copy of the escalation rule is a second chance to disagree with the first about how far
|
|
127
|
+
// below a requirement an actor may still ask.
|
|
128
|
+
import { gateDispatch, approvalRequestFor } from '../agent/permission.mjs';
|
|
129
|
+
import { isAuthorityLevel } from '../agent/authority.mjs';
|
|
125
130
|
// v0.6.0: the device keypair and the offer's key distribution (`docs/P2P-CRYPTO.md` §4). Imported
|
|
126
131
|
// rather than reimplemented: the dispatcher and the executor have to derive the *same* keys, and two
|
|
127
132
|
// implementations of a handshake are two chances to disagree about it.
|
|
@@ -185,7 +190,7 @@ const MAX_STAGES = 16;
|
|
|
185
190
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
186
191
|
* overrides it at runtime for tests and packagers.
|
|
187
192
|
*/
|
|
188
|
-
const PLUGIN_VERSION = '0.
|
|
193
|
+
const PLUGIN_VERSION = '0.23.0';
|
|
189
194
|
|
|
190
195
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
191
196
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -680,6 +685,21 @@ function readConfig(config = {}) {
|
|
|
680
685
|
.filter((entry) => typeof entry === 'string')
|
|
681
686
|
.map((entry) => entry.trim())
|
|
682
687
|
.filter(Boolean),
|
|
688
|
+
/**
|
|
689
|
+
* v0.22.0: the authority level this machine dispatches AS, or null when nobody configured one.
|
|
690
|
+
*
|
|
691
|
+
* Not part of the shared config, deliberately: this is a local statement about who is typing, and a
|
|
692
|
+
* fleet-wide value would let one machine's level speak for another's operator. `W2M_ACTOR_AUTHORITY`
|
|
693
|
+
* is accepted so a machine can be pointed at a level without editing a profile.
|
|
694
|
+
*
|
|
695
|
+
* null is not 0. An unset level means there is no actor to judge, and the gate reports that as
|
|
696
|
+
* unenforced rather than refusing every writing dispatch on a machine that never opted in.
|
|
697
|
+
*/
|
|
698
|
+
actorAuthorityLevel: isAuthorityLevel(config.actorAuthorityLevel)
|
|
699
|
+
? config.actorAuthorityLevel
|
|
700
|
+
: (Number.isInteger(Number(process.env.W2M_ACTOR_AUTHORITY)) && isAuthorityLevel(Number(process.env.W2M_ACTOR_AUTHORITY))
|
|
701
|
+
? Number(process.env.W2M_ACTOR_AUTHORITY)
|
|
702
|
+
: null),
|
|
683
703
|
defaultTimeoutMs: shared.values.defaultTimeoutMs,
|
|
684
704
|
sharedConfig: shared,
|
|
685
705
|
pairingCode: typeof config.pairingCode === 'string' && config.pairingCode.trim() !== ''
|
|
@@ -3687,6 +3707,40 @@ export async function apply(ctx, config = {}) {
|
|
|
3687
3707
|
);
|
|
3688
3708
|
}
|
|
3689
3709
|
|
|
3710
|
+
/**
|
|
3711
|
+
* v0.22.0: the permission gate, placed AFTER the write-scope check so the thing being graded is
|
|
3712
|
+
* already known to be well-formed.
|
|
3713
|
+
*
|
|
3714
|
+
* It grades the dispatch from its own facts rather than from a capability the caller names, and with
|
|
3715
|
+
* no configured level it reports `enforced: false` and changes nothing -- which is why adding it
|
|
3716
|
+
* cannot alter the behaviour of any machine that has not opted in.
|
|
3717
|
+
*/
|
|
3718
|
+
const gate = gateDispatch({ write, write_scope: writeScope }, cfg.actorAuthorityLevel);
|
|
3719
|
+
if (gate.enforced) {
|
|
3720
|
+
if (gate.decision.verdict === 'deny') {
|
|
3721
|
+
throw new W2MError(
|
|
3722
|
+
'W2M_PERMISSION_DENIED',
|
|
3723
|
+
'W2M_PERMISSION_DENIED: ' + gate.decision.detail,
|
|
3724
|
+
{ hint: "raise this machine's actorAuthorityLevel, or use a capability its level reaches" },
|
|
3725
|
+
);
|
|
3726
|
+
}
|
|
3727
|
+
if (gate.decision.verdict === 'needs_approval') {
|
|
3728
|
+
const request = approvalRequestFor(gate.decision, {
|
|
3729
|
+
taskId: null,
|
|
3730
|
+
machineId: null,
|
|
3731
|
+
requestedBy: cfg.machineName,
|
|
3732
|
+
});
|
|
3733
|
+
throw new W2MError(
|
|
3734
|
+
'W2M_PERMISSION_NEEDS_APPROVAL',
|
|
3735
|
+
'W2M_PERMISSION_NEEDS_APPROVAL: ' + gate.decision.detail,
|
|
3736
|
+
{
|
|
3737
|
+
hint: 'this dispatch needs an approver; it is refused rather than queued because no approval queue is wired to this call yet',
|
|
3738
|
+
request,
|
|
3739
|
+
},
|
|
3740
|
+
);
|
|
3741
|
+
}
|
|
3742
|
+
}
|
|
3743
|
+
|
|
3690
3744
|
// §5: dispatching a task is the operator's privilege, not the device's. The operator token is
|
|
3691
3745
|
// required and there is no device-token fallbac - see requireOperatorToken.
|
|
3692
3746
|
const token = requireOperatorToken(cfg);
|