@twinsearth/w2m-dsh-plugin 0.6.3 → 0.8.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +1030 -0
  2. package/README.md +9 -2
  3. package/bin/w2m-localside.mjs +0 -0
  4. package/bin/w2m-rabbit.mjs +6 -0
  5. package/bin/w2m-stun.mjs +0 -0
  6. package/deploy/server/README.md +109 -0
  7. package/deploy/server/deploy-w2m.sh +161 -0
  8. package/deploy/server/w2m-localside.service +31 -0
  9. package/deploy/server/w2m-rabbit.service +26 -0
  10. package/deploy/server/w2m-stun.service +26 -0
  11. package/docs/ACCOUNT-FLEET-DESIGN.md +369 -0
  12. package/package.json +33 -22
  13. package/src/chatops/account.mjs +361 -0
  14. package/src/chatops/actions.mjs +227 -0
  15. package/src/chatops/approval-broker.mjs +320 -0
  16. package/src/chatops/auth.mjs +376 -0
  17. package/src/chatops/bridge.mjs +344 -0
  18. package/src/chatops/channel.mjs +192 -0
  19. package/src/chatops/commands.mjs +409 -0
  20. package/src/chatops/config-route.mjs +332 -0
  21. package/src/chatops/device-signing.mjs +218 -0
  22. package/src/chatops/durability.mjs +202 -0
  23. package/src/chatops/file-safety.mjs +282 -0
  24. package/src/chatops/fleet-scope.mjs +176 -0
  25. package/src/chatops/fleet-wiring.mjs +199 -0
  26. package/src/chatops/fleet.mjs +272 -0
  27. package/src/chatops/handoff.mjs +258 -0
  28. package/src/chatops/ilink-protocol.mjs +165 -0
  29. package/src/chatops/ilink-transport.mjs +168 -0
  30. package/src/chatops/ilink.mjs +423 -0
  31. package/src/chatops/index.mjs +429 -0
  32. package/src/chatops/outbound-queue.mjs +282 -0
  33. package/src/chatops/policy.mjs +356 -0
  34. package/src/chatops/run-command.mjs +308 -0
  35. package/src/plugin/tools.mjs +1 -1
  36. package/src/relay/server.mjs +96 -0
  37. package/src/relay/state.mjs +2233 -2011
