@twinsearth/w2m-dsh-plugin 0.5.9 → 0.6.1

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.
@@ -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}}|{ok: false, error: string}}
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 { ok: false, error: 'body must be a JSON object' };
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 { ok: false, error: '`session_id` must be a non-empty string' };
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 { ok: false, error: '`machine_id` must be a string or null' };
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 { ok: false, error: '`machine_name` must be a string or null' };
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 Response.json({ ok: false, error: '`session_id` query parameter is required' }, { status: 400 });
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 Response.json({ ok: false, error: 'body is not JSON' }, { status: 400 });
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) return Response.json({ ok: false, error: parsed.error }, { status: 400 });
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 Response.json(
646
- { ok: false, error: `the fleet could not be read, so no target was changed: ${fleet.error ?? 'unknown cause'}` },
647
- { status: 503 },
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
- if (machine !== null && machine.online !== true) {
652
- return Response.json(
653
- { ok: false, error: `${machine.name ?? machineId} is listed as ${machine.state}, so it cannot be selected` },
654
- { status: 409 },
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, {
@@ -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.5.9';
180
+ const PLUGIN_VERSION = '0.6.1';
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 socket is `unref()`ed, so it
1814
- * cannot keep the process alive, and refusing to start the direct path because the host cannot track
1815
- * a disposer would break the release's own default.
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 entries = prefixes.map((prefix) => [normalizeExecToken(prefix[0]), ...prefix.slice(1)]);
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,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
- return;
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
- return;
513
+ } else {
514
+ this.operatorToken = randomBytes(32).toString('hex');
515
+ this.operatorTokenSource = 'generated';
516
+ this.persistence.writeOperatorToken(this.operatorToken);
482
517
  }
483
- this.operatorToken = randomBytes(32).toString('hex');
484
- this.operatorTokenSource = 'generated';
485
- this.persistence.writeOperatorToken(this.operatorToken);
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
- m.push(`[w2m-rabbit] operator token (${this.operatorTokenSource}) - required for POST /v1/task:`);
521
- m.push(`[w2m-rabbit] ${this.operatorToken}`);
522
- if (this.persistence.operatorTokenFile) {
523
- m.push(`[w2m-rabbit] stored at ${this.persistence.operatorTokenFile} (0600)`);
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
  });