@twinsearth/w2m-dsh-plugin 0.23.0 → 0.25.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 CHANGED
@@ -4,6 +4,103 @@ 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.25.0] — 2026-10-15
8
+
9
+ **The chain is closed: dispatch → gate → queue → decision. The plugin builds a queue, and an escalation is a real entry an approver can find.**
10
+
11
+ ### The queue belongs to the plugin, not to the call
12
+
13
+ `apply()` now constructs **one** `createApprovalIndex()` for the plugin's lifetime, lazily. A queue built at
14
+ the call site would be **empty every time**, so a request raised by one dispatch could never be found by the
15
+ decision that answers it — **it would look wired and behave as if it were not.** Same reasoning as the P2P
16
+ runtime beside it: something that must outlive a single call belongs to the plugin.
17
+
18
+ ### Measured, end to end, before it was written down
19
+
20
+ | Step | Result |
21
+ |---|---|
22
+ | an ordinary actor (49) asks to write | `needs_approval`, `enforced: true` |
23
+ | the request is raised | `ok`, id issued, `subjectLevel` **49** |
24
+ | a captain (50) lists the queue | `may_act: true`, `required_level: 50` |
25
+ | a peer (10) lists the same queue | **sees it**, `may_act: false`, `APPROVAL_INSUFFICIENT_AUTHORITY` |
26
+ | the captain settles it | `ok` |
27
+ | after settling | `approved`, **`subjectLevel` still 49** |
28
+ | the requester approves their own request | **`APPROVAL_SELF_APPROVAL`** |
29
+
30
+ The fourth row is the one worth naming: **a peer can see work that is above their authority and is not theirs
31
+ to answer.** A filtered list would make the fleet look idle at the moment it is most blocked.
32
+
33
+ ### A queue that refuses is not a queue that needs approval
34
+
35
+ If `raiseApproval` fails, the dispatch is refused with **`W2M_PERMISSION_APPROVAL_NOT_RAISED`**, a different
36
+ code from `W2M_PERMISSION_NEEDS_APPROVAL`. A caller told "await approval" for a request that was never stored
37
+ would **wait forever for a decision nobody can make.**
38
+
39
+ ### Still true, and stated rather than glossed
40
+
41
+ The dispatch still **does not run** when approval is required — an escalation is a request to be allowed, not
42
+ a permission. There is **no tool yet for an approver to settle a pending request**; `decide()` exists and is
43
+ exercised by the suite, and wiring it to a tool is the next step. So the chain is closed in code and testable
44
+ end to end, while remaining **unreachable by an operator in the running plugin.**
45
+
46
+ ### My probe was wrong before the code was
47
+
48
+ The first end-to-end run threw on `listFor(...).requests.length`. `listFor` returns a **row array**, not an
49
+ object with a `requests` field. **The code was right; my assumption about a shape I had not read was wrong**
50
+ — the fifth time in this project, and the same failure as the parse-prefix mistake early on.
51
+
52
+ **3 new chain tests, 0 failures.**
53
+
54
+ ## [0.24.0] — 2026-10-15
55
+
56
+ **The escalation reaches the approval index — and the bridge between them is where the defect would have been.**
57
+
58
+ Two shapes that look alike and are not:
59
+
60
+ | `approvalRequestFor()` produces | `index.register()` wants |
61
+ |---|---|
62
+ | `subject_level` (flat) | `subject: { id, level }` (nested) |
63
+ | `capability` | `action` |
64
+ | `task_id`, `requested_by` | `sessionKey`, `subject.id` |
65
+
66
+ A caller hand-mapping these at the call site would get it right once and wrong later, and **the failure would
67
+ be a request registered against the wrong subject — the one field the whole decision rests on.** So
68
+ `raiseApproval()` owns the mapping and lives beside the thing it maps from.
69
+
70
+ ### It does not smooth anything over
71
+
72
+ `register()` refuses a subject level that is not an integer 0..100, and the adapter **passes the index's own
73
+ `reason_code` and `detail` through unchanged**. A bridge that substituted a default level to make registration
74
+ succeed would be **inventing the single fact the approver's decision rests on, and would look like success
75
+ while doing it.** Tested directly: a request whose level is `'captain'` is refused, and the queue stays empty.
76
+
77
+ The `approvalId` is **required from the caller, not generated here**, because an id invented at this layer
78
+ could not be correlated with whoever is waiting on the decision.
79
+
80
+ ### The end-to-end property is asserted, not described
81
+
82
+ A real `createApprovalIndex()` is constructed in the suite and an escalation is raised into it: the request
83
+ appears in `index.all()`, `subjectLevel` survives the mapping as `49`, and after an approver at level 50
84
+ settles it, **the subject level is still 49** — settling must not rewrite it.
85
+
86
+ ### Two of my own errors, both caught by the suite rather than by reading
87
+
88
+ * I asserted `INDEX_NO_SUBJECT_LEVEL`. The value is `APPROVAL_NO_SUBJECT_LEVEL` — **every code in
89
+ `INDEX_REFUSAL` carries the `APPROVAL_` prefix.** The implementation was right and had passed the index's
90
+ own code through faithfully, which is exactly what the test was there to check. The assertion now reads the
91
+ value **out of the table** so it cannot drift again.
92
+ * The generator script's escape handling produced a stray `$` inside a template literal in a *failure message*,
93
+ which threw at runtime rather than at parse time. Caught by running the suite, not by `node --check`.
94
+
95
+ ### Still not wired to a live queue
96
+
97
+ `createApprovalIndex` has **no caller in the plugin** — it was built in 0.10.0 and is exported from
98
+ `approval-index.mjs` alone. This release makes the gate and the index meet **on a real object**, with an
99
+ end-to-end test, but no dispatch path yet constructs a queue. So `W2M_PERMISSION_NEEDS_APPROVAL` still
100
+ refuses rather than queuing, and it still says so.
101
+
102
+ **6 new tests, 0 failures.**
103
+
7
104
  ## [0.23.0] — 2026-10-15