@@ -0,0 +1,308 @@
1
+ /**
2
+ * `/run` — dispatch one command from a chat window to the fleet (v0.7.12).
3
+ *
4
+ * ## Why an explicit prefix, and not "every message is a command"
5
+ *
6
+ * The alternative is treating any incoming message as a command, which makes a typo a fleet-wide
7
+ * execution and makes ordinary conversation dangerous. A prefix means **sending a message is never
8
+ * an action**; acting requires saying so. That is the same reason the other commands here are
9
+ * prefixed, and it is the property most of these tests are about.
10
+ *
11
+ * ## What this module is, and what it is not
12
+ *
13
+ * It **parses** a `/run` line and **renders** a verdict. It does not talk to the relay — that is
14
+ * `fleet.mjs`, whose executor is injected as `deps.run`. Keeping the two apart is what makes the
15
+ * parsing and the rendering testable without a live fleet, and it is where the mistakes in this
16
+ * kind of feature actually live: quoting, refusing empty commands, deciding what a refusal reads
17
+ * like when a human is holding a phone.
18
+ *
19
+ * ## The argv question, which is the real design decision
20
+ *
21
+ * `/run node --version` has to become `['node', '--version']`. Splitting on whitespace is wrong the
22
+ * moment a path has a space, and invoking a shell is wrong always — the fleet's units take an
23
+ * **argv array** and never a shell string, deliberately, because a shell here would turn every
24
+ * chat message into arbitrary command execution on every machine.
25
+ *
26
+ * So quoting is honoured for the simple cases (single and double, no escapes), and anything the
27
+ * parser cannot be sure about is a **named refusal** rather than a guess. A wrong guess executes
28
+ * something on a fleet; a refusal costs one retry.
29
+ */
30
+
31
+ /** Why a `/run` line was not dispatched. */
32
+ export const RUN_REFUSAL = Object.freeze({
33
+ EMPTY: 'empty_command',
34
+ UNBALANCED_QUOTE: 'unbalanced_quote',
35
+ SHELL_METACHARACTER: 'shell_metacharacter',
36
+ TOO_LONG: 'too_long',
37
+ NOT_PERMITTED: 'not_permitted',
38
+ });
39
+
40
+ /** A command long enough to be a mistake, and long enough to be worth refusing before dispatch. */
41
+ export const MAX_ARGS = 64;
42
+ export const MAX_ARG_LENGTH = 4096;
43
+
44
+ /** Characters that only mean something to a shell, and so indicate a caller expecting one. */
45
+ const SHELL_METACHARACTERS = ['|', '&', ';', '>', '<', '`', '$', '\n', '\\'];
46
+
47
+ /**
48
+ * Split a `/run` argument string into an argv array.
49
+ *
50
+ * @param {string} input Everything after `/run`.
51
+ * @returns {{ok: true, argv: string[]} | {ok: false, reason: string, text: string}}
52
+ */
53
+ export function parseRun(input) {
54
+ const raw = typeof input === 'string' ? input.trim() : '';
55
+ if (raw === '') {
56
+ return { ok: false, reason: RUN_REFUSAL.EMPTY, text: '用法:/run <命令>,例如 /run node --version' };
57
+ }
58
+
59
+ const argv = [];
60
+ let current = '';
61
+ let quote = null;
62
+ let started = false;
63
+
64
+ for (const ch of raw) {
65
+ if (quote !== null) {
66
+ if (ch === quote) { quote = null; continue; }
67
+ current += ch;
68
+ continue;
69
+ }
70
+ if (ch === '"' || ch === "'") { quote = ch; started = true; continue; }
71
+ if (ch === ' ' || ch === '\t' || ch === '\r') {
72
+ // A quoted empty argument ("") is a real argument, which is why `started` exists rather than
73
+ // a length check: `echo "" x` is two arguments, not one.
74
+ if (started || current !== '') { argv.push(current); current = ''; started = false; }
75
+ continue;
76
+ }
77
+ current += ch;
78
+ started = true;
79
+ }
80
+
81
+ if (quote !== null) {
82
+ return {
83
+ ok: false,
84
+ reason: RUN_REFUSAL.UNBALANCED_QUOTE,
85
+ text: `引号没有闭合(缺少 ${quote})。把整条命令用同一对引号包起来,或去掉引号。`,
86
+ };
87
+ }
88
+ if (started || current !== '') argv.push(current);
89
+
90
+ if (argv.length === 0) {
91
+ return { ok: false, reason: RUN_REFUSAL.EMPTY, text: '用法:/run <命令>,例如 /run node --version' };
92
+ }
93
+
94
+ /**
95
+ * A shell metacharacter is refused rather than passed through.
96
+ *
97
+ * The fleet takes an argv array and never a shell string, so `|` or `&&` would arrive as a
98
+ * literal argument and quietly do something other than what was typed. Refusing says so; passing
99
+ * it through produces a command that runs and does the wrong thing.
100
+ */
101
+ const offending = argv.find((a) => SHELL_METACHARACTERS.some((m) => a.includes(m)));
102
+ if (offending !== undefined) {
103
+ return {
104
+ ok: false,
105
+ reason: RUN_REFUSAL.SHELL_METACHARACTER,
106
+ text:
107
+ `命令里含有 shell 元字符(${SHELL_METACHARACTERS.filter((m) => offending.includes(m)).join(' ')})。`
108
+ + '编队接收的是 argv 数组、不是 shell 字符串,所以管道和重定向不会生效。'
109
+ + '请分别运行,或把逻辑放进一个脚本。',
110
+ };
111
+ }
112
+
113
+ if (argv.length > MAX_ARGS) {
114
+ return { ok: false, reason: RUN_REFUSAL.TOO_LONG, text: `参数太多(${argv.length} > ${MAX_ARGS})。` };
115
+ }
116
+ const long = argv.find((a) => a.length > MAX_ARG_LENGTH);
117
+ if (long !== undefined) {
118
+ return { ok: false, reason: RUN_REFUSAL.TOO_LONG, text: `单个参数过长(${long.length} > ${MAX_ARG_LENGTH})。` };
119
+ }
120
+
121
+ return { ok: true, argv };
122
+ }
123
+
124
+ /**
125
+ * Parse the flags `/run` accepts alongside the command.
126
+ *
127
+ * Kept separate from `parseRun` because the command itself is opaque: `/run node --timeout 5` is a
128
+ * node invocation whose argument happens to look like a flag, and pulling it out would run a
129
+ * different command than the one typed. Flags are only recognised **before** the command.
130
+ */
131
+ export function parseRunFlags(args) {
132
+ const flags = { mode: 'replicate', timeoutMs: null, machines: null, ask: false };
133
+ const rest = [...args];
134
+ while (rest.length > 0 && rest[0].startsWith('--')) {
135
+ const flag = rest.shift();
136
+ /**
137
+ * `--ask` switches the argument from a command to a **request in words**.
138
+ *
139
+ * This is the "C" form, and the flag is what keeps it from weakening the "A" form: an explicit
140
+ * marker means natural language is never *guessed* at, so a command that happens to read like
141
+ * a sentence is still dispatched as a command, and a sentence is never dispatched.
142
+ */
143
+ if (flag === '--ask' || flag === '-a') { flags.ask = true; continue; }
144
+ if (flag === '--on' || flag === '--machine') {
145
+ const value = rest.shift();
146
+ if (value !== undefined) flags.machines = value.split(',').map((s) => s.trim()).filter((s) => s !== '');
147
+ continue;
148
+ }
149
+ if (flag === '--timeout') {
150
+ const value = Number(rest.shift());
151
+ if (Number.isFinite(value) && value > 0) flags.timeoutMs = Math.min(value, 3_600_000);
152
+ continue;
153
+ }
154
+ if (flag === '--mode') {
155
+ const value = rest.shift();
156
+ if (['replicate', 'split', 'broadcast', 'pipeline'].includes(value)) flags.mode = value;
157
+ continue;
158
+ }
159
+ // An unrecognised leading `--flag` is handed back to the command: it may be a real argument
160
+ // (`node --version`), and swallowing it would run something else.
161
+ rest.unshift(flag);
162
+ break;
163
+ }
164
+ return { flags, command: rest.join(' ') };
165
+ }
166
+
167
+ /**
168
+ * The two forms, as **privilege levels** rather than two spellings of one action.
169
+ *
170
+ * | form | who | what it does | capability |
171
+ * |---|---|---|---|
172
+ * | `/run <argv>` | expert | dispatches those arguments, uninterpreted | `fleet.dispatch` |
173
+ * | `/run --ask <words>` | beginner | asks; the agent answers, and **proposes** a command | `operate` |
174
+ *
175
+ * The asymmetry is the point. A beginner describing what they want is *not* executing anything: the
176
+ * request goes to a session, the session answers in prose, and if it wants something run it emits
177
+ * `/run <argv>` — which still needs `fleet.dispatch` to actually happen. So a low-privilege window
178
+ * can ask freely and cannot cause a fleet execution by asking.
179
+ *
180
+ * That is why the two are not one command with a flag for the *syntax*: they are two capabilities,
181
+ * and a window that may do one must not thereby be able to do the other.
182
+ */
183
+ export const RUN_FORM = Object.freeze({
184
+ /** Expert: dispatch exactly these arguments. */
185
+ DISPATCH: 'dispatch',
186
+ /** Beginner: state what you want; the agent interprets and proposes. */
187
+ ASK: 'ask',
188
+ });
189
+
190
+ /** Which capability each form needs. Names match `commands.mjs`'s `CAPABILITY`. */
191
+ export const FORM_CAPABILITY = Object.freeze({
192
+ [RUN_FORM.DISPATCH]: 'fleet.dispatch',
193
+ [RUN_FORM.ASK]: 'operate',
194
+ });
195
+
196
+ /**
197
+ * Which capability a form requires.
198
+ *
199
+ * Returns the capability rather than checking it, so the check stays in the router where every
200
+ * other command's check lives — "authorisation is checked before the handler runs, never inside
201
+ * it", which is what stops a handler becoming reachable by forgetting its own guard.
202
+ */
203
+ export function requiredCapability(form) {
204
+ return FORM_CAPABILITY[form] ?? null;
205
+ }
206
+
207
+ /**
208
+ * Decide what one `/run` line is asking for.
209
+ *
210
+ * Two forms, and the distinction is **declared, not inferred**:
211
+ *
212
+ * * **A — `/run <argv>`** dispatches exactly those arguments. Nothing interprets them.
213
+ * * **C — `/run --ask <words>`** is a request the session's agent reads. The agent may answer in
214
+ * prose, and if it wants something run it must emit `/run <argv>` — which the human then sees
215
+ * as a command before it happens, and which needs the higher capability to execute.
216
+ *
217
+ * The reason `--ask` is a flag rather than a heuristic: an inferred distinction gets it wrong in
218
+ * both directions, and the direction that matters is dispatching a sentence as if it were a
219
+ * program. Declaring the form means the dangerous interpretation never happens by accident — and
220
+ * here it also means the *privilege level* is declared, not guessed from how the text reads.
221
+ *
222
+ * @returns {{kind: 'dispatch', argv: string[], flags: object, capability: string}
223
+ * | {kind: 'ask', text: string, flags: object, capability: string}
224
+ * | {kind: 'refused', reason: string, text: string}}
225
+ */
226
+ export function parseInvocation(input) {
227
+ const raw = typeof input === 'string' ? input.trim() : '';
228
+ if (raw === '') {
229
+ return { kind: 'refused', reason: RUN_REFUSAL.EMPTY, text: '用法:/run <命令>,或 /run --ask <用一句话描述你要做什么>' };
230
+ }
231
+
232
+ const { flags, command } = parseRunFlags(raw.split(/\s+/));
233
+
234
+ if (flags.ask) {
235
+ if (command.trim() === '') {
236
+ return {
237
+ kind: 'refused',
238
+ reason: RUN_REFUSAL.EMPTY,
239
+ text: '用法:/run --ask <用一句话描述你要做什么>,例如 /run --ask 看看各机器的 node 版本',
240
+ };
241
+ }
242
+ return { kind: 'ask', text: command.trim(), flags, capability: FORM_CAPABILITY[RUN_FORM.ASK] };
243
+ }
244
+
245
+ const parsed = parseRun(command);
246
+ if (!parsed.ok) return { kind: 'refused', reason: parsed.reason, text: parsed.text };
247
+ return { kind: 'dispatch', argv: parsed.argv, flags, capability: FORM_CAPABILITY[RUN_FORM.DISPATCH] };
248
+ }
249
+
250
+ /** A one-line summary of what was dispatched, for the acknowledgement. */
251
+ export function describeDispatch({ argv, mode, machines }) {
252
+ const parts = [`mode=${mode}`];
253
+ if (Array.isArray(machines) && machines.length > 0) parts.push(`on=${machines.join(',')}`);
254
+ return `${argv.join(' ')} (${parts.join(' ')})`;
255
+ }
256
+
257
+ /**
258
+ * Render a fleet verdict for a phone screen.
259
+ *
260
+ * Two constraints shape this, and both come from where the text is read rather than from the data:
261
+ * a verdict can name a dozen machines, and a chat message is not a terminal. So the per-machine
262
+ * detail is **capped**, the aggregate verdict leads, and a truncation is **stated** — a list that
263
+ * silently stops at ten reads as "ten machines" rather than "ten shown".
264
+ */
265
+ export function renderVerdict({ taskId, verdict, machines, maxMachines = 10 }) {
266
+ const lines = [`判定:${verdict}`, `任务:${taskId}`];
267
+ const rows = Array.isArray(machines) ? machines : [];
268
+ if (rows.length === 0) {
269
+ lines.push('(还没有逐机结果)');
270
+ return { text: lines.join('\n'), truncated: 0 };
271
+ }
272
+
273
+ const shown = rows.slice(0, maxMachines);
274
+ lines.push('', '逐机结果:');
275
+ for (const m of shown) {
276
+ const name = m.machine_name ?? m.machine_id ?? '?';
277
+ const outcome = m.outcome ?? m.state ?? '?';
278
+ const code = m.exit_code === null || m.exit_code === undefined ? '' : ` exit=${m.exit_code}`;
279
+ const why = m.refusal_reason ? ` (${m.refusal_reason})` : '';
280
+ lines.push(` ${name}: ${outcome}${code}${why}`);
281
+ }
282
+ const truncated = rows.length - shown.length;
283
+ if (truncated > 0) lines.push(` …另有 ${truncated} 台未显示`);
284
+ return { text: lines.join('\n'), truncated };
285
+ }
286
+
287
+ /**
288
+ * Render the failure of a dispatch that never became a task.
289
+ *
290
+ * `fleet.mjs` reports `NO_ONLINE_DEVICE` for several different situations, and the useful sentence
291
+ * differs: "no machine is paired" and "the machines are paired but silent" call for different
292
+ * actions from whoever is holding the phone.
293
+ */
294
+ export function renderDispatchFailure({ code, reason, detail }) {
295
+ if (code === 'NO_ONLINE_DEVICE') {
296
+ if (reason === 'requester_has_no_fleet') {
297
+ return '这个 relay 已绑定舰队,而发起方不属于任何舰队,所以不能派发。';
298
+ }
299
+ if (reason === 'target_outside_fleet') {
300
+ return '指定的机器不在这条舰队里。';
301
+ }
302
+ return '没有可用的在线设备。请确认每台机器上的 Localside agent 都在运行。';
303
+ }
304
+ if (code === 'COMMAND_NOT_ALLOWED') {
305
+ return `这条命令不在目标机器的 allow-list 里${detail ? `:${detail}` : ''}。`;
306
+ }
307
+ return `派发失败:${code ?? '未知错误'}${detail ? ` — ${detail}` : ''}`;
308
+ }
@@ -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.6.3';
180
+ const PLUGIN_VERSION = '__W2M_PLUGIN_VERSION__';
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;
@@ -29,6 +29,11 @@ import { renderMetrics, METRICS_CONTENT_TYPE } from './metrics.mjs';
29
29
  // v0.3.0 §9: request signing. This import is the entire point of the module --
