@twinsearth/w2m-dsh-plugin 0.5.9 → 0.6.2
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 +379 -3
- package/PROTOCOL-v0.4.0.md +11 -3
- package/PROTOCOL.md +69 -15
- package/README.md +197 -27
- package/SECURITY.md +69 -19
- package/TROUBLESHOOTING.md +129 -53
- package/bin/w2m-localside.mjs +5 -0
- package/bin/w2m-rabbit.mjs +32 -5
- package/deploy/networking/LESSONS.md +18 -1
- package/deploy/networking/README.md +32 -7
- package/deploy/windows/README.md +20 -1
- package/deploy/windows/install-service.ps1 +98 -19
- package/deploy/windows/verify-supervisor.mjs +794 -0
- package/deploy/windows/w2m-agent-supervisor.mjs +867 -40
- package/docs/AUDIT.md +10 -1
- package/docs/DEPLOY.md +16 -3
- package/docs/P2P-CRYPTO.md +105 -4
- package/docs/PIPELINE-DESIGN.md +35 -10
- package/docs/TROUBLESHOOTING.md +22 -3
- package/package.json +5 -5
- package/src/agent/agent.mjs +242 -23
- package/src/agent/allowed-commands.mjs +480 -18
- package/src/agent/crypto.mjs +252 -40
- package/src/agent/p2p-node.mjs +136 -7
- package/src/agent/p2p.mjs +342 -37
- package/src/agent/resolve.mjs +68 -3
- package/src/plugin/fleet-target.mjs +160 -15
- package/src/plugin/tools.mjs +27 -29
- package/src/relay/persistence.mjs +72 -2
- package/src/relay/server.mjs +84 -11
- package/src/relay/state.mjs +9 -2
|
@@ -34,6 +34,12 @@
|
|
|
34
34
|
* GET /api/w2m/target?session_id=… the fleet and this session's selection, as one snapshot
|
|
35
35
|
* POST /api/w2m/target { session_id, machine_id|null } → the same snapshot
|
|
36
36
|
*
|
|
37
|
+
* A refusal is `{ok: false, error, reason_code, detail}` with a `{@link TARGET_REFUSAL}` code and a
|
|
38
|
+
* `detail` that says what would change it; a `PUT`/`DELETE`/`PATCH` is a `405` rather than a write
|
|
39
|
+
* (only exactly `GET` and `POST` are answered), a machine the relay does not list is a `409` exactly
|
|
40
|
+
* like one it lists as offline, and `session_id` is capped at {@link MAX_SESSION_ID_LENGTH} characters
|
|
41
|
+
* on both verbs because the store keys on the whole string.
|
|
42
|
+
*
|
|
37
43
|
* Plain `Response.json`, no Typert descriptor: this package is dependency-free, and a generated
|
|
38
44
|
* strict Remote contribution is neither reachable from plain JavaScript nor worth a codec for one
|
|
39
45
|
* object. Nothing else in the protocol changes — no new relay endpoint, no new tool parameter, no
|
|
@@ -76,6 +82,49 @@ export const NO_SESSION = '(no-session)';
|
|
|
76
82
|
/** Session buckets retained before the oldest is evicted; a browse session cannot grow this. */
|
|
77
83
|
export const MAX_SELECTIONS = 512;
|
|
78
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Longest `session_id` this route accepts, in characters (UTF-16 code units).
|
|
87
|
+
*
|
|
88
|
+
* `MAX_SELECTIONS` bounds how many buckets exist; it says nothing about how large one key is, and the
|
|
89
|
+
* store keeps the whole string. A caller that sends a 200,000-character id therefore allocates as much
|
|
90
|
+
* as it likes per request and gets it echoed back in the snapshot — the count cap bounds the *number*
|
|
91
|
+
* of sessions, never the *bytes*. 200 is a bound with room to spare: a DSH session id is a UUID (36
|
|
92
|
+
* characters), and 200 leaves room for a ULID, a path-shaped id or a test's own naming without
|
|
93
|
+
* inviting an unbounded key.
|
|
94
|
+
*/
|
|
95
|
+
export const MAX_SESSION_ID_LENGTH = 200;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The `/api/w2m/target` route's refusals, in the house `{reason_code, detail}` shape.
|
|
99
|
+
*
|
|
100
|
+
* Deliberately a map of its own rather than a widening of `tools.mjs`'s `REFUSAL_CODE`: those codes name
|
|
101
|
+
* refusals on the *dispatch* path (which the eight tools report), while these name refusals at the
|
|
102
|
+
* route the browser calls, and `fleet-target.mjs` is imported *by* `tools.mjs` — importing back would
|
|
103
|
+
* be a cycle. `reason_code` is the greppable, stable identifier and `detail` says what would change it.
|
|
104
|
+
*/
|
|
105
|
+
export const TARGET_REFUSAL = Object.freeze({
|
|
106
|
+
/** A verb this route does not answer. Only exactly `GET` and `POST` are read or written. */
|
|
107
|
+
METHOD_NOT_ALLOWED: 'TARGET_METHOD_NOT_ALLOWED',
|
|
108
|
+
/** `GET` carried no `session_id`, so there is no session to answer for. */
|
|
109
|
+
SESSION_ID_REQUIRED: 'TARGET_SESSION_ID_REQUIRED',
|
|
110
|
+
/** `session_id` is longer than {@link MAX_SESSION_ID_LENGTH}. */
|
|
111
|
+
SESSION_ID_TOO_LONG: 'TARGET_SESSION_ID_TOO_LONG',
|
|
112
|
+
/** A `POST` body that could not be parsed as JSON at all. */
|
|
113
|
+
BODY_NOT_JSON: 'TARGET_BODY_NOT_JSON',
|
|
114
|
+
/** A `POST` body that is not a JSON object. */
|
|
115
|
+
BODY_NOT_AN_OBJECT: 'TARGET_BODY_NOT_AN_OBJECT',
|
|
116
|
+
/** `machine_id` was neither a string nor null. */
|
|
117
|
+
MACHINE_ID_NOT_A_STRING: 'TARGET_MACHINE_ID_NOT_A_STRING',
|
|
118
|
+
/** `machine_name` was neither a string nor null. */
|
|
119
|
+
MACHINE_NAME_NOT_A_STRING: 'TARGET_MACHINE_NAME_NOT_A_STRING',
|
|
120
|
+
/** The named machine is not one the relay lists at all. */
|
|
121
|
+
MACHINE_UNKNOWN: 'TARGET_MACHINE_UNKNOWN',
|
|
122
|
+
/** The named machine exists, and the relay does not call it online. */
|
|
123
|
+
MACHINE_OFFLINE: 'TARGET_MACHINE_OFFLINE',
|
|
124
|
+
/** The fleet could not be read, so no selection could be checked against it. */
|
|
125
|
+
FLEET_UNAVAILABLE: 'TARGET_FLEET_UNAVAILABLE',
|
|
126
|
+
});
|
|
127
|
+
|
|
79
128
|
/** How long the fleet roster may be reused by the control before it is asked for again. */
|
|
80
129
|
export const FLEET_CACHE_MS = 10_000;
|
|
81
130
|
|
|
@@ -415,25 +464,55 @@ export function buildUiState(deps) {
|
|
|
415
464
|
/**
|
|
416
465
|
* Parse the JSON body of a selection write, refusing anything outside the declared shape.
|
|
417
466
|
*
|
|
467
|
+
* Every refusal is a *value with a name*: `reason_code` is stable and greppable, and `detail` says what
|
|
468
|
+
* would have to change for the same request to be accepted. The route turns both into the response body
|
|
469
|
+
* verbatim, so the browser can show the reason without re-deriving it.
|
|
470
|
+
*
|
|
418
471
|
* @param {unknown} body
|
|
419
|
-
* @returns {{ok: true, value: {session_id: string, machine_id: string|null, machine_name: string|null}}
|
|
472
|
+
* @returns {{ok: true, value: {session_id: string, machine_id: string|null, machine_name: string|null}}
|
|
473
|
+
* |{ok: false, error: string, reason_code: string, detail: string}}
|
|
420
474
|
*/
|
|
421
475
|
export function parseSelectionWrite(body) {
|
|
476
|
+
const refuse = (error, reason_code, detail) => ({ ok: false, error, reason_code, detail });
|
|
422
477
|
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
|
|
423
|
-
return
|
|
478
|
+
return refuse(
|
|
479
|
+
'body must be a JSON object',
|
|
480
|
+
TARGET_REFUSAL.BODY_NOT_AN_OBJECT,
|
|
481
|
+
'send `{"session_id": "<id>", "machine_id": "<id>|null"}`',
|
|
482
|
+
);
|
|
424
483
|
}
|
|
425
484
|
const record = /** @type {Record<string, unknown>} */ (body);
|
|
426
485
|
const sessionId = record.session_id;
|
|
427
486
|
if (typeof sessionId !== 'string' || sessionId === '') {
|
|
428
|
-
return
|
|
487
|
+
return refuse(
|
|
488
|
+
'`session_id` must be a non-empty string',
|
|
489
|
+
TARGET_REFUSAL.SESSION_ID_REQUIRED,
|
|
490
|
+
'send the id of the session this selection belongs to',
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
if (sessionId.length > MAX_SESSION_ID_LENGTH) {
|
|
494
|
+
return refuse(
|
|
495
|
+
`\`session_id\` must be at most ${MAX_SESSION_ID_LENGTH} characters, got ${sessionId.length}`,
|
|
496
|
+
TARGET_REFUSAL.SESSION_ID_TOO_LONG,
|
|
497
|
+
`send the id the host gave this session (a UUID is 36 characters); anything longer than `
|
|
498
|
+
+ `${MAX_SESSION_ID_LENGTH} characters is not a session id`,
|
|
499
|
+
);
|
|
429
500
|
}
|
|
430
501
|
const machineId = record.machine_id;
|
|
431
502
|
if (machineId !== null && machineId !== undefined && typeof machineId !== 'string') {
|
|
432
|
-
return
|
|
503
|
+
return refuse(
|
|
504
|
+
'`machine_id` must be a string or null',
|
|
505
|
+
TARGET_REFUSAL.MACHINE_ID_NOT_A_STRING,
|
|
506
|
+
'send a `machine_id` from the roster this route reports, or null to clear the selection',
|
|
507
|
+
);
|
|
433
508
|
}
|
|
434
509
|
const machineName = record.machine_name;
|
|
435
510
|
if (machineName !== null && machineName !== undefined && typeof machineName !== 'string') {
|
|
436
|
-
return
|
|
511
|
+
return refuse(
|
|
512
|
+
'`machine_name` must be a string or null',
|
|
513
|
+
TARGET_REFUSAL.MACHINE_NAME_NOT_A_STRING,
|
|
514
|
+
'send the machine\'s display name as a string, or leave it out and the relay\'s name is used',
|
|
515
|
+
);
|
|
437
516
|
}
|
|
438
517
|
return {
|
|
439
518
|
ok: true,
|
|
@@ -603,11 +682,51 @@ function mountTargetRoutes(connection, deps, status, logger) {
|
|
|
603
682
|
fetch: async (request) => {
|
|
604
683
|
const url = new URL(request.url);
|
|
605
684
|
const noStore = { 'cache-control': 'no-store' };
|
|
685
|
+
/**
|
|
686
|
+
* One refusal shape for the whole route: `{ok:false, error, reason_code, detail}`.
|
|
687
|
+
*
|
|
688
|
+
* @param {number} status
|
|
689
|
+
* @param {string} reasonCode A {@link TARGET_REFUSAL} value.
|
|
690
|
+
* @param {string} error What happened, in the host's own words (the control prints this).
|
|
691
|
+
* @param {string} detail What would change it.
|
|
692
|
+
* @returns {Response}
|
|
693
|
+
*/
|
|
694
|
+
const refuse = (status, reasonCode, error, detail) =>
|
|
695
|
+
Response.json({ ok: false, error, reason_code: reasonCode, detail }, { status, headers: noStore });
|
|
696
|
+
|
|
697
|
+
// Defence in depth, and the one place it is cheap. The registration above declares
|
|
698
|
+
// `methods: ['GET', 'POST']`, so the host should never route a `PUT`/`DELETE`/`PATCH` here at all
|
|
699
|
+
// — but the handler used to treat *anything* that was not a GET as a write, which means a
|
|
700
|
+
// registration mistake, a host that forwards other verbs, or a future `methods` change would
|
|
701
|
+
// silently turn `DELETE /api/w2m/target` into "clear this selection". A route that writes must
|
|
702
|
+
// say which verbs write; everything else is refused by name rather than guessed at.
|
|
703
|
+
if (request.method !== 'GET' && request.method !== 'POST') {
|
|
704
|
+
return refuse(
|
|
705
|
+
405,
|
|
706
|
+
TARGET_REFUSAL.METHOD_NOT_ALLOWED,
|
|
707
|
+
`${request.method} is not a method this route answers`,
|
|
708
|
+
'GET reads the fleet and this session\'s selection and POST writes one; any other verb is refused '
|
|
709
|
+
+ 'rather than treated as a write',
|
|
710
|
+
);
|
|
711
|
+
}
|
|
606
712
|
|
|
607
713
|
if (request.method === 'GET') {
|
|
608
714
|
const sessionId = url.searchParams.get('session_id');
|
|
609
715
|
if (typeof sessionId !== 'string' || sessionId === '') {
|
|
610
|
-
return
|
|
716
|
+
return refuse(
|
|
717
|
+
400,
|
|
718
|
+
TARGET_REFUSAL.SESSION_ID_REQUIRED,
|
|
719
|
+
'`session_id` query parameter is required',
|
|
720
|
+
'add `?session_id=<the session the control belongs to>`',
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
if (sessionId.length > MAX_SESSION_ID_LENGTH) {
|
|
724
|
+
return refuse(
|
|
725
|
+
400,
|
|
726
|
+
TARGET_REFUSAL.SESSION_ID_TOO_LONG,
|
|
727
|
+
`\`session_id\` must be at most ${MAX_SESSION_ID_LENGTH} characters, got ${sessionId.length}`,
|
|
728
|
+
`send the id the host gave this session (a UUID is 36 characters)`,
|
|
729
|
+
);
|
|
611
730
|
}
|
|
612
731
|
try {
|
|
613
732
|
return Response.json(await snapshot(sessionId, request.signal), { headers: noStore });
|
|
@@ -623,10 +742,17 @@ function mountTargetRoutes(connection, deps, status, logger) {
|
|
|
623
742
|
try {
|
|
624
743
|
body = await request.json();
|
|
625
744
|
} catch {
|
|
626
|
-
return
|
|
745
|
+
return refuse(
|
|
746
|
+
400,
|
|
747
|
+
TARGET_REFUSAL.BODY_NOT_JSON,
|
|
748
|
+
'body is not JSON',
|
|
749
|
+
'send `{"session_id": "<id>", "machine_id": "<id>|null"}` as a JSON object',
|
|
750
|
+
);
|
|
627
751
|
}
|
|
628
752
|
const parsed = parseSelectionWrite(body);
|
|
629
|
-
if (!parsed.ok)
|
|
753
|
+
if (!parsed.ok) {
|
|
754
|
+
return refuse(400, parsed.reason_code, parsed.error, parsed.detail);
|
|
755
|
+
}
|
|
630
756
|
const { session_id: sessionId, machine_id: machineId, machine_name: machineName } = parsed.value;
|
|
631
757
|
|
|
632
758
|
if (machineId === null) {
|
|
@@ -642,16 +768,35 @@ function mountTargetRoutes(connection, deps, status, logger) {
|
|
|
642
768
|
const cfg = deps.cfg();
|
|
643
769
|
const fleet = await fleetFor(request.signal);
|
|
644
770
|
if (fleet.ok !== true) {
|
|
645
|
-
return
|
|
646
|
-
|
|
647
|
-
|
|
771
|
+
return refuse(
|
|
772
|
+
503,
|
|
773
|
+
TARGET_REFUSAL.FLEET_UNAVAILABLE,
|
|
774
|
+
`the fleet could not be read, so no target was changed: ${fleet.error ?? 'unknown cause'}`,
|
|
775
|
+
'bring the relay back (or fix `rabbitUrl`) and choose again; nothing was selected in the meantime',
|
|
648
776
|
);
|
|
649
777
|
}
|
|
650
778
|
const machine = fleet.machines.find((entry) => entry.machine_id === machineId) ?? null;
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
779
|
+
// A machine the relay does not list *at all* is refused in exactly the same breath as one it lists
|
|
780
|
+
// as offline. The comment above always claimed this and the code did not do it: `machine === null`
|
|
781
|
+
// fell through to `store.set`, so `{"machine_id":"NOT-A-MACHINE"}` was accepted with a 200 and a
|
|
782
|
+
// selection the fleet could never honour. Dispatch re-validates, which is what bounded the impact
|
|
783
|
+
// — but a control whose whole promise is "you cannot select what the fleet cannot run" has to
|
|
784
|
+
// refuse the id it has never heard of too.
|
|
785
|
+
if (machine === null) {
|
|
786
|
+
return refuse(
|
|
787
|
+
409,
|
|
788
|
+
TARGET_REFUSAL.MACHINE_UNKNOWN,
|
|
789
|
+
`${machineId} is not a machine this relay lists`,
|
|
790
|
+
'pick a machine from the roster this route reports; if it has just paired, reload the list and '
|
|
791
|
+
+ 'choose it again',
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
if (machine.online !== true) {
|
|
795
|
+
return refuse(
|
|
796
|
+
409,
|
|
797
|
+
TARGET_REFUSAL.MACHINE_OFFLINE,
|
|
798
|
+
`${machine.name ?? machineId} is listed as ${machine.state}, so it cannot be selected`,
|
|
799
|
+
'the machine must be online: wait for its next heartbeat, or select another machine',
|
|
655
800
|
);
|
|
656
801
|
}
|
|
657
802
|
deps.store.set(sessionId, {
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -177,7 +177,7 @@ const MAX_STAGES = 16;
|
|
|
177
177
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
178
178
|
* overrides it at runtime for tests and packagers.
|
|
179
179
|
*/
|
|
180
|
-
const PLUGIN_VERSION = '0.
|
|
180
|
+
const PLUGIN_VERSION = '0.6.2';
|
|
181
181
|
|
|
182
182
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
183
183
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -1810,9 +1810,15 @@ async function initP2P(ctx, cfg, runtime, block) {
|
|
|
1810
1810
|
* Registered *here*, after the node exists, rather than in `createP2PRuntime` — an effect that owns
|
|
1811
1811
|
* nothing is a disposer that releases nothing, and `relay` mode and a machine with no identity must
|
|
1812
1812
|
* leave the host's effect list exactly as they found it. A host that provides no `ctx.effect` (an
|
|
1813
|
-
* older Cordis, or a bare test context) still gets a working node: the
|
|
1814
|
-
*
|
|
1815
|
-
* a disposer would break the release's own
|
|
1813
|
+
* older Cordis, or a bare test context) still gets a working node: the node itself is asked not to
|
|
1814
|
+
* hold the process open (`node.unref()`, below), so it cannot keep the process alive, and refusing
|
|
1815
|
+
* to start the direct path because the host cannot track a disposer would break the release's own
|
|
1816
|
+
* default.
|
|
1817
|
+
*
|
|
1818
|
+
* v0.5.10: `node.unref()` is a real call, not a description. Until it was added this comment
|
|
1819
|
+
* claimed an `unref()` that existed nowhere in the plugin or the node — the socket went on holding
|
|
1820
|
+
* the event loop, so a host without `ctx.effect` that started a node never exited (found by an
|
|
1821
|
+
* independent verifier against the `w2m_status` test's bare context).
|
|
1816
1822
|
*/
|
|
1817
1823
|
if (typeof ctx?.effect === 'function') {
|
|
1818
1824
|
ctx.effect(() => () => {
|
|
@@ -1824,6 +1830,11 @@ async function initP2P(ctx, cfg, runtime, block) {
|
|
|
1824
1830
|
runtime.events.last_error = error instanceof Error ? error.message : String(error);
|
|
1825
1831
|
});
|
|
1826
1832
|
});
|
|
1833
|
+
} else {
|
|
1834
|
+
// No disposer will ever be called by the host, so the node must not be the reason the process
|
|
1835
|
+
// stays up. Called before `start()`: the node remembers it and unrefs the socket it binds, which
|
|
1836
|
+
// is the only ordering that leaves no window where the loop is held open.
|
|
1837
|
+
runtime.node?.unref?.();
|
|
1827
1838
|
}
|
|
1828
1839
|
|
|
1829
1840
|
// eslint-disable-next-line require-atomic-updates -- single-entry: `ensureStarted` assigns `starting` before this runs
|
|
@@ -2700,27 +2711,6 @@ async function resolveTreeFingerprint(projectDir, timeoutMs) {
|
|
|
2700
2711
|
// Command policy
|
|
2701
2712
|
// ---------------------------------------------------------------------------------------------
|
|
2702
2713
|
|
|
2703
|
-
/**
|
|
2704
|
-
* The comparable form of an **executable token**: basename, lower case, no `.exe`/`.cmd`/`.bat`/`.com`.
|
|
2705
|
-
*
|
|
2706
|
-
* Applied to `argv[0]` and to the first token of an entry, never to the arguments after it --
|
|
2707
|
-
* `--reporter=TAP` and `--reporter=tap` are different arguments, and collapsing them would authorise
|
|
2708
|
-
* something nobody wrote.
|
|
2709
|
-
*
|
|
2710
|
-
* This is the plugin's long-documented tolerance (`allowedCommands: ['Node.exe']` accepts
|
|
2711
|
-
* `C:\Program Files\nodejs\node.exe`), and it is deliberately platform-independent: the old check
|
|
2712
|
-
* normalised the separator itself rather than asking `path`, which is what makes the documented
|
|
2713
|
-
* entry work the same on a POSIX dispatcher. Everything *after* the executable token is compared
|
|
2714
|
-
* verbatim by the shared matcher, which is where the prefix rule lives.
|
|
2715
|
-
*
|
|
2716
|
-
* @param {string} value
|
|
2717
|
-
* @returns {string}
|
|
2718
|
-
*/
|
|
2719
|
-
function normalizeExecToken(value) {
|
|
2720
|
-
const base = path.basename(String(value).trim().replace(/\\/g, '/')).toLowerCase();
|
|
2721
|
-
return base.replace(/\.(exe|cmd|bat|com)$/, '');
|
|
2722
|
-
}
|
|
2723
|
-
|
|
2724
2714
|
/**
|
|
2725
2715
|
* Check one argv against the configured allow-list, with the **same rule the agent applies**.
|
|
2726
2716
|
*
|
|
@@ -2736,6 +2726,15 @@ function normalizeExecToken(value) {
|
|
|
2736
2726
|
* documented config `['node --test','git status --porcelain']` refused `['git','rev-parse','HEAD']`
|
|
2737
2727
|
* -- stricter than the gate, which is the direction that silently loses work.
|
|
2738
2728
|
*
|
|
2729
|
+
* The argv is handed over **verbatim**, including `argv[0]`. It used to be reduced to a basename
|
|
2730
|
+
* first, and that was the plugin half of a defect: the matcher decides executable identity by
|
|
2731
|
+
* resolving a path, and a pre-flight that strips the path can only ever compare names, so it would
|
|
2732
|
+
* wave `E:\Temp\evil\node.exe` through while the agent refused it. Passing the argv through
|
|
2733
|
+
* unchanged is what keeps the two ends one rule. What the shared matcher then resolves, it resolves
|
|
2734
|
+
* against **this** process's environment — the dispatcher's PATH — which is a real limit of a
|
|
2735
|
+
* pre-flight (the executing machine's PATH is the one that will decide), and the reason the refusal
|
|
2736
|
+
* that matters is the agent's own.
|
|
2737
|
+
*
|
|
2739
2738
|
* A refusal names itself: `reason_code` is the matcher's own code (so the two ends cannot report one
|
|
2740
2739
|
* refusal under two spellings) and `detail` says what would change it. The two ways this control
|
|
2741
2740
|
* refuses are kept apart — a command outside the list and a list that cannot be parsed have
|
|
@@ -2766,15 +2765,14 @@ function checkAllowedCommand(allowedCommands, argv) {
|
|
|
2766
2765
|
}
|
|
2767
2766
|
// Prefixes "as written": the configured entries, whitespace-normalised, in a stable order.
|
|
2768
2767
|
const allowed = prefixes.map((prefix) => prefix.join(' ')).sort();
|
|
2769
|
-
const
|
|
2770
|
-
const command = [normalizeExecToken(argv[0]), ...argv.slice(1)];
|
|
2771
|
-
const match = matchAllowedCommand(command, entries);
|
|
2768
|
+
const match = matchAllowedCommand(argv, prefixes);
|
|
2772
2769
|
if (match.allowed) return null;
|
|
2773
2770
|
return {
|
|
2774
2771
|
allowed,
|
|
2775
2772
|
// The shared matcher's code, not a second copy of it: `COMMAND_NOT_ALLOWED` is the literal the
|
|
2776
2773
|
// agent writes into its own envelope, so a machine's refusal and this pre-flight's refusal read
|
|
2777
|
-
// the same to whoever has to act on them.
|
|
2774
|
+
// the same to whoever has to act on them. The matcher's three newer codes travel the same way
|
|
2775
|
+
// for the same reason.
|
|
2778
2776
|
reason_code: match.reason_code ?? REFUSAL_CODE.COMMAND_NOT_ALLOWED,
|
|
2779
2777
|
message: `\`${argv.join(' ')}\` is not in this plugin's allowedCommands (${allowed.join(', ')})`,
|
|
2780
2778
|
detail:
|
|
@@ -36,6 +36,15 @@ export class Persistence {
|
|
|
36
36
|
this._now = now;
|
|
37
37
|
/** @type {{file:string, message:string, line?:number}[]} */
|
|
38
38
|
this.warnings = [];
|
|
39
|
+
/**
|
|
40
|
+
* Durability state (v0.6.2): whether the ledger is actually being written.
|
|
41
|
+
*
|
|
42
|
+
* `appendFailures` is cumulative and never reset, so "how much was lost" stays answerable after a
|
|
43
|
+
* disk fills and is freed again. `degradedSinceMs` is the live condition.
|
|
44
|
+
*/
|
|
45
|
+
this.appendFailures = 0;
|
|
46
|
+
this.lastAppendError = null;
|
|
47
|
+
this.degradedSinceMs = null;
|
|
39
48
|
this.devicesCorrupt = false;
|
|
40
49
|
this.devicesMissing = true;
|
|
41
50
|
this.revivedDevices = 0;
|
|
@@ -50,8 +59,61 @@ export class Persistence {
|
|
|
50
59
|
get operatorTokenFile() { return this.enabled ? path.join(this.dir, OPERATOR_TOKEN_FILE) : null; }
|
|
51
60
|
|
|
52
61
|
warn(file, message, extra = {}) {
|
|
53
|
-
|
|
54
|
-
|
|
62
|
+
// Deduplicated (v0.6.2). A full disk fails *every* append, and appending an identical entry each
|
|
63
|
+
// time buried the signal in its own repetition: this fleet's relay reached 22 byte-identical
|
|
64
|
+
// "ENOSPC" warnings and no way to ask "is this relay still recording?" without counting them. One
|
|
65
|
+
// entry that counts is what an operator can act on.
|
|
66
|
+
const existing = this.warnings.find((entry) => entry.file === file && entry.message === message);
|
|
67
|
+
if (existing !== undefined) {
|
|
68
|
+
existing.count = (existing.count ?? 1) + 1;
|
|
69
|
+
existing.last_at = rfc3339(this._now());
|
|
70
|
+
return existing;
|
|
71
|
+
}
|
|
72
|
+
const entry = { file, message, count: 1, ...extra };
|
|
73
|
+
this.warnings.push(entry);
|
|
74
|
+
return entry;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* One ledger append failed: the relay is still serving, but it is no longer recording (v0.6.2).
|
|
79
|
+
*
|
|
80
|
+
* This is a first-class state rather than a log line because the ledger *is* the relay's durability
|
|
81
|
+
* story. Every machine's result, every lease, everything a later question would be answered from —
|
|
82
|
+
* none of it survives a restart once appends stop landing. A relay in this state accepts work
|
|
83
|
+
* exactly as before, which is precisely why it has to say so out loud.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} message
|
|
86
|
+
* @returns {false}
|
|
87
|
+
*/
|
|
88
|
+
noteAppendFailure(message) {
|
|
89
|
+
this.appendFailures += 1;
|
|
90
|
+
this.lastAppendError = message;
|
|
91
|
+
if (this.degradedSinceMs === null) this.degradedSinceMs = this._now();
|
|
92
|
+
this.warn(LEDGER_FILE, `append failed: ${message}`);
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The ledger is being written again.
|
|
98
|
+
*
|
|
99
|
+
* The history is kept — `append_failures` is not reset — because "how much was lost" is the first
|
|
100
|
+
* question after a disk fills, and a counter that silently returns to zero cannot answer it.
|
|
101
|
+
*
|
|
102
|
+
* @returns {true}
|
|
103
|
+
*/
|
|
104
|
+
noteAppendSuccess() {
|
|
105
|
+
if (this.degradedSinceMs !== null) {
|
|
106
|
+
const forMs = Math.max(0, this._now() - this.degradedSinceMs);
|
|
107
|
+
this.degradedSinceMs = null;
|
|
108
|
+
this.lastAppendError = null;
|
|
109
|
+
this.warn(LEDGER_FILE, `append recovered after ${this.appendFailures} failure(s) over ${forMs}ms`);
|
|
110
|
+
}
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Is the relay serving without recording? @returns {boolean} */
|
|
115
|
+
get degraded() {
|
|
116
|
+
return this.degradedSinceMs !== null;
|
|
55
117
|
}
|
|
56
118
|
|
|
57
119
|
ensureDir() {
|
|
@@ -199,6 +261,14 @@ export class Persistence {
|
|
|
199
261
|
devices_missing: this.devicesMissing,
|
|
200
262
|
ledger_lines_read: this.ledgerLinesRead,
|
|
201
263
|
ledger_lines_skipped: this.ledgerLinesSkipped,
|
|
264
|
+
/**
|
|
265
|
+
* v0.6.2. `degraded: true` means the relay is answering requests while failing to record them,
|
|
266
|
+
* which is the one relay failure that looks exactly like health from the outside.
|
|
267
|
+
*/
|
|
268
|
+
degraded: this.degraded,
|
|
269
|
+
degraded_since: this.degradedSinceMs === null ? null : rfc3339(this.degradedSinceMs),
|
|
270
|
+
last_append_error: this.lastAppendError,
|
|
271
|
+
append_failures: this.appendFailures,
|
|
202
272
|
warnings: this.warnings.map((w) => ({ ...w })),
|
|
203
273
|
};
|
|
204
274
|
}
|
package/src/relay/server.mjs
CHANGED
|
@@ -36,6 +36,32 @@ export const DEFAULT_PAIR_RATE_LIMIT_PER_MINUTE = 5; // v0.1.2 §6
|
|
|
36
36
|
export const RATE_LIMIT_WINDOW_MS = 60_000;
|
|
37
37
|
export const DEFAULT_STATE_DIR = path.join(os.homedir(), '.w2m');
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* The environment variable that opts in to printing the operator token on a start that did not
|
|
41
|
+
* generate it (MEDIUM-6 of the v0.5.9 review).
|
|
42
|
+
*
|
|
43
|
+
* The value is printed **once, on the start that generated it**, because that is the only moment at
|
|
44
|
+
* which it exists nowhere else yet; every other start has `<state>/operator-token.txt` to read it
|
|
45
|
+
* from, and a relay's stdout ends up in journald, a container log or a CI transcript — where a
|
|
46
|
+
* credential that authorises dispatching to the whole fleet does not belong. `W2M_PRINT_OPERATOR_TOKEN=1`
|
|
47
|
+
* (or `printOperatorToken: true`, which is the same switch for an embedder) restores the old
|
|
48
|
+
* behaviour for an operator who genuinely needs it in the log, and says so in the banner when it does.
|
|
49
|
+
*/
|
|
50
|
+
export const PRINT_OPERATOR_TOKEN_ENV = 'W2M_PRINT_OPERATOR_TOKEN';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resolve that opt-in, so the relay and the CLI agree on one answer.
|
|
54
|
+
*
|
|
55
|
+
* @param {object} [options] Relay options; `printOperatorToken: true` is the programmatic spelling.
|
|
56
|
+
* @returns {'option'|'env'|null} Why printing was asked for, or null.
|
|
57
|
+
*/
|
|
58
|
+
export function printOperatorTokenOptIn(options = {}) {
|
|
59
|
+
if (options.printOperatorToken === true) return 'option';
|
|
60
|
+
const raw = process.env[PRINT_OPERATOR_TOKEN_ENV];
|
|
61
|
+
if (raw === '1' || raw === 'true') return 'env';
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
39
65
|
/* ------------------------------------------------------------------ */
|
|
40
66
|
/* helpers */
|
|
41
67
|
/* ------------------------------------------------------------------ */
|
|
@@ -459,11 +485,19 @@ export class RelayServer {
|
|
|
459
485
|
const wantsRequired = options.operatorTokenRequired !== false;
|
|
460
486
|
const explicit = options.operatorToken;
|
|
461
487
|
const persisted = this.persistence.enabled ? this.persistence.readOperatorToken() : null;
|
|
488
|
+
/**
|
|
489
|
+
* Why the token may be printed, when it may be: `'generated'` is the one start whose token exists
|
|
490
|
+
* nowhere else until this process writes it, and `'option'`/`'env'` are the documented opt-ins.
|
|
491
|
+
* @type {'generated'|'option'|'env'|null}
|
|
492
|
+
*/
|
|
493
|
+
this.operatorTokenPrintReason = printOperatorTokenOptIn(options);
|
|
462
494
|
|
|
463
495
|
if (!wantsRequired || explicit === '') {
|
|
464
496
|
this.operatorToken = null;
|
|
465
497
|
this.operatorTokenRequired = false;
|
|
466
498
|
this.operatorTokenSource = 'disabled';
|
|
499
|
+
this.operatorTokenPrintable = false;
|
|
500
|
+
this.operatorTokenPrintReason = null;
|
|
467
501
|
return;
|
|
468
502
|
}
|
|
469
503
|
this.operatorTokenRequired = true;
|
|
@@ -471,18 +505,22 @@ export class RelayServer {
|
|
|
471
505
|
this.operatorToken = explicit;
|
|
472
506
|
this.operatorTokenSource = 'option';
|
|
473
507
|
this.persistence.writeOperatorToken(explicit);
|
|
474
|
-
|
|
475
|
-
}
|
|
476
|
-
if (persisted) {
|
|
508
|
+
} else if (persisted) {
|
|
477
509
|
// §5.1 + §7: keep the token stable across restarts, otherwise every restart
|
|
478
510
|
// would silently invalidate the plugin's configured operatorToken.
|
|
479
511
|
this.operatorToken = persisted;
|
|
480
512
|
this.operatorTokenSource = 'restored';
|
|
481
|
-
|
|
513
|
+
} else {
|
|
514
|
+
this.operatorToken = randomBytes(32).toString('hex');
|
|
515
|
+
this.operatorTokenSource = 'generated';
|
|
516
|
+
this.persistence.writeOperatorToken(this.operatorToken);
|
|
482
517
|
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
518
|
+
// MEDIUM-6: the material is printed only by the start that generated it, or when the operator has
|
|
519
|
+
// explicitly asked for it. "The posture, never the material" still holds everywhere else.
|
|
520
|
+
if (this.operatorTokenSource === 'generated') {
|
|
521
|
+
this.operatorTokenPrintReason = this.operatorTokenPrintReason ?? 'generated';
|
|
522
|
+
}
|
|
523
|
+
this.operatorTokenPrintable = this.operatorTokenPrintReason !== null;
|
|
486
524
|
}
|
|
487
525
|
|
|
488
526
|
_buildStartupMessages() {
|
|
@@ -517,10 +555,30 @@ export class RelayServer {
|
|
|
517
555
|
m.push(`[w2m-rabbit] !! ${w.file}: ${w.message}`);
|
|
518
556
|
}
|
|
519
557
|
if (this.operatorTokenRequired) {
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
558
|
+
// MEDIUM-6: the token is material, not posture, so it is printed as rarely as it can be — on the
|
|
559
|
+
// start that generated it (`operatorTokenSource === 'generated'`) or when `printOperatorToken` /
|
|
560
|
+
// `W2M_PRINT_OPERATOR_TOKEN=1` asked for it — and once, never twice. Where it is stored is said in
|
|
561
|
+
// every case, including the case where there is nowhere to store it.
|
|
562
|
+
//
|
|
563
|
+
// DELIBERATE, do not "fix": on that one start the CLI prints the value a second time, in its own
|
|
564
|
+
// stdout banner. Two sinks for one start is not a leak — the defect was printing it on *every*
|
|
565
|
+
// start, restored ones included — and the box is the documented first-start handoff a human reads
|
|
566
|
+
// (docs/DEPLOY.md §2.4, test/crossnetwork.test.mjs). Collapsing the pair would trade a documented
|
|
567
|
+
// behaviour for one saved line on one start.
|
|
568
|
+
const tokenFile = this.persistence.operatorTokenFile ?? null;
|
|
569
|
+
const stored = tokenFile === null
|
|
570
|
+
? 'persistence is off, so this token exists only in this process and cannot be read back later'
|
|
571
|
+
: `stored at ${tokenFile} (0600)`;
|
|
572
|
+
if (this.operatorTokenPrintable) {
|
|
573
|
+
m.push(`[w2m-rabbit] operator token (${this.operatorTokenSource}) - required for POST /v1/task:`);
|
|
574
|
+
m.push(`[w2m-rabbit] ${this.operatorToken}`);
|
|
575
|
+
m.push(`[w2m-rabbit] printed once because ${this.operatorTokenPrintReason === 'generated'
|
|
576
|
+
? 'this start generated it' : `${this.operatorTokenPrintReason} asked for it (${PRINT_OPERATOR_TOKEN_ENV})`}`
|
|
577
|
+
+ ` - and ${stored}`);
|
|
578
|
+
} else {
|
|
579
|
+
m.push(`[w2m-rabbit] operator token (${this.operatorTokenSource}) - required for POST /v1/task, NOT printed`);
|
|
580
|
+
m.push(`[w2m-rabbit] read it from ${tokenFile ?? '<state>/operator-token.txt'} - ${stored}`);
|
|
581
|
+
m.push(`[w2m-rabbit] set ${PRINT_OPERATOR_TOKEN_ENV}=1 to print it on this start as well`);
|
|
524
582
|
}
|
|
525
583
|
} else {
|
|
526
584
|
m.push('[w2m-rabbit] !! WARNING: OPERATOR TOKEN DISABLED (--operator-token "") - '
|
|
@@ -562,6 +620,11 @@ export class RelayServer {
|
|
|
562
620
|
basePath: this.basePath,
|
|
563
621
|
operatorToken: this.operatorToken,
|
|
564
622
|
operatorTokenRequired: this.operatorTokenRequired,
|
|
623
|
+
// The CLI prints its own banner, so it has to reach the same conclusion this class did rather
|
|
624
|
+
// than re-deriving one from the token's presence (MEDIUM-6).
|
|
625
|
+
operatorTokenSource: this.operatorTokenSource,
|
|
626
|
+
operatorTokenPrintable: this.operatorTokenPrintable === true,
|
|
627
|
+
operatorTokenPrintReason: this.operatorTokenPrintReason ?? null,
|
|
565
628
|
scheme: this.scheme,
|
|
566
629
|
});
|
|
567
630
|
});
|
|
@@ -839,6 +902,16 @@ export class RelayServer {
|
|
|
839
902
|
// so this drops to 0 by itself when nobody is refreshing.
|
|
840
903
|
...this.peers.stats(),
|
|
841
904
|
ok: true,
|
|
905
|
+
/**
|
|
906
|
+
* v0.6.2: hoisted out of `persistence` so it cannot be missed by a reader that only looks at
|
|
907
|
+
* the top level. `degraded: true` means this relay is answering requests while failing to record
|
|
908
|
+
* them -- the one relay failure that presents as health. `ok` deliberately stays true: the relay
|
|
909
|
+
* still serves the fleet, and flipping it would turn a recording problem into an outage.
|
|
910
|
+
*/
|
|
911
|
+
degraded: this.persistence.enabled && this.persistence.degraded,
|
|
912
|
+
degraded_reason: this.persistence.degraded
|
|
913
|
+
? `ledger appends are failing (${this.persistence.lastAppendError ?? 'unknown error'}); results are served but not durably recorded`
|
|
914
|
+
: null,
|
|
842
915
|
protocol_version: PROTOCOL_VERSION,
|
|
843
916
|
rabbit_time: this.state.nowIso(),
|
|
844
917
|
uptime_ms: this.state.nowMs() - this._startedAtMs,
|
package/src/relay/state.mjs
CHANGED
|
@@ -470,9 +470,16 @@ export class RabbitState {
|
|
|
470
470
|
_ledger(entry) {
|
|
471
471
|
if (this._restoring || !this._persist) return false;
|
|
472
472
|
try {
|
|
473
|
-
|
|
473
|
+
const appended = this._persist.append(entry);
|
|
474
|
+
// v0.6.2: a successful append is what clears a degraded relay, so the outcome has to be
|
|
475
|
+
// reported back rather than assumed -- otherwise the state could only ever be entered.
|
|
476
|
+
if (appended) this._persist.noteAppendSuccess?.();
|
|
477
|
+
return appended;
|
|
474
478
|
} catch (err) {
|
|
475
|
-
|
|
479
|
+
// Recorded as a state, not merely as a line: this is the one relay failure that keeps serving,
|
|
480
|
+
// and so the one that is invisible from every other angle (v0.6.2).
|
|
481
|
+
if (typeof this._persist.noteAppendFailure === 'function') this._persist.noteAppendFailure(err.message);
|
|
482
|
+
else this._persist.warn?.('ledger.jsonl', `append failed: ${err.message}`);
|
|
476
483
|
return false;
|
|
477
484
|
}
|
|
478
485
|
}
|