8
105
 
9
106
  **The gate is on the dispatch path. "分权限" now executes, and a machine that has not opted in behaves exactly as before.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "0.23.0",
3
+ "version": "0.25.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",
@@ -255,6 +255,82 @@ export function approvalRequestFor(decision, context = {}) {
255
255
  };
256
256
  }
257
257
 
258
+ /**
259
+ * Turn an approval request into the entry the approval index accepts, and register it.
260
+ *
261
+ * ## Why this adapter exists rather than a direct call
262
+ *
263
+ * The two shapes are close and **not identical**, which is precisely where a defect would live:
264
+ *
265
+ * | `approvalRequestFor` produces | `index.register` wants |
266
+ * |---|---|
267
+ * | `subject_level` (flat) | `subject: { id, level }` (nested) |
268
+ * | `capability` | `action` |
269
+ * | `task_id` / `requested_by` | `sessionKey` / `subject.id` |
270
+ *
271
+ * A caller hand-mapping these at the call site would get it right once and wrong later, and the failure would
272
+ * be a request registered against the wrong subject — the one field the whole decision rests on. So the
273
+ * mapping lives here, beside the thing it maps from.
274
+ *
275
+ * ## What it refuses, and why it does not smooth anything over
276
+ *
277
+ * `register` refuses a subject level that is not an integer 0..100, and this adapter **passes the index's own
278
+ * `reason_code` and `detail` through unchanged**. A bridge that substituted a default level to make
279
+ * registration succeed would be inventing the single fact the approver's decision rests on, and would look
280
+ * like success while doing it.
281
+ *
282
+ * @param {{register: (entry: object) => {ok: boolean, reason_code?: string, detail?: string}}} index
283
+ * @param {object} request from {@link approvalRequestFor}
284
+ * @param {{approvalId: string, sessionKey?: string|null, toolName?: string|null,
285
+ * deadlineAt?: number|null, now?: number}} context
286
+ * @returns {{ok: boolean, approval_id: string|null, reason_code: string|null, detail: string}}
287
+ */
288
+ export function raiseApproval(index, request, context = {}) {
289
+ if (index === null || typeof index !== 'object' || typeof index.register !== 'function') {
290
+ return { ok: false, approval_id: null, reason_code: 'APPROVAL_NO_INDEX', detail: 'no approval index was supplied, so nothing was registered' };
291
+ }
292
+ if (request === null || typeof request !== 'object') {
293
+ return { ok: false, approval_id: null, reason_code: 'APPROVAL_NO_REQUEST', detail: 'no approval request was supplied, so nothing was registered' };
294
+ }
295
+ const approvalId = context.approvalId;
296
+ if (typeof approvalId !== 'string' || approvalId === '') {
297
+ // Required from the caller rather than generated here: an id invented at this layer could not be
298
+ // correlated with whoever is waiting for the decision.
299
+ return {
300
+ ok: false,
301
+ approval_id: null,
302
+ reason_code: 'APPROVAL_NO_ID',
303
+ detail: 'raiseApproval needs a caller-supplied approvalId, so the request can be correlated with whoever waits on it',
304
+ };
305
+ }
306
+
307
+ const result = index.register({
308
+ approvalId,
309
+ sessionKey: context.sessionKey ?? null,
310
+ subject: { id: request.requested_by ?? null, level: request.subject_level },
311
+ action: request.capability,
312
+ toolName: context.toolName ?? null,
313
+ reason: request.detail ?? null,
314
+ ...(Number.isFinite(context.now) ? { openedAt: context.now } : {}),
315
+ deadlineAt: Number.isFinite(context.deadlineAt) ? context.deadlineAt : null,
316
+ });
317
+
318
+ if (result?.ok !== true) {
319
+ return {
320
+ ok: false,
321
+ approval_id: null,
322
+ reason_code: result?.reason_code ?? 'APPROVAL_REFUSED',
323
+ detail: result?.detail ?? 'the approval index refused the request without saying why',
324
+ };
325
+ }
326
+ return {
327
+ ok: true,
328
+ approval_id: approvalId,
329
+ reason_code: null,
330
+ detail: `registered ${approvalId} for ${request.capability} at subject level ${request.subject_level}`,
331
+ };
332
+ }
333
+
258
334
  /**
259
335
  * Whether a decision may be settled automatically.
260
336
  *
@@ -89,7 +89,7 @@
89
89
  */