30
30
  // until the HTTP layer calls it, HMAC support does not exist for any real request.
31
31
  import { DEFAULT_SKEW_SECONDS, NonceCache, verifyRequest } from '../signing.mjs';
32
+ // v0.7.11: per-device Ed25519 verification, tried before the shared secret.
33
+ import {
34
+ SIGNATURE_HEADER as DEVICE_SIGNATURE_HEADER,
35
+ verifyDeviceSignature,
36
+ } from '../chatops/device-signing.mjs';
32
37
 
33
38
  export const DEFAULT_SSE_KEEPALIVE_MS = 15_000; // §3.2: every 15s a `: keepalive` line
34
39
  export const DEFAULT_SWEEP_INTERVAL_MS = 1_000;
@@ -127,6 +132,19 @@ function firstHeaderValue(value) {
127
132
  return first === '' ? null : first;
128
133
  }
129
134
 
135
+ /**
136
+ * Read one header case-insensitively from a plain Node headers object.
137
+ *
138
+ * `signing.mjs` does this internally, but the device-key path needs it before deciding whether that
139
+ * module applies at all, so it is needed here too. Node lower-cases incoming header names, and a
140
+ * test that builds a request by hand may not, so both spellings are tried.
141
+ */
142
+ function readHeader(headers, name) {
143
+ if (headers === null || headers === undefined) return null;
144
+ const found = Object.keys(headers).find((k) => k.toLowerCase() === name);
145
+ return found === undefined ? null : firstHeaderValue(headers[found]);
146
+ }
147
+
130
148
  /**
131
149
  * Read a request body with a hard byte cap.
132
150
  * @returns {Promise<{tooLarge?:boolean, buffer?:Buffer, aborted?:boolean}>}
@@ -973,7 +991,37 @@ export class RelayServer {
973
991
  * path is the only value both sides agree on in all three deployment forms, so
974
992
  * clients sign `/v1/result` and never `https://host/w2m/v1/result`.
975
993
  */
