@twinsearth/w2m-dsh-plugin 1.0.4 → 1.0.6
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 +113 -0
- package/cordis.patch.yml +20 -0
- package/lib/gpw.js +10 -0
- package/package.json +3 -2
- package/plugin.json +1 -1
- package/src/plugin/gpw.mjs +205 -0
- package/src/plugin/tools.mjs +1 -1
- package/src/relay/persistence.mjs +249 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,119 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented here.
|
|
4
4
|
|
|
5
|
+
## [1.0.6] — 2026-10-17
|
|
6
|
+
|
|
7
|
+
**DSH-GPW now ships *findable*: installed with the package, present in the plugin list, and off until an operator
|
|
8
|
+
turns it on.**
|
|
9
|
+
|
|
10
|
+
### What v1.0.5 got wrong
|
|
11
|
+
|
|
12
|
+
It shipped the subpath export `@twinsearth/w2m-dsh-plugin/gpw` and no config row. **A plugin the composed tree does
|
|
13
|
+
not mention is a plugin the plugin manager cannot list** -- so "enable it manually" had **nothing to click**, and
|
|
14
|
+
the only way in was hand-editing a profile patch.
|
|
15
|
+
|
|
16
|
+
**An optional component nobody can find is not optional, it is absent.** That is the same distinction as the seven
|
|
17
|
+
written-but-unreachable modules in this repository, seen from the user's side instead of the code's.
|
|
18
|
+
|
|
19
|
+
### The shape that fixes it
|
|
20
|
+
|
|
21
|
+
The package's own bundle patch now carries a second entry:
|
|
22
|
+
|
|
23
|
+
- id: dsh-gpw
|
|
24
|
+
name: '@twinsearth/w2m-dsh-plugin/gpw'
|
|
25
|
+
disabled: true
|
|
26
|
+
config:
|
|
27
|
+
maxPerWindow: 10
|
|
28
|
+
windowMs: 60000
|
|
29
|
+
minGapMs: 3000
|
|
30
|
+
|
|
31
|
+
**`disabled: true` is the load-bearing field.** The row exists, so the component appears; the toggle is off, so
|
|
32
|
+
nothing answers `approval/request`; and **flipping it in the UI is the whole enablement step** -- no file edit, no
|
|
33
|
+
restart of anything but the client.
|
|
34
|
+
|
|
35
|
+
**It stays in the same `insert:` list as the w2m row**, and that is still the correct form: this is an insert
|
|
36
|
+
because no earlier layer creates a `dsh-gpw` row. (A *bare* `- id:` patch aimed at a nonexistent row is skipped
|
|
37
|
+
silently -- the failure recorded in this file and in TROUBLESHOOTING 3.2.)
|
|
38
|
+
|
|
39
|
+
### The distinction the two releases now make
|
|
40
|
+
|
|
41
|
+
| | v1.0.5 | v1.0.6 |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| Installs with the package | yes | yes |
|
|
44
|
+
| Appears in the plugin manager | **no** | **yes** |
|
|
45
|
+
| Enabled by default | no | no |
|
|
46
|
+
| How you turn it on | hand-edit a profile patch | **click its toggle** |
|
|
47
|
+
|
|
48
|
+
**Not verified: that the toggle actually flips it on and that it then intercepts.** That needs a live host. The
|
|
49
|
+
observable evidence is its own startup line followed by its counted line --
|
|
50
|
+
|
|
51
|
+
approval-ratelimit: active -- at most 10 auto-approvals per 60 s, at least 3000 ms apart, process-wide
|
|
52
|
+
approval-ratelimit: N auto-approved, M deferred to the human
|
|
53
|
+
|
|
54
|
+
-- **because without the second line, "mounted and working" and "mounted and doing nothing" are the same picture**,
|
|
55
|
+
which is the failure this watchdog's own counters exist to prevent.
|
|
56
|
+
|
|
57
|
+
## [1.0.5] — 2026-10-17
|
|
58
|
+
|
|
59
|
+
**DSH-GPW ships as an optional sub-plugin, and the reason it never activated is fixed.**
|
|
60
|
+
|
|
61
|
+
### What DSH-GPW is
|
|
62
|
+
|
|
63
|
+
**Global Permission Watchdog.** It answers permission requests on the `approval/request` waterfall with a hard global
|
|
64
|
+
budget: **at most 10 auto-approvals per rolling 60 s, at least 3000 ms apart.** Past either limit it returns
|
|
65
|
+
`next()` -- *"no opinion"* -- so the request falls through to the human prompt **and waits**. It never refuses, and
|
|
66
|
+
it never changes what is being asked.
|
|
67
|
+
|
|
68
|
+
### Optional means optional, which decides where it lives
|
|
69
|
+
|
|
70
|
+
The package declares **one** `dsh.bundle.patch`, and that is what makes the w2m plugin mount itself on install. **A
|
|
71
|
+
second plugin in the same patch would mount for everyone**, and this one changes how permissions are answered -- that
|
|
72
|
+
is an operator's decision, not a default.
|
|
73
|
+
|
|
74
|
+
So it is exposed as `@twinsearth/w2m-dsh-plugin/gpw` and mounted by one explicit row:
|
|
75
|
+
|
|
76
|
+
- insert:
|
|
77
|
+
- id: dsh-gpw
|
|
78
|
+
name: '@twinsearth/w2m-dsh-plugin/gpw'
|
|
79
|
+
|
|
80
|
+
**`insert:` is required and the reason is recorded in this package's own patch file**: no earlier layer creates a
|
|
81
|
+
`dsh-gpw` row, and a bare `- id:` patch aimed at a nonexistent row is **skipped silently**. Removing the row
|
|
82
|
+
disables it again.
|
|
83
|
+
|
|
84
|
+
**It is deliberately not re-exported from `lib/index.js`.** A permission-answering plugin must not arrive because
|
|
85
|
+
someone installed a fleet tool.
|
|
86
|
+
|
|
87
|
+
### The defect that kept it from ever activating
|
|
88
|
+
|
|
89
|
+
The standalone copy declared:
|
|
90
|
+
|
|
91
|
+
export const inject = { required: ['approval'] };
|
|
92
|
+
|
|
93
|
+
**The host reads `inject` as an array of service names.** Measured across the shipped tree: **283 array-shaped
|
|
94
|
+
declarations, 0 object-shaped.** So the host took the object's *key* and waited for a service literally named
|
|
95
|
+
`required`:
|
|
96
|
+
|
|
97
|
+
pending (waiting for service: required)
|
|
98
|
+
|
|
99
|
+
-- indefinitely, and the client reported the component as `等待依赖`. It is now `['approval']`, and `approval`
|
|
100
|
+
does exist: `@deepseek-ai/dsh-base`'s patch mounts `@deepseek-ai/dsh-user-approval` as id `approval`.
|
|
101
|
+
|
|
102
|
+
**The error message named its own cause**, which is why this took one measurement instead of an investigation: it
|
|
103
|
+
said `required`, and `required` is the key.
|
|
104
|
+
|
|
105
|
+
### What is verified, and what is not
|
|
106
|
+
|
|
107
|
+
**Verified:** `lib/gpw.js` loads; `name` is `dsh-gpw`; `inject` is an array; `apply` is a function;
|
|
108
|
+
`approval/request` and `allowed-once` both exist in the host (9 and 22 files respectively).
|
|
109
|
+
|
|
110
|
+
**Not verified: that it mounts and intercepts.** That needs a live host with the row added. The observable evidence
|
|
111
|
+
after a restart is its own startup line --
|
|
112
|
+
|
|
113
|
+
approval-ratelimit: active -- at most 10 auto-approvals per 60 s, at least 3000 ms apart, process-wide
|
|
114
|
+
|
|
115
|
+
-- **and then the periodic counted line, without which "mounted and working" and "mounted and doing nothing" look
|
|
116
|
+
identical.** That distinction is the reason those counters exist.
|
|
117
|
+
|
|
5
118
|
## [1.0.4] — 2026-10-17
|
|
6
119
|
|
|
7
120
|
**The description described a different package, and it is the only thing many readers see.**
|
package/cordis.patch.yml
CHANGED
|
@@ -115,3 +115,23 @@
|
|
|
115
115
|
allowedCommands: []
|
|
116
116
|
p2pMode: auto
|
|
117
117
|
stunServers: []
|
|
118
|
+
# DSH-GPW (Global Permission Watchdog) ships with this package and is **off until an operator turns it on**.
|
|
119
|
+
#
|
|
120
|
+
# It is in this insert list rather than left out of the tree entirely, because a plugin the composed tree does
|
|
121
|
+
# not mention is a plugin the plugin manager cannot list -- and then "enable it manually" has no control to
|
|
122
|
+
# click. **A row with `disabled: true` is the shape that makes it installable with the package and switchable
|
|
123
|
+
# from the UI**: the component shows up, its toggle is off, and flipping it needs no file edit.
|
|
124
|
+
#
|
|
125
|
+
# Found this way, not assumed: v1.0.5 shipped the subpath export but no row, so the watchdog was reachable only
|
|
126
|
+
# by hand-editing a profile patch. **An optional component nobody can find is not optional, it is absent.**
|
|
127
|
+
#
|
|
128
|
+
# What it does once enabled: answers `approval/request` with at most 10 auto-approvals per rolling 60 s, at
|
|
129
|
+
# least 3000 ms apart, and defers everything past that to the human prompt, where it waits.
|
|
130
|
+
- id: dsh-gpw
|
|
131
|
+
name: '@twinsearth/w2m-dsh-plugin/gpw'
|
|
132
|
+
disabled: true
|
|
133
|
+
config:
|
|
134
|
+
maxPerWindow: 10
|
|
135
|
+
windowMs: 60000
|
|
136
|
+
minGapMs: 3000
|
|
137
|
+
|
package/lib/gpw.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-facing entry for DSH-GPW: `@twinsearth/w2m-dsh-plugin/gpw`.
|
|
3
|
+
*
|
|
4
|
+
* The documented subpath a profile row names to enable the watchdog. A seam, not a second implementation -- the code
|
|
5
|
+
* lives in `src/plugin/gpw.mjs`, which is where it is readable.
|
|
6
|
+
*
|
|
7
|
+
* **It is deliberately not re-exported from `lib/index.js`.** The package root is what installs by default, and a
|
|
8
|
+
* permission-answering plugin must not arrive because someone installed a fleet tool.
|
|
9
|
+
*/
|
|
10
|
+
export { apply, inject, name } from '../src/plugin/gpw.mjs';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"./rabbit": "./bin/w2m-rabbit.mjs",
|
|
17
17
|
"./localside": "./bin/w2m-localside.mjs",
|
|
18
18
|
"./stun": "./bin/w2m-stun.mjs",
|
|
19
|
-
"./package.json": "./package.json"
|
|
19
|
+
"./package.json": "./package.json",
|
|
20
|
+
"./gpw": "./lib/gpw.js"
|
|
20
21
|
},
|
|
21
22
|
"files": [
|
|
22
23
|
"mcp.json",
|
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "twinsearth-w2m-dsh",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.6",
|
|
5
5
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH-GPW -- Global Permission Watchdog (v1.0.5), shipped as an OPTIONAL sub-plugin of @twinsearth/w2m-dsh-plugin.
|
|
3
|
+
*
|
|
4
|
+
* ## What it does, in one paragraph
|
|
5
|
+
*
|
|
6
|
+
* It answers permission requests on the `approval/request` waterfall with a hard global budget: **at most 10
|
|
7
|
+
* auto-approvals per rolling 60 s, at least 3000 ms apart.** Anything past either limit returns `next()`, which is
|
|
8
|
+
* *"no opinion"* -- the request falls through to the human prompt and **waits**. It never refuses, and it never
|
|
9
|
+
* changes what is being asked; it only decides who answers and how fast.
|
|
10
|
+
*
|
|
11
|
+
* ## Why the budget exists
|
|
12
|
+
*
|
|
13
|
+
* Every auto-approval is a permission decision made without a human reading it. A watchdog with no ceiling is a
|
|
14
|
+
* plugin that grants everything and is indistinguishable from one that is working correctly -- the failure mode this
|
|
15
|
+
* repository has recorded seven times. The two counters it logs (`allowedTotal`, `deferredTotal`) exist so that
|
|
16
|
+
* "mounted and doing nothing" and "mounted and working" look different.
|
|
17
|
+
*
|
|
18
|
+
* ## How it is enabled -- it is NOT on by default
|
|
19
|
+
*
|
|
20
|
+
* Installing this package does **not** mount the watchdog: its own `dsh.bundle.patch` mounts only the w2m plugin.
|
|
21
|
+
* To enable it, add ONE `insert:` to the profile's `cordis.patch.yml`:
|
|
22
|
+
*
|
|
23
|
+
* - insert:
|
|
24
|
+
* - id: dsh-gpw
|
|
25
|
+
* name: '@twinsearth/w2m-dsh-plugin/gpw'
|
|
26
|
+
* config:
|
|
27
|
+
* maxPerWindow: 10 # optional; these are the defaults
|
|
28
|
+
* windowMs: 60000
|
|
29
|
+
* minGapMs: 3000
|
|
30
|
+
*
|
|
31
|
+
* `insert:` is required: no earlier layer creates a `dsh-gpw` row, and a bare `- id:` patch aimed at a
|
|
32
|
+
* non-existent row is skipped **silently**. Remove the row to disable it again -- no other change is needed.
|
|
33
|
+
*
|
|
34
|
+
* ## Its dependency, and the bug that shipped in the standalone copy
|
|
35
|
+
*
|
|
36
|
+
* `inject = ['approval']`. The first version of this module declared `{ required: ['approval'] }`; the host reads
|
|
37
|
+
* `inject` as an array (**283 array-shaped declarations across the shipped tree, 0 object-shaped**), so it waited
|
|
38
|
+
* for a service named `required` and stayed `pending (waiting for service: required)` forever. `approval` itself
|
|
39
|
+
* exists: `@deepseek-ai/dsh-base`'s patch mounts `@deepseek-ai/dsh-user-approval` as id `approval`.
|
|
40
|
+
*
|
|
41
|
+
* ## Provenance
|
|
42
|
+
*
|
|
43
|
+
* Written as a standalone package `dsh-approval-ratelimit@1.0.0`, then folded in here so a fleet operator installs
|
|
44
|
+
* one thing. The logic is unchanged apart from the name and the `inject` fix; every comment explaining a decision is
|
|
45
|
+
* the original.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/** @type {import('node:timers').Timeout | null} */
|
|
49
|
+
let logTimer = null;
|
|
50
|
+
|
|
51
|
+
export const name = 'dsh-gpw';
|
|
52
|
+
|
|
53
|
+
export const inject = ['approval'];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {object} ctx Cordis context.
|
|
57
|
+
* @param {object} [config]
|
|
58
|
+
* @param {number} [config.maxPerWindow] Auto-approvals allowed per rolling window. Default 10.
|
|
59
|
+
* @param {number} [config.windowMs] Window length in ms. Default 60000.
|
|
60
|
+
* @param {number} [config.minGapMs] Minimum gap between two auto-approvals, in ms. Default 3000.
|
|
61
|
+
*/
|
|
62
|
+
export function apply(ctx, config = {}) {
|
|
63
|
+
const maxPerWindow = positiveInt(config.maxPerWindow, 10);
|
|
64
|
+
const windowMs = positiveInt(config.windowMs, 60_000);
|
|
65
|
+
const minGapMs = positiveInt(config.minGapMs, 3_000);
|
|
66
|
+
|
|
67
|
+
/** Timestamps of the auto-approvals inside the current window, oldest first. */
|
|
68
|
+
const approvals = [];
|
|
69
|
+
/** Timestamp of the most recent auto-approval, or null. */
|
|
70
|
+
let lastApprovalAt = null;
|
|
71
|
+
/** Counters for the periodic line, so the operator can see this thing working. */
|
|
72
|
+
let deferredTotal = 0;
|
|
73
|
+
let allowedTotal = 0;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Drop approvals that have aged out of the window.
|
|
77
|
+
*
|
|
78
|
+
* A rolling window is the requirement; a fixed one would allow a burst of `2 * maxPerWindow`
|
|
79
|
+
* straddling a boundary (ten at the end of one minute, ten at the start of the next).
|
|
80
|
+
*
|
|
81
|
+
* @param {number} now
|
|
82
|
+
*/
|
|
83
|
+
function prune(now) {
|
|
84
|
+
const cutoff = now - windowMs;
|
|
85
|
+
while (approvals.length > 0 && approvals[0] <= cutoff) approvals.shift();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Decide one request, without ever rejecting.
|
|
90
|
+
*
|
|
91
|
+
* @param {number} now
|
|
92
|
+
* @returns {{ allow: boolean, reason: string, inWindow: number }}
|
|
93
|
+
*/
|
|
94
|
+
function decide(now) {
|
|
95
|
+
prune(now);
|
|
96
|
+
if (lastApprovalAt !== null && now - lastApprovalAt < minGapMs) {
|
|
97
|
+
return {
|
|
98
|
+
allow: false,
|
|
99
|
+
reason: `only ${now - lastApprovalAt} ms since the last auto-approval (needs ${minGapMs} ms)`,
|
|
100
|
+
inWindow: approvals.length,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (approvals.length >= maxPerWindow) {
|
|
104
|
+
return {
|
|
105
|
+
allow: false,
|
|
106
|
+
reason: `${approvals.length} auto-approvals already in the last ${Math.round(windowMs / 1000)} s (limit ${maxPerWindow})`,
|
|
107
|
+
inWindow: approvals.length,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return { allow: true, reason: 'within both limits', inWindow: approvals.length };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* One line per window of real activity, so a silent plugin and a broken plugin look different.
|
|
115
|
+
*
|
|
116
|
+
* The failure mode this guards against is the one this whole session keeps meeting: a component
|
|
117
|
+
* that is mounted, doing nothing, and indistinguishable from a healthy one. `allowedTotal` and
|
|
118
|
+
* `deferredTotal` are the two numbers that tell them apart.
|
|
119
|
+
*/
|
|
120
|
+
function startReporter() {
|
|
121
|
+
if (logTimer !== null) return () => {};
|
|
122
|
+
logTimer = setInterval(() => {
|
|
123
|
+
if (allowedTotal === 0 && deferredTotal === 0) return;
|
|
124
|
+
ctx.logger?.info?.(
|
|
125
|
+
`approval-ratelimit: ${allowedTotal} auto-approved, ${deferredTotal} deferred to the human `
|
|
126
|
+
+ `(limit ${maxPerWindow}/${Math.round(windowMs / 1000)}s, gap ${minGapMs}ms, ${approvals.length} in the current window)`,
|
|
127
|
+
);
|
|
128
|
+
}, windowMs);
|
|
129
|
+
logTimer.unref?.();
|
|
130
|
+
return () => {
|
|
131
|
+
if (logTimer !== null) {
|
|
132
|
+
clearInterval(logTimer);
|
|
133
|
+
logTimer = null;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The answerer.
|
|
140
|
+
*
|
|
141
|
+
* Registered on the `approval/request` waterfall. Returning `'allowed-once'` grants this one
|
|
142
|
+
* request; returning `next()` is "no opinion" and hands the decision to whoever answers next --
|
|
143
|
+
* which is the human prompt, and it *waits* there rather than being refused.
|
|
144
|
+
*/
|
|
145
|
+
const disposeListener = ctx.on('approval/request', (request, next) => {
|
|
146
|
+
const now = Date.now();
|
|
147
|
+
const verdict = decide(now);
|
|
148
|
+
if (!verdict.allow) {
|
|
149
|
+
deferredTotal += 1;
|
|
150
|
+
// Debug, not warn: deferring is the designed behaviour of this plugin, and warning about the
|
|
151
|
+
// normal path trains an operator to ignore the line that would matter.
|
|
152
|
+
ctx.logger?.debug?.(
|
|
153
|
+
`approval-ratelimit: deferring to the human -- ${verdict.reason}`
|
|
154
|
+
+ (request?.toolName ? ` (tool ${request.toolName})` : ''),
|
|
155
|
+
);
|
|
156
|
+
return next();
|
|
157
|
+
}
|
|
158
|
+
approvals.push(now);
|
|
159
|
+
lastApprovalAt = now;
|
|
160
|
+
allowedTotal += 1;
|
|
161
|
+
ctx.logger?.info?.(
|
|
162
|
+
`approval-ratelimit: auto-approved (${approvals.length}/${maxPerWindow} in the current window)`
|
|
163
|
+
+ (request?.toolName ? ` tool ${request.toolName}` : '')
|
|
164
|
+
+ (request?.reason ? ` -- ${request.reason}` : ''),
|
|
165
|
+
);
|
|
166
|
+
return 'allowed-once';
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const stopReporter = startReporter();
|
|
170
|
+
|
|
171
|
+
// Reversible: the host owns this effect, so an unload removes the answerer and the timer with it.
|
|
172
|
+
// A listener that outlived its plugin would keep approving requests with nobody watching.
|
|
173
|
+
if (typeof ctx.effect === 'function') {
|
|
174
|
+
ctx.effect(() => () => {
|
|
175
|
+
disposeListener?.();
|
|
176
|
+
stopReporter();
|
|
177
|
+
});
|
|
178
|
+
} else {
|
|
179
|
+
// No effect seam: say so rather than pretending the cleanup is guaranteed.
|
|
180
|
+
ctx.logger?.warn?.(
|
|
181
|
+
'approval-ratelimit: the context provides no ctx.effect, so the answerer and its reporter '
|
|
182
|
+
+ 'cannot be released on unload',
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
ctx.logger?.info?.(
|
|
187
|
+
`approval-ratelimit: active -- at most ${maxPerWindow} auto-approvals per ${Math.round(windowMs / 1000)} s, `
|
|
188
|
+
+ `at least ${minGapMs} ms apart, process-wide; everything else waits for the human`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Coerce a config value to a positive integer, falling back to the default.
|
|
194
|
+
*
|
|
195
|
+
* A misconfigured limit must not silently become "0", which would disable the plugin while looking
|
|
196
|
+
* like it was on -- and must not become a non-finite number, which would break the comparison.
|
|
197
|
+
*
|
|
198
|
+
* @param {unknown} value
|
|
199
|
+
* @param {number} fallback
|
|
200
|
+
* @returns {number}
|
|
201
|
+
*/
|
|
202
|
+
function positiveInt(value, fallback) {
|
|
203
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return fallback;
|
|
204
|
+
return Math.floor(value);
|
|
205
|
+
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -208,7 +208,7 @@ const MAX_STAGES = 16;
|
|
|
208
208
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
209
209
|
* overrides it at runtime for tests and packagers.
|
|
210
210
|
*/
|
|
211
|
-
const PLUGIN_VERSION = '1.0.
|
|
211
|
+
const PLUGIN_VERSION = '1.0.6';
|
|
212
212
|
|
|
213
213
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
214
214
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* `<state>/devices.json` atomic snapshot (tmp file + rename)
|
|
5
5
|
* `<state>/ledger.jsonl` append-only, one JSON object per line
|
|
6
6
|
* `<state>/operator-token.txt` single line, 0600
|
|
7
|
+
* `<state>/blobs/<aa>/<sha256>` content-addressed bytes, v1.0.7 (see `putBlob`)
|
|
7
8
|
*
|
|
8
9
|
* This is the only relay module allowed to touch the filesystem.
|
|
9
10
|
* Corruption is never silent: every skipped line / unreadable snapshot is
|
|
@@ -12,12 +13,31 @@
|
|
|
12
13
|
|
|
13
14
|
import fs from 'node:fs';
|
|
14
15
|
import path from 'node:path';
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
15
17
|
|
|
16
18
|
import { rfc3339 } from './state.mjs';
|
|
17
19
|
|
|
18
20
|
export const DEVICES_FILE = 'devices.json';
|
|
19
21
|
export const LEDGER_FILE = 'ledger.jsonl';
|
|
20
22
|
export const OPERATOR_TOKEN_FILE = 'operator-token.txt';
|
|
23
|
+
/** v1.0.7: the content-addressed blob directory, and the two caps that keep it bounded. */
|
|
24
|
+
export const BLOB_DIR = 'blobs';
|
|
25
|
+
export const MAX_BLOB_BYTES = 8 * 1024 * 1024;
|
|
26
|
+
export const MAX_BLOBS = 256;
|
|
27
|
+
/**
|
|
28
|
+
* v1.0.7: **which fleet wrote each blob.**
|
|
29
|
+
*
|
|
30
|
+
* Content addressing keys a blob by its own hash, and a hash knows nothing about fleets. So two fleets that store
|
|
31
|
+
* the same bytes land at the same path -- and without this index, the second one could read the first one's content.
|
|
32
|
+
* **A content-addressed store with no ownership record is not isolated, it is shared.**
|
|
33
|
+
*
|
|
34
|
+
* The index is a separate document rather than part of the path (`blobs/<fleet>/<aa>/<hash>`) for one concrete
|
|
35
|
+
* reason: **deduplication is only sound when the content is identical**, and identical bytes stored twice would
|
|
36
|
+
* otherwise occupy two entries. The path stays a pure function of the content; the fleet is metadata.
|
|
37
|
+
*
|
|
38
|
+
* Shape: `{ "<sha256>": { fleet_id: string|null, added_at_ms: number } }`.
|
|
39
|
+
*/
|
|
40
|
+
export const BLOB_INDEX_FILE = 'index.json';
|
|
21
41
|
|
|
22
42
|
/** Ledger entry types understood by `RabbitState#restore`. */
|
|
23
43
|
export const LEDGER_TYPES = Object.freeze([
|
|
@@ -250,8 +270,232 @@ export class Persistence {
|
|
|
250
270
|
return true;
|
|
251
271
|
}
|
|
252
272
|
|
|
273
|
+
/* ------------------------------------------------------------------------------------------- */
|
|
274
|
+
/* v1.0.7: content-addressed blobs — the fleet sync channel (#1) */
|
|
275
|
+
/* ------------------------------------------------------------------------------------------- */
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Store bytes under their own SHA-256, and read them back by that hash.
|
|
279
|
+
*
|
|
280
|
+
* ## Why the relay may carry bytes at all, when it deliberately carries none today
|
|
281
|
+
*
|
|
282
|
+
* The relay persists `stdout_sha256` and never the text, and `src/agent/storage.mjs` records why that matters:
|
|
283
|
+
* `memory.jsonl` on each machine is the only durable copy of what a command printed. **That invariant is about
|
|
284
|
+
* the relay not becoming a document store** — it never interprets content and never asserts that content is
|
|
285
|
+
* authentic.
|
|
286
|
+
*
|
|
287
|
+
* A blob keyed by **its own hash** preserves both halves. The relay still interprets nothing; **the hash is what
|
|
288
|
+
* says the bytes are the bytes**, exactly as `stdout_sha256` does for a result. The only thing that is new is
|
|
289
|
+
* that a hash the relay already holds can now have its bytes fetched from the same place.
|
|
290
|
+
*
|
|
291
|
+
* ## The two limits, because an uncapped writer is what caused the original incident
|
|
292
|
+
*
|
|
293
|
+
* `storage.mjs`'s header records the incident that produced its whole design: a single uncapped writer reached
|
|
294
|
+
* 100% filesystem at 14.7 GB/day. A blob channel with no ceiling reproduces that on the relay, so:
|
|
295
|
+
*
|
|
296
|
+
* * **8 MiB per blob** — a `fleet-memory.md` is tens of KB; this is three orders of magnitude of headroom.
|
|
297
|
+
* Larger content is an archive, not a memory.
|
|
298
|
+
* * **256 blobs** — bounded *count* matters as much as bounded size: 256 × 8 MiB caps a fleet's share at
|
|
299
|
+
* 2 GiB regardless of what it writes.
|
|
300
|
+
*
|
|
301
|
+
* Eviction is oldest-first and **never takes the last blob**: deleting the only copy of a fact is the failure
|
|
302
|
+
* `UNDELETABLE_TIERS` exists to prevent locally.
|
|
303
|
+
*
|
|
304
|
+
* @param {Buffer} bytes
|
|
305
|
+
* @param {{fleetId?: string|null}} [options] the owning fleet, recorded so another fleet cannot read it back
|
|
306
|
+
* @returns {{ok:true, hash:string, bytes:number, evicted:string[]}|{ok:false, reason:string, detail:string}}
|
|
307
|
+
*/
|
|
308
|
+
putBlob(bytes, options = {}) {
|
|
309
|
+
if (!this.enabled) return { ok: false, reason: 'PERSISTENCE_DISABLED', detail: 'the relay has no state directory' };
|
|
310
|
+
if (!Buffer.isBuffer(bytes) || bytes.length === 0) {
|
|
311
|
+
return { ok: false, reason: 'BLOB_EMPTY', detail: 'an empty blob is a caller bug, not content' };
|
|
312
|
+
}
|
|
313
|
+
if (bytes.length > MAX_BLOB_BYTES) {
|
|
314
|
+
return {
|
|
315
|
+
ok: false,
|
|
316
|
+
reason: 'BLOB_TOO_LARGE',
|
|
317
|
+
detail: `${bytes.length} bytes exceeds the ${MAX_BLOB_BYTES}-byte limit`,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
const hash = createHash('sha256').update(bytes).digest('hex');
|
|
321
|
+
const file = this.blobFile(hash);
|
|
322
|
+
if (file === null) return { ok: false, reason: 'PERSISTENCE_DISABLED', detail: 'no blob directory' };
|
|
323
|
+
|
|
324
|
+
const index = this.readBlobIndex();
|
|
325
|
+
const fleetId = typeof options.fleetId === 'string' && options.fleetId !== '' ? options.fleetId : null;
|
|
326
|
+
|
|
327
|
+
// Content addressing makes the write idempotent: the same bytes always land at the same path.
|
|
328
|
+
if (fs.existsSync(file)) {
|
|
329
|
+
/**
|
|
330
|
+
* Already stored. **The first writer's fleet keeps ownership** -- re-assigning it on a second write would let
|
|
331
|
+
* one fleet claim a blob another fleet already owns by simply PUT-ting the same bytes, which is the same
|
|
332
|
+
* failure as having no index at all.
|
|
333
|
+
*/
|
|
334
|
+
const entry = index[hash];
|
|
335
|
+
if (entry === undefined) {
|
|
336
|
+
index[hash] = { fleet_id: fleetId, added_at_ms: this._now() };
|
|
337
|
+
this.writeBlobIndex(index);
|
|
338
|
+
}
|
|
339
|
+
return { ok: true, hash, bytes: bytes.length, evicted: [] };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const evicted = this.evictBlobsFor(1);
|
|
343
|
+
for (const gone of evicted) delete index[gone];
|
|
344
|
+
try {
|
|
345
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
346
|
+
const tmp = `${file}.tmp-${process.pid}-${this._now()}`;
|
|
347
|
+
fs.writeFileSync(tmp, bytes);
|
|
348
|
+
fs.renameSync(tmp, file);
|
|
349
|
+
} catch (error) {
|
|
350
|
+
this.warn(BLOB_DIR, `blob write failed: ${error?.message ?? String(error)}`);
|
|
351
|
+
return { ok: false, reason: 'BLOB_WRITE_FAILED', detail: error?.message ?? String(error) };
|
|
352
|
+
}
|
|
353
|
+
index[hash] = { fleet_id: fleetId, added_at_ms: this._now() };
|
|
354
|
+
this.writeBlobIndex(index);
|
|
355
|
+
return { ok: true, hash, bytes: bytes.length, evicted };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The bytes for a hash, or `null`.
|
|
360
|
+
*
|
|
361
|
+
* Never throws -- an unreadable blob is a miss, not a crash -- and **when `fleetId` is supplied, a blob owned by a
|
|
362
|
+
* different fleet is a miss too.** The caller cannot distinguish "no such blob" from "not yours", which is
|
|
363
|
+
* deliberate: a distinguishable refusal tells a prober that a hash exists on this relay.
|
|
364
|
+
*
|
|
365
|
+
* @param {string} hash
|
|
366
|
+
* @param {{fleetId?: string|null|undefined}} [options] omit `fleetId` entirely for an unscoped read
|
|
367
|
+
*/
|
|
368
|
+
getBlob(hash, options = {}) {
|
|
369
|
+
const file = this.blobFile(hash);
|
|
370
|
+
if (file === null) return null;
|
|
371
|
+
if (options.fleetId !== undefined) {
|
|
372
|
+
const entry = this.readBlobIndex()[hash];
|
|
373
|
+
// A blob with no recorded owner predates the index or was written unscoped; it is not this fleet's.
|
|
374
|
+
if (entry === undefined || entry.fleet_id !== options.fleetId) return null;
|
|
375
|
+
}
|
|
376
|
+
try {
|
|
377
|
+
return fs.readFileSync(file);
|
|
378
|
+
} catch {
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
hasBlob(hash) {
|
|
384
|
+
const file = this.blobFile(hash);
|
|
385
|
+
if (file === null) return false;
|
|
386
|
+
try {
|
|
387
|
+
return fs.statSync(file).isFile();
|
|
388
|
+
} catch {
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Every stored blob, newest last, with the size and mtime needed to enforce the count cap.
|
|
395
|
+
*
|
|
396
|
+
* @returns {{hash:string, bytes:number, mtimeMs:number}[]}
|
|
397
|
+
*/
|
|
398
|
+
listBlobs() {
|
|
399
|
+
if (!this.enabled) return [];
|
|
400
|
+
const root = path.join(this.dir, BLOB_DIR);
|
|
401
|
+
let shards;
|
|
402
|
+
try {
|
|
403
|
+
shards = fs.readdirSync(root, { withFileTypes: true });
|
|
404
|
+
} catch {
|
|
405
|
+
return [];
|
|
406
|
+
}
|
|
407
|
+
const out = [];
|
|
408
|
+
for (const shard of shards) {
|
|
409
|
+
if (!shard.isDirectory()) continue;
|
|
410
|
+
const shardPath = path.join(root, shard.name);
|
|
411
|
+
let names;
|
|
412
|
+
try {
|
|
413
|
+
names = fs.readdirSync(shardPath, { withFileTypes: true });
|
|
414
|
+
} catch {
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
for (const entry of names) {
|
|
418
|
+
if (!entry.isFile() || entry.name.includes('.tmp-')) continue;
|
|
419
|
+
try {
|
|
420
|
+
const stat = fs.statSync(path.join(shardPath, entry.name));
|
|
421
|
+
out.push({ hash: entry.name, bytes: stat.size, mtimeMs: stat.mtimeMs });
|
|
422
|
+
} catch {
|
|
423
|
+
/* a blob that vanished mid-scan is not a failure */
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
out.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
428
|
+
return out;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Make room for `incoming` more blobs, oldest first, and never take the last one.
|
|
433
|
+
*
|
|
434
|
+
* **The caller must prune the index for the returned hashes** -- this method deletes files, and the index is a
|
|
435
|
+
* separate document. `putBlob` does that pruning; a future caller that evicts for another reason owes the same.
|
|
436
|
+
*
|
|
437
|
+
* @param {number} incoming
|
|
438
|
+
* @returns {string[]} the hashes evicted
|
|
439
|
+
*/
|
|
440
|
+
evictBlobsFor(incoming) {
|
|
441
|
+
const existing = this.listBlobs();
|
|
442
|
+
const over = existing.length + incoming - MAX_BLOBS;
|
|
443
|
+
if (over <= 0) return [];
|
|
444
|
+
const evicted = [];
|
|
445
|
+
let count = existing.length;
|
|
446
|
+
for (const blob of existing) {
|
|
447
|
+
if (count <= 1) break; // never delete the only copy of anything
|
|
448
|
+
try {
|
|
449
|
+
fs.rmSync(path.join(this.dir, BLOB_DIR, blob.hash.slice(0, 2), blob.hash), { force: true });
|
|
450
|
+
evicted.push(blob.hash);
|
|
451
|
+
count -= 1;
|
|
452
|
+
} catch {
|
|
453
|
+
/* leave it; the caller will see the count did not drop */
|
|
454
|
+
}
|
|
455
|
+
if (evicted.length >= over) break;
|
|
456
|
+
}
|
|
457
|
+
if (evicted.length > 0) this.warn(BLOB_DIR, `evicted ${evicted.length} blob(s) to stay within ${MAX_BLOBS}`);
|
|
458
|
+
return evicted;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Read the hash -> owner map. A missing or corrupt index reads as empty rather than taking the relay down. */
|
|
462
|
+
readBlobIndex() {
|
|
463
|
+
if (!this.enabled) return {};
|
|
464
|
+
try {
|
|
465
|
+
const raw = fs.readFileSync(path.join(this.dir, BLOB_DIR, BLOB_INDEX_FILE), 'utf8');
|
|
466
|
+
const parsed = JSON.parse(raw);
|
|
467
|
+
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
468
|
+
} catch {
|
|
469
|
+
return {};
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Atomically replace the index. Best-effort: a failure is warned about, never thrown at the caller. */
|
|
474
|
+
writeBlobIndex(index) {
|
|
475
|
+
if (!this.enabled) return false;
|
|
476
|
+
const file = path.join(this.dir, BLOB_DIR, BLOB_INDEX_FILE);
|
|
477
|
+
try {
|
|
478
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
479
|
+
const tmp = `${file}.tmp-${process.pid}-${this._now()}`;
|
|
480
|
+
fs.writeFileSync(tmp, `${JSON.stringify(index)}\n`, 'utf8');
|
|
481
|
+
fs.renameSync(tmp, file);
|
|
482
|
+
return true;
|
|
483
|
+
} catch (error) {
|
|
484
|
+
this.warn(BLOB_DIR, `blob index write failed: ${error?.message ?? String(error)}`);
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** `<state>/blobs/<aa>/<sha256>`, or `null` when persistence is off or the hash is not a sha256. */
|
|
490
|
+
blobFile(hash) {
|
|
491
|
+
if (!this.enabled) return null;
|
|
492
|
+
if (typeof hash !== 'string' || !/^[0-9a-f]{64}$/.test(hash)) return null;
|
|
493
|
+
return path.join(this.dir, BLOB_DIR, hash.slice(0, 2), hash);
|
|
494
|
+
}
|
|
495
|
+
|
|
253
496
|
/** Redacted view for `/healthz` — never contains tokens. */
|
|
254
497
|
describe() {
|
|
498
|
+
const blobs = this.listBlobs();
|
|
255
499
|
return {
|
|
256
500
|
enabled: this.enabled,
|
|
257
501
|
dir: this.dir,
|
|
@@ -261,6 +505,11 @@ export class Persistence {
|
|
|
261
505
|
devices_missing: this.devicesMissing,
|
|
262
506
|
ledger_lines_read: this.ledgerLinesRead,
|
|
263
507
|
ledger_lines_skipped: this.ledgerLinesSkipped,
|
|
508
|
+
/** v1.0.7: the fleet sync channel's occupancy, reported as counts and bytes — never contents. */
|
|
509
|
+
blobs: blobs.length,
|
|
510
|
+
blob_bytes: blobs.reduce((sum, b) => sum + b.bytes, 0),
|
|
511
|
+
blobs_limit: MAX_BLOBS,
|
|
512
|
+
blob_bytes_limit: MAX_BLOB_BYTES,
|
|
264
513
|
/**
|
|
265
514
|
* v0.6.2. `degraded: true` means the relay is answering requests while failing to record them,
|
|
266
515
|
* which is the one relay failure that looks exactly like health from the outside.
|