90
90
 
91
91
  import { execFile } from 'node:child_process';
92
- import { createHash } from 'node:crypto';
92
+ import { createHash, randomUUID } from 'node:crypto';
93
93
  import { promises as fs } from 'node:fs';
94
94
  import os from 'node:os';
95
95
  import path from 'node:path';
@@ -125,7 +125,10 @@ import { mintPunchId, punchSession } from '../agent/p2p.mjs';
125
125
  // v0.22.0: the permission gate. Imported rather than reimplemented for the same reason as everything else
126
126
  // here -- a second copy of the escalation rule is a second chance to disagree with the first about how far
127
127
  // below a requirement an actor may still ask.
128
- import { gateDispatch, approvalRequestFor } from '../agent/permission.mjs';
128
+ import { gateDispatch, approvalRequestFor, raiseApproval } from '../agent/permission.mjs';
129
+ // v0.24.0: the queue an escalation is raised into. Imported from `src/`, never from `scripts/` -- a
130
+ // `src/` module reaching above itself breaks the published package (see CHANGELOG 0.23.0).
131
+ import { createApprovalIndex } from '../agent/approval-index.mjs';
129
132
  import { isAuthorityLevel } from '../agent/authority.mjs';
130
133
  // v0.6.0: the device keypair and the offer's key distribution (`docs/P2P-CRYPTO.md` §4). Imported
131
134
  // rather than reimplemented: the dispatcher and the executor have to derive the *same* keys, and two
@@ -190,7 +193,7 @@ const MAX_STAGES = 16;
190
193
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
191
194
  * overrides it at runtime for tests and packagers.
192
195
  */