994
+ /**
995
+ * Verify a request's signature (v0.7.11).
996
+ *
997
+ * Two mechanisms, and the order between them is the whole design:
998
+ *
999
+ * **1. Per-device Ed25519, when the sender is a device with a stored public key.** This is the
1000
+ * one that proves *which* device signed. It is tried **first** because a device key is strictly
1001
+ * stronger evidence than a shared secret, and because a device that registered a key must not
1002
+ * fall back to the weaker mechanism by accident.
1003
+ *
1004
+ * **2. HMAC over a shared secret, otherwise.** Every device that paired before v0.7.10 arrives
1005
+ * here — which is every device today — and nothing about its behaviour changes.
1006
+ *
1007
+ * ## Why this converges with no second deployment
1008
+ *
1009
+ * A device that has not been updated sends no signature header, so mechanism 1 finds no key and
1010
+ * mechanism 2 answers exactly as before. A device that *has* been updated sends a signature, its
1011
+ * public key is on file from pairing, and it is verified with it. So a fleet updates one machine
1012
+ * at a time and each machine gets the strongest mechanism available **to it** the moment it
1013
+ * starts sending one — no flag, no rollout step, no coordination.
1014
+ *
1015
+ * ## What this deliberately does not do
1016
+ *
1017
+ * It does not **require** a device key. `requireSignature` still means "a signature must be
1018
+ * present", and either mechanism satisfies it. Making a *device key* mandatory is a separate,
1019
+ * later switch, because turning it on today would refuse all four machines at once.
1020
+ */
976
1021
  verifySignature(req, { method, signedPath, body }) {
1022
+ const viaDevice = this.verifyWithDeviceKey(req, { method, signedPath, body });
1023
+ if (viaDevice !== null) return viaDevice;
1024
+
977
1025
  return verifyRequest({
978
1026
  headers: req.headers, // plain Node object; signing.mjs reads it case-insensitively
979
1027
  method,
@@ -987,6 +1035,54 @@ export class RelayServer {
987
1035
  });
988
1036
  }
989
1037
 
1038
+ /**
1039
+ * Try the per-device Ed25519 path.
1040
+ *
1041
+ * Returns `null` when the mechanism does not apply — no signature presented, or the signing
1042
+ * machine is not a device whose public key we hold — so the caller falls through. Returns a
1043
+ * decision when it *does* apply, **including when it fails**: a device that registered a key and
1044
+ * then presented a bad signature must not get a second chance through the shared secret, since
1045
+ * that would make the stronger mechanism optional for anyone able to produce a malformed one.
1046
+ */
1047
+ verifyWithDeviceKey(req, { method, signedPath, body }) {
1048
+ const header = readHeader(req.headers, DEVICE_SIGNATURE_HEADER);
1049
+ if (typeof header !== 'string' || header === '') return null;
1050
+
1051
+ const machineId = this.machineIdForRequest(req);
1052
+ if (machineId === null) return null;
1053
+ const publicKey = this.state.devices.get(machineId)?.public_key;
1054
+ if (typeof publicKey !== 'string' || publicKey === '') return null;
1055
+
1056
+ const decision = verifyDeviceSignature({
1057
+ headers: req.headers,
1058
+ publicKeyPem: publicKey,
1059
+ method,
1060
+ path: signedPath,
1061
+ body: body ?? '',
1062
+ nowMs: this.state.nowMs(),
1063
+ skewSeconds: this.signatureSkewSeconds,
1064
+ // `accept`, not `remember` -- the name this class actually exposes. It reports whether the
1065
+ // nonce was new, which is exactly the contract `verifyDeviceSignature` expects.
1066
+ nonces: (nonce, at) => this.nonceCache.accept(nonce, at),
1067
+ });
1068
+ if (decision.ok) return { ...decision, machine_id: machineId, keyIndex: null };
1069
+ return decision;
1070
+ }
1071
+
1072
+ /**
1073
+ * Which machine a request claims to be, from either credential.
1074
+ *
1075
+ * An **operator token is not a machine**, so it resolves to `null` and the device path does not
1076
+ * apply — consistent with `authorizeTaskDispatch`, which reports `machine_id: null` for an
1077
+ * operator. That is why an operator-token request keeps using the shared secret.
1078
+ */
1079
+ machineIdForRequest(req) {
1080
+ const token = bearerToken(req);
1081
+ if (!token) return null;
1082
+ if (this.operatorToken !== null && token === this.operatorToken) return null;
1083
+ return this.state.authenticate(token)?.machine_id ?? null;
1084
+ }
1085
+
990
1086
  /** Periodic maintenance, called by the sweep timer. */
991
1087
  maintenance() {
992
1088
  const expired = this.sweep();