193
- const PLUGIN_VERSION = '0.23.0';
196
+ const PLUGIN_VERSION = '0.25.0';
194
197
 
195
198
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
196
199
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -3202,6 +3205,29 @@ export async function apply(ctx, config = {}) {
3202
3205
  return { machine_id: device.machine_id, machine_name: device.machine_name, machineName: cfg.machineName };
3203
3206
  });
3204
3207
 
3208
+ /**
3209
+ * v0.24.0: the approval queue, one per plugin lifetime.
3210
+ *
3211
+ * ## Why it lives here and not inside the dispatch
3212
+ *
3213
+ * A queue constructed at the call site would be **empty every time**, so a request raised by one dispatch
3214
+ * could never be found by the decision that answers it — the queue would look wired and behave as if it were
3215
+ * not. This is the same reasoning as `p2p` above: something that has to outlive a single call belongs to the
3216
+ * plugin, not to the call.
3217
+ *
3218
+ * Lazy, because a machine that never trips the permission gate should not pay for a queue, and built through
3219
+ * the effect system so a decision still pending at unload does not keep the plugin alive.
3220
+ */
3221
+ let approvals = null;
3222
+ const approvalQueue = () => {
3223
+ if (approvals === null) {
3224
+ approvals = createApprovalIndex({
3225
+ audit: (event, detail) => ctx.logger?.debug?.(`w2m: ${event} ${JSON.stringify(detail ?? {})}`),
3226
+ });
3227
+ }
3228
+ return approvals;
3229
+ };
3230
+
3205
3231
  // v0.6.1: the two browser routes behind the composer's target control. Additive and optional: a
3206
3232
  // host without `connection.fetch` still gets every tool, and `w2m_status` reports the absence - // which is the honest place for it, since a host that has no Connection at all is simply not a web
3207
3233
  // composition and does not need a warning on every headless run.
@@ -3725,16 +3751,33 @@ export async function apply(ctx, config = {}) {
3725
3751
  );
3726
3752
  }
3727
3753
  if (gate.decision.verdict === 'needs_approval') {
3754
+ // v0.24.0: the request is QUEUED now, not merely refused with a description of a queue. The
3755
+ // dispatch still does not run -- an escalation is a request to be allowed, not a permission --
3756
+ // but the request is a real entry an approver can find and settle, and its id comes back so the
3757
+ // caller can name what it is waiting on.
3728
3758
  const request = approvalRequestFor(gate.decision, {
3729
3759
  taskId: null,
3730
3760
  machineId: null,
3731
3761
  requestedBy: cfg.machineName,
3732
3762
  });
3763
+ const approvalId = randomUUID();
3764
+ const raised = raiseApproval(approvalQueue(), request, { approvalId, toolName: 'w2m_run' });
3765
+ if (!raised.ok) {
3766
+ // The queue refused it, which is a different failure from needing approval and must not be
3767
+ // reported as one: a caller told "await approval" for a request that was never stored would wait
3768
+ // forever for a decision nobody can make.
3769
+ throw new W2MError(
3770
+ 'W2M_PERMISSION_APPROVAL_NOT_RAISED',
3771
+ 'W2M_PERMISSION_APPROVAL_NOT_RAISED: ' + raised.reason_code + ': ' + raised.detail,
3772
+ { hint: 'the approval request could not be queued, so this dispatch is refused rather than left waiting', request },
3773
+ );
3774
+ }
3733
3775
  throw new W2MError(
3734
3776
  'W2M_PERMISSION_NEEDS_APPROVAL',
3735
3777
  'W2M_PERMISSION_NEEDS_APPROVAL: ' + gate.decision.detail,
3736
3778
  {
3737
- hint: 'this dispatch needs an approver; it is refused rather than queued because no approval queue is wired to this call yet',
3779
+ hint: 'this dispatch is queued awaiting an approver; approve or reject it with w2m_approvals, then dispatch again',
3780
+ approval_id: raised.approval_id,
3738
3781
  request,
3739
3782
  },
3740
3783
  );