@tikoci/centrs 0.1.8 → 0.1.9-next.22

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 (80) hide show
  1. package/CHANGELOG.md +216 -31
  2. package/GLOSSARY.txt +8 -0
  3. package/README.md +16 -8
  4. package/SECURITY.md +11 -3
  5. package/commands/api/README.md +44 -15
  6. package/commands/api/examples.md +44 -6
  7. package/commands/execute/README.md +218 -7
  8. package/commands/execute/examples.md +278 -1
  9. package/commands/explain/README.md +2 -1
  10. package/commands/mcp/README.md +11 -0
  11. package/commands/mcp/examples.md +6 -0
  12. package/commands/retrieve/README.md +32 -14
  13. package/commands/retrieve/examples.md +10 -6
  14. package/commands/transfer/examples.md +9 -0
  15. package/docs/CLI.md +10 -8
  16. package/docs/CONSTITUTION.md +54 -3
  17. package/docs/MATRIX.md +20 -3
  18. package/docs/errors/cdb/empty-group.md +19 -3
  19. package/docs/errors/cdb/empty-selection.md +9 -3
  20. package/docs/errors/cmr/caller-policy-not-enforced.md +46 -0
  21. package/docs/errors/cmr/cancelled.md +15 -0
  22. package/docs/errors/cmr/client-disconnected.md +19 -0
  23. package/docs/errors/cmr/client-script-not-validated.md +22 -0
  24. package/docs/errors/cmr/console-only.md +29 -0
  25. package/docs/errors/cmr/empty-selection.md +33 -0
  26. package/docs/errors/cmr/membership-changed.md +20 -0
  27. package/docs/errors/cmr/not-a-controller.md +26 -0
  28. package/docs/errors/cmr/outcome-unknown.md +29 -0
  29. package/docs/errors/cmr/output-truncated.md +16 -0
  30. package/docs/errors/cmr/policy-denied.md +31 -0
  31. package/docs/errors/cmr/rule-selects-every-device.md +32 -0
  32. package/docs/errors/cmr/selection-includes-controller.md +27 -0
  33. package/docs/errors/cmr/selection-incomplete.md +18 -0
  34. package/docs/errors/cmr/validation-incomplete.md +23 -0
  35. package/docs/errors/routeros/version-unsupported.md +4 -0
  36. package/docs/errors/transport/write-outcome-unknown.md +24 -0
  37. package/docs/errors/usage/flag-after-separator.md +34 -0
  38. package/docs/errors/usage/missing-labels.md +19 -0
  39. package/package.json +7 -5
  40. package/src/api-fanout.ts +18 -2
  41. package/src/api.ts +230 -71
  42. package/src/btest.ts +7 -2
  43. package/src/cli/api.ts +30 -16
  44. package/src/cli/common.ts +180 -1
  45. package/src/cli/devices.ts +0 -2
  46. package/src/cli/discover.ts +0 -1
  47. package/src/cli/execute.ts +176 -21
  48. package/src/cli/explain.ts +2 -0
  49. package/src/cli/mcp.ts +0 -1
  50. package/src/cli/retrieve.ts +40 -18
  51. package/src/cli/settings.ts +0 -3
  52. package/src/cli/terminal.ts +0 -2
  53. package/src/cli/transfer.ts +10 -8
  54. package/src/cli/version.ts +87 -0
  55. package/src/cli.ts +5 -3
  56. package/src/core/cmr-fleet.ts +486 -0
  57. package/src/core/cmr.ts +182 -0
  58. package/src/core/envelope.ts +46 -1
  59. package/src/core/error-catalog.ts +95 -2
  60. package/src/core/execute-command.ts +144 -19
  61. package/src/core/fanout.ts +207 -14
  62. package/src/errors.ts +3 -1
  63. package/src/execute-cmr.ts +1442 -0
  64. package/src/execute-fanout.ts +15 -1
  65. package/src/execute.ts +173 -40
  66. package/src/index.ts +35 -1
  67. package/src/mcp/safety.ts +11 -0
  68. package/src/mcp/tools.ts +4 -1
  69. package/src/protocols/adapter.ts +54 -12
  70. package/src/resolver/cdb.ts +13 -1
  71. package/src/resolver/index.ts +4 -0
  72. package/src/resolver/selection.ts +37 -1
  73. package/src/resolver/target.ts +96 -8
  74. package/src/retrieve-fanout.ts +4 -1
  75. package/src/retrieve-follow.ts +29 -33
  76. package/src/retrieve-sample.ts +14 -8
  77. package/src/retrieve-stream.ts +38 -6
  78. package/src/retrieve.ts +73 -59
  79. package/src/transfer-fanout.ts +8 -1
  80. package/src/transfer.ts +15 -19
@@ -0,0 +1,1442 @@
1
+ /**
2
+ * `execute --cmr`: run one validated RouterOS command on CMR clients through
3
+ * their controller (#450).
4
+ *
5
+ * CMR (RouterOS 7.26beta1+) keeps a device inventory on a controller and runs
6
+ * scripts on paired clients with `/cmr/device/run-script`. centrs talks only to
7
+ * the controller, with the controller's credentials; a client's address is
8
+ * never dialed. CMR is a selection source here, not a protocol: `--via` pins
9
+ * the controller transport.
10
+ *
11
+ * Grounded on 7.26beta1 x86 CHR (#441 and the #450 probes; the device facts
12
+ * this module relies on are asserted in `test/integration/execute-cmr.test.ts`):
13
+ *
14
+ * - `run-script` rows are `{.section, device, status, output}` progress
15
+ * snapshots. They carry NO device id, and `device` is the controller's
16
+ * cached `identity@address`, which changed mid-call when a client dropped.
17
+ * So every call here selects exactly ONE device (`numbers=<.id>`): the rows
18
+ * of that call are that device's, with nothing to correlate.
19
+ * - `numbers` and `labels` together select their intersection; an empty one
20
+ * is refused up front with `failure: no devices selected` (nothing sent).
21
+ * That refusal is how a preflight call learns "not a member", and how a
22
+ * dispatch learns the label set moved since preflight.
23
+ * - `failed` with `timed out (6)` or `std failure: timeout (13)` (a ~60 s
24
+ * ceiling `timeout=` does not lift), and a cancelled call, all left the
25
+ * client script RUNNING; its side effects landed later. A call that did not
26
+ * end in a final row is an unknown outcome, never "did not run", and is
27
+ * never retried.
28
+ * - Output is clipped at ~60 KiB with an in-band `...(+N B)` and status
29
+ * `success`. A validation result therefore passes a completeness gate (a
30
+ * trailing nonce line) before its `:parse` text is classified.
31
+ * - Scripts run with only the `read`, `write`, `test` and `reboot` policies
32
+ * (no user session). `/user/add` and `/tool/sniffer` fail at RUN time with
33
+ * `not enough permissions (9)` although `:parse` accepted them, so that is
34
+ * a dispatch outcome (`cmr/policy-denied`), never a validation result.
35
+ * - A client cannot check its own key: inside `run-script`,
36
+ * `/cmr/client get local-id` reads `*****` (no `sensitive` policy), and
37
+ * `numbers=` takes `.id` or identity but not `ids`. So binding is a
38
+ * host-side `/cmr/device` read just before each call, not an atomic check.
39
+ * - `freeze-frame-interval=20ms` brings a trivial call from ~1 s to ~25 ms;
40
+ * `strategy`/`fail-policy` are pinned and `order` is never sent (it adds
41
+ * `pending` and policy-`skipped` statuses).
42
+ * - The caller's policy is not checked against the script (#456):
43
+ * `run-script` needs only `read` + `api` on the controller login, and a
44
+ * `!write !reboot` login wrote to and rebooted clients, the controller's
45
+ * own row included. The write gate here is the only one; a write run by
46
+ * such a login carries `cmr/caller-policy-not-enforced`.
47
+ */
48
+
49
+ import {
50
+ assertNotCmrDashboardIl,
51
+ CMR_RUN_SCRIPT_PATH,
52
+ CMR_SCRIPT_POLICIES,
53
+ type CmrCallerPolicy,
54
+ cmrCallerPolicyWarning,
55
+ readCmrCallerPolicy,
56
+ } from "./core/cmr.ts";
57
+ import type {
58
+ CentrsErrorEnvelope,
59
+ CentrsSuccessEnvelope,
60
+ CommonSettingsMeta,
61
+ EnvelopeTargetMeta,
62
+ EnvelopeValidationMeta,
63
+ FanoutData,
64
+ FanoutSummary,
65
+ Warning,
66
+ } from "./core/envelope.ts";
67
+ import { notReachedReason } from "./core/envelope.ts";
68
+ import { isWriteShaped } from "./core/execute-command.ts";
69
+ import {
70
+ buildFanoutEnvelope,
71
+ NOT_SENT_TRANSPORT_CODES,
72
+ resolveFanoutConcurrency,
73
+ runBoundedPool,
74
+ summarizeFanout,
75
+ } from "./core/fanout.ts";
76
+ import {
77
+ assertCapability,
78
+ type CapabilityRequirement,
79
+ probeDeviceVersion,
80
+ } from "./core/preflight.ts";
81
+ import { mapRouterOsError } from "./core/routeros-errors.ts";
82
+ import { routerOsStringLiteral } from "./core/routeros-string.ts";
83
+ import { toYaml } from "./core/yaml.ts";
84
+ import {
85
+ CentrsError,
86
+ formatCentrsErrorText,
87
+ serializeCentrsError,
88
+ terminalSafeLine,
89
+ } from "./errors.ts";
90
+ import {
91
+ disabledValidationMeta,
92
+ type ExecuteEnvelope,
93
+ type ExecuteOperationMeta,
94
+ type ExecuteRequest,
95
+ type ExecuteRequestSummary,
96
+ offlineStagePassed,
97
+ passedStages,
98
+ promptForWriteConfirmation,
99
+ type ResolvedExecuteRequest,
100
+ renderExecuteEnvelope,
101
+ resolveExecuteRequest,
102
+ } from "./execute.ts";
103
+ import { assertOfflineSyntax, type OfflineGateResult } from "./offline-gate.ts";
104
+ import {
105
+ createProtocolAdapter,
106
+ type ProtocolAdapter,
107
+ type RouterOsProtocol,
108
+ } from "./protocols/index.ts";
109
+ import { classifyParseResult } from "./protocols/mac-telnet-console.ts";
110
+ import { redactTargetInput, toCoreSource } from "./resolver/index.ts";
111
+
112
+ /** The device-stage validator identity for a CMR client. */
113
+ export const CMR_VALIDATION_SOURCE = ":put [:parse ...] via cmr run-script";
114
+
115
+ /** CMR's hard per-call ceiling is ~60 s; stay under it so `timeout=` decides. */
116
+ export const CMR_MAX_CALL_TIMEOUT_MS = 55_000;
117
+
118
+ /** Local grace past the CMR `timeout=` before centrs stops waiting. */
119
+ export const CMR_CALL_GRACE_MS = 3_000;
120
+
121
+ /** Fixed per-call arguments (see the module header for why). */
122
+ const RUN_SCRIPT_PINNED = {
123
+ "freeze-frame-interval": "20ms",
124
+ strategy: "parallel",
125
+ "fail-policy": "continue",
126
+ } as const;
127
+
128
+ const FINAL_STATUSES = new Set(["success", "failed", "skipped"]);
129
+ const TRUNCATION_MARKER = /\.\.\.\(\+(\d+) B\)\s*$/;
130
+ const TIMEOUT_OUTPUT =
131
+ /^(?:timed out \(\d+\)|std failure: timeout \(\d+\))\s*$/;
132
+ const NO_DEVICES_SELECTED = /no devices selected/i;
133
+ /** run-script lacks `policy`, `sniff`, `sensitive` (…); `:parse` cannot see that. */
134
+ const POLICY_DENIED = /^not enough permissions \(9\).*$/m;
135
+ /** An invocation, optionally after a relative menu change (`/ip/address/;(evl …`). */
136
+ const ACCEPTED_IL = /^(?:\/[^\s;()]*;)?\(evl\s/;
137
+ /** A missing `/cmr/device`: native `no such command prefix`, REST `no such command or directory (cmr)`. */
138
+ const MISSING_MENU = /no such command (?:prefix|or directory)/i;
139
+
140
+ export interface ExecuteCmrRequest extends ExecuteRequest {
141
+ /** RouterOS label expression (comma list; plain = OR, `+` AND, `-` AND NOT, `all`). */
142
+ labels: string;
143
+ /** Max controller calls in flight (default: the transport fan-out default). */
144
+ concurrency?: number;
145
+ /** Cancels the operation; members already sent become unknown outcomes. */
146
+ signal?: AbortSignal;
147
+ }
148
+
149
+ /** One `/cmr/device` row, reduced to what binding needs. */
150
+ export interface CmrDevice {
151
+ id: string;
152
+ /** `<device key>/<controller key>` (hex); stable across rename and re-pair on 7.26beta1. */
153
+ ids: string;
154
+ identity: string;
155
+ address?: string;
156
+ }
157
+
158
+ export interface CmrScriptRow {
159
+ device: string;
160
+ status: string;
161
+ output: string;
162
+ section: number;
163
+ }
164
+
165
+ export interface CmrScriptCall {
166
+ numbers: string;
167
+ labels: string;
168
+ script: string;
169
+ /** CMR `timeout=` in ms; the local wait adds {@link CMR_CALL_GRACE_MS}. */
170
+ timeoutMs: number;
171
+ }
172
+
173
+ /**
174
+ * What one single-device `run-script` call produced. `selected: false` is the
175
+ * controller's up-front refusal (nothing sent). Otherwise `row` is the newest
176
+ * row seen, and `error` says why the call ended without one being final.
177
+ */
178
+ export interface CmrScriptOutcome {
179
+ selected: boolean;
180
+ /** The request reached the controller (native: the sentence was written). */
181
+ sent: boolean;
182
+ row?: CmrScriptRow;
183
+ error?: CentrsError;
184
+ }
185
+
186
+ /** Controller seam: production wraps a protocol adapter; tests inject a fake. */
187
+ /** What a router without `/cmr/device` says about itself, read only on that failure. */
188
+ export interface CmrAbsence {
189
+ /** `/system/resource` version. */
190
+ version?: string;
191
+ /** `/cmr/client` `controller-identity` / `controller-address`, when it is a client. */
192
+ controllerIdentity?: string;
193
+ controllerAddress?: string;
194
+ }
195
+
196
+ export interface CmrController {
197
+ devices(): Promise<CmrDevice[]>;
198
+ /** Explain a missing `/cmr/device`; each read is best effort. */
199
+ absence?(): Promise<CmrAbsence>;
200
+ /** The login's own group policy; `undefined` when it cannot be read. */
201
+ callerPolicy?(): Promise<CmrCallerPolicy | undefined>;
202
+ runScript(
203
+ call: CmrScriptCall,
204
+ signal: AbortSignal,
205
+ ): Promise<CmrScriptOutcome>;
206
+ close(): Promise<void>;
207
+ }
208
+
209
+ export interface ExecuteCmrInternals {
210
+ controller?: (
211
+ resolved: ResolvedExecuteRequest,
212
+ signal: AbortSignal,
213
+ ) => CmrController;
214
+ nonce?: () => string;
215
+ }
216
+
217
+ export interface ExecuteCmrOperationMeta {
218
+ kind: "cmr";
219
+ concurrency: number;
220
+ summary: FanoutSummary;
221
+ request: ExecuteRequestSummary;
222
+ /** Hoisted from the members: every member runs under the controller login. */
223
+ auth?: { username?: string; passwordProvided: boolean };
224
+ cmr: {
225
+ labels: string;
226
+ /** `/cmr/device` rows considered. */
227
+ inventory: number;
228
+ /** Rows the label expression selected. */
229
+ selected: number;
230
+ /** Members the command was sent to. */
231
+ dispatched: number;
232
+ };
233
+ }
234
+
235
+ export type ExecuteCmrData = FanoutData<unknown, ExecuteOperationMeta>;
236
+ export type ExecuteCmrEnvelope = CentrsSuccessEnvelope<
237
+ ExecuteCmrData,
238
+ ExecuteCmrOperationMeta
239
+ >;
240
+ export type ExecuteCmrErrorEnvelope =
241
+ CentrsErrorEnvelope<ExecuteCmrOperationMeta>;
242
+
243
+ interface Member {
244
+ device: CmrDevice;
245
+ /** Final envelope once decided; undefined while still eligible to run. */
246
+ result?: ExecuteEnvelope;
247
+ validation: EnvelopeValidationMeta;
248
+ }
249
+
250
+ /**
251
+ * Run `request.command` on every CMR client `request.labels` selects on the
252
+ * controller `request` resolves to. Throws (outer error) only before anything
253
+ * is dispatched: bad usage, an incomplete selection, a refused write gate.
254
+ */
255
+ export async function executeCmr(
256
+ request: ExecuteCmrRequest,
257
+ env: Record<string, string | undefined> = Bun.env,
258
+ internals: ExecuteCmrInternals = {},
259
+ ): Promise<ExecuteCmrEnvelope> {
260
+ const labels = request.labels.trim();
261
+ if (labels.length === 0) {
262
+ throw new CentrsError({
263
+ code: "usage/missing-labels",
264
+ summary:
265
+ "`--cmr` needs a `--labels` expression naming the clients to run on.",
266
+ remediation:
267
+ "Pass RouterOS label syntax, e.g. `--labels all`, `--labels site-a,+edge` or `--labels lab,-controller`.",
268
+ });
269
+ }
270
+ const resolved = await resolveExecuteRequest(request, env);
271
+ const via = resolved.via.value;
272
+ if (via !== "native-api" && via !== "rest-api") {
273
+ throw new CentrsError({
274
+ code: "usage/conflicting-flags",
275
+ summary: `\`--cmr\` talks to the controller over native-api or rest-api, not ${via}.`,
276
+ remediation:
277
+ "Drop `--via` (native-api is the default) or pin `--via native-api` / `--via rest-api`.",
278
+ context: { via },
279
+ });
280
+ }
281
+ const offline = resolved.validate.value
282
+ ? assertOfflineSyntax(resolved.command, { surface: "execute", via })
283
+ : undefined;
284
+ const signal = request.signal ?? new AbortController().signal;
285
+ const concurrency = resolveFanoutConcurrency(request.concurrency, via);
286
+ const callTimeoutMs = Math.min(
287
+ resolved.timeoutMs.value,
288
+ CMR_MAX_CALL_TIMEOUT_MS,
289
+ );
290
+ const write = isWriteShaped(resolved.canonical);
291
+ const controllerName =
292
+ resolved.target.identity ?? resolved.target.input ?? resolved.target.host;
293
+ const controller = (internals.controller ?? adapterController)(
294
+ resolved,
295
+ signal,
296
+ );
297
+ const nonce = (internals.nonce ?? defaultNonce)();
298
+
299
+ const warnings: Warning[] = [...resolved.warnings];
300
+ try {
301
+ // 1. Inventory and per-row preflight (membership + client validation).
302
+ const before = await readInventoryBeforeDispatch(controller, signal);
303
+ const preflightScript = resolved.validate.value
304
+ ? `:put [:parse ${routerOsStringLiteral(resolved.command)}]; :put "${nonce}"`
305
+ : `:put "${nonce}"`;
306
+ const preflight = await runBoundedPool(before, concurrency, (device) =>
307
+ signal.aborted
308
+ ? Promise.resolve<CmrScriptOutcome>({
309
+ selected: true,
310
+ sent: false,
311
+ error: cancelledError(),
312
+ })
313
+ : controller.runScript(
314
+ {
315
+ numbers: device.id,
316
+ labels,
317
+ script: preflightScript,
318
+ timeoutMs: callTimeoutMs,
319
+ },
320
+ signal,
321
+ ),
322
+ );
323
+ if (signal.aborted) throw cancelledError();
324
+ const unknownMembership = before.filter(
325
+ (_, index) =>
326
+ preflight[index]?.selected === true &&
327
+ preflight[index]?.row === undefined,
328
+ );
329
+ if (unknownMembership.length > 0) {
330
+ throw new CentrsError({
331
+ code: "cmr/selection-incomplete",
332
+ summary: `The controller did not say whether ${unknownMembership.length} CMR device(s) match \`${labels}\`, so nothing was run.`,
333
+ remediation:
334
+ "Check the controller is reachable and responsive, raise `--timeout`, then retry. A partial selection is never run.",
335
+ context: {
336
+ labels,
337
+ devices: unknownMembership.map((d) => ({
338
+ id: d.id,
339
+ identity: d.identity,
340
+ })),
341
+ causes: unknownMembership.map((d) => {
342
+ const error = preflight[before.indexOf(d)]?.error;
343
+ return error ? error.code : "no reply";
344
+ }),
345
+ },
346
+ });
347
+ }
348
+
349
+ const members: Member[] = [];
350
+ before.forEach((device, index) => {
351
+ const outcome = preflight[index];
352
+ if (!outcome?.selected || !outcome.row) return;
353
+ members.push(judgePreflight(device, outcome, resolved, offline, nonce));
354
+ });
355
+ if (members.length === 0) {
356
+ const message = `No CMR device on the controller matches \`${labels}\`. Labels are case-sensitive and RouterOS accepts unknown ones silently.`;
357
+ const context = { labels, inventory: before.length };
358
+ // #460: RouterOS refuses an empty selection (`no devices selected`);
359
+ // a write here must not exit 0 on a mistyped label.
360
+ if (write) {
361
+ throw new CentrsError({
362
+ code: "cmr/empty-selection",
363
+ summary: `${message} Nothing ran: a write-shaped command must select at least one client.`,
364
+ remediation:
365
+ "Check the labels with `centrs retrieve <controller> /cmr/device` (`labels`, `auto-labels`), then re-run. A read over the same labels succeeds with a warning instead.",
366
+ context: { ...context, write: true },
367
+ });
368
+ }
369
+ warnings.push({ code: "cmr/empty-selection", message, context });
370
+ }
371
+
372
+ // 2. Bind: a member whose `ids` changed since S1 was validated as another key.
373
+ const middle = await readInventoryBeforeDispatch(controller, signal);
374
+ for (const member of members) {
375
+ if (member.result) continue;
376
+ const now = middle.find((d) => d.id === member.device.id);
377
+ if (!now || now.ids !== member.device.ids) {
378
+ member.result = memberError(
379
+ member,
380
+ resolved,
381
+ notDispatched(
382
+ "cmr/membership-changed",
383
+ `CMR device ${member.device.id} (${member.device.identity}) changed or left the inventory between validation and dispatch.`,
384
+ { before: member.device, after: now ?? null },
385
+ ),
386
+ );
387
+ }
388
+ }
389
+
390
+ // 3. One write gate, naming the blast radius. The login's policy is read
391
+ // once, best effort: a failed read only loses the warning.
392
+ const runnable = members.filter((m) => !m.result);
393
+ if (write && runnable.length > 0) {
394
+ const caller = await controller.callerPolicy?.().catch(() => undefined);
395
+ const notEnforced =
396
+ caller && cmrCallerPolicyWarning(caller, CMR_SCRIPT_POLICIES);
397
+ if (notEnforced) warnings.push(notEnforced);
398
+ await confirmCmrWrite(
399
+ request,
400
+ signal,
401
+ resolved,
402
+ controllerName,
403
+ runnable.length,
404
+ members.length - runnable.length,
405
+ notEnforced,
406
+ );
407
+ }
408
+
409
+ // 4. Dispatch: one call per member, no retry. The prompt and the pool
410
+ // queue can both wait arbitrarily long after S2, so each member is
411
+ // re-bound to the key it was validated on just before its own call.
412
+ const dispatched = await runBoundedPool(
413
+ runnable,
414
+ concurrency,
415
+ async (member) => {
416
+ const unsent = (error: CentrsError) => ({
417
+ member,
418
+ outcome: { selected: true, sent: false, error },
419
+ });
420
+ if (signal.aborted) return unsent(cancelledError());
421
+ const rebound = await rebindBeforeSend(controller, member, signal);
422
+ if (rebound) return unsent(rebound);
423
+ const outcome = await controller.runScript(
424
+ {
425
+ numbers: member.device.id,
426
+ labels,
427
+ script: resolved.command,
428
+ timeoutMs: callTimeoutMs,
429
+ },
430
+ signal,
431
+ );
432
+ return { member, outcome };
433
+ },
434
+ );
435
+ // S3 re-binds every sent result to the key it was validated on. If it
436
+ // cannot be read (or the run was cancelled), sent results are unknown.
437
+ const after =
438
+ dispatched.length > 0 && !signal.aborted
439
+ ? await readInventory(controller).catch(() => undefined)
440
+ : undefined;
441
+ let sentCount = 0;
442
+ for (const { member, outcome } of dispatched) {
443
+ if (outcome.sent) sentCount += 1;
444
+ const now = after?.find((d) => d.id === member.device.id);
445
+ const binding =
446
+ after === undefined
447
+ ? "unverified"
448
+ : now?.ids === member.device.ids
449
+ ? "bound"
450
+ : "drifted";
451
+ member.result = applyMemberBudget(
452
+ judgeDispatch(member, outcome, resolved, binding),
453
+ member,
454
+ resolved,
455
+ );
456
+ }
457
+
458
+ const targets = members.map(
459
+ (member) =>
460
+ member.result ?? memberError(member, resolved, internalUndecided()),
461
+ );
462
+ const summary = summarizeFanout(targets);
463
+ return buildFanoutEnvelope<
464
+ unknown,
465
+ ExecuteOperationMeta,
466
+ ExecuteCmrOperationMeta
467
+ >({
468
+ summary,
469
+ targets,
470
+ warnings,
471
+ settings: settingsMeta(resolved),
472
+ via,
473
+ target: controllerTargetMeta(resolved),
474
+ operation: {
475
+ kind: "cmr",
476
+ concurrency,
477
+ summary,
478
+ request: requestSummary(resolved),
479
+ cmr: {
480
+ labels,
481
+ inventory: before.length,
482
+ selected: members.length,
483
+ dispatched: sentCount,
484
+ },
485
+ },
486
+ });
487
+ } catch (error) {
488
+ // An outer error keeps the warnings gathered before it, such as
489
+ // `cmr/caller-policy-not-enforced` on a refused confirmation.
490
+ if (error instanceof CentrsError && warnings.length > 0) {
491
+ OUTER_ERROR_WARNINGS.set(error, [...warnings]);
492
+ }
493
+ throw error;
494
+ } finally {
495
+ await controller.close();
496
+ }
497
+ }
498
+
499
+ /** Warnings an outer `executeCmr` error carried; read by {@link buildExecuteCmrErrorEnvelope}. */
500
+ const OUTER_ERROR_WARNINGS = new WeakMap<CentrsError, Warning[]>();
501
+
502
+ // ---------------------------------------------------------------------------
503
+ // Preflight and dispatch judgement
504
+ // ---------------------------------------------------------------------------
505
+
506
+ function judgePreflight(
507
+ device: CmrDevice,
508
+ outcome: CmrScriptOutcome,
509
+ resolved: ResolvedExecuteRequest,
510
+ offline: OfflineGateResult | undefined,
511
+ nonce: string,
512
+ ): Member {
513
+ const row = outcome.row as CmrScriptRow;
514
+ const member: Member = {
515
+ device,
516
+ validation: resolved.validate.value
517
+ ? devicePassed(resolved, offline)
518
+ : disabledValidationMeta(),
519
+ };
520
+ if (row.status === "skipped") {
521
+ member.validation = deviceNotReached(
522
+ resolved,
523
+ offline,
524
+ "cmr/client-disconnected",
525
+ );
526
+ member.result = memberError(
527
+ member,
528
+ resolved,
529
+ disconnectedError(device, row),
530
+ );
531
+ return member;
532
+ }
533
+ const incomplete = (why: string) => {
534
+ member.validation = deviceNotReached(
535
+ resolved,
536
+ offline,
537
+ "cmr/validation-incomplete",
538
+ );
539
+ member.result = memberError(
540
+ member,
541
+ resolved,
542
+ notDispatched(
543
+ "cmr/validation-incomplete",
544
+ `The validation result from CMR device ${device.identity} was incomplete (${why}), so the command was not sent to it.`,
545
+ { id: device.id, status: row.status, output: row.output },
546
+ ),
547
+ );
548
+ return member;
549
+ };
550
+ if (!FINAL_STATUSES.has(row.status))
551
+ return incomplete(
552
+ outcome.error
553
+ ? `${outcome.error.code}, last status ${row.status}`
554
+ : `last status ${row.status}`,
555
+ );
556
+ if (row.status !== "success")
557
+ return incomplete(`status ${row.status}: ${row.output.trim()}`);
558
+ const text = row.output.replaceAll("\r\n", "\n");
559
+ if (TRUNCATION_MARKER.test(text)) return incomplete("output clipped by CMR");
560
+ const lines = text.replace(/\n+$/, "").split("\n");
561
+ if (lines.at(-1) !== nonce) return incomplete("completion marker missing");
562
+ if (!resolved.validate.value) return member;
563
+ const parse = lines.slice(0, -1).join("\n").trim();
564
+ if (parse.length === 0) return incomplete("empty `:parse` return");
565
+ try {
566
+ classifyParseResult(parse, resolved.command, "cmr run-script");
567
+ assertNotCmrDashboardIl(parse, resolved.command);
568
+ } catch (error) {
569
+ const rejected = error as CentrsError;
570
+ member.validation = deviceRejected(resolved, offline, rejected);
571
+ member.result = memberError(member, resolved, rejected);
572
+ return member;
573
+ }
574
+ // Positive evidence: every accepted `:parse` return seen (structured,
575
+ // console spacing, multi-statement, blocks, `:if`/`:foreach`; 7.24.5 and
576
+ // 7.26beta1) starts `(evl `, after a relative menu change if the command
577
+ // opens with one (7.26beta1). A comment-only script returns `/`.
578
+ if (!ACCEPTED_IL.test(parse)) return incomplete("`:parse` return is not IL");
579
+ return member;
580
+ }
581
+
582
+ function judgeDispatch(
583
+ member: Member,
584
+ outcome: CmrScriptOutcome,
585
+ resolved: ResolvedExecuteRequest,
586
+ binding: "bound" | "drifted" | "unverified",
587
+ ): ExecuteEnvelope {
588
+ const device = member.device;
589
+ if (!outcome.selected) {
590
+ return memberError(
591
+ member,
592
+ resolved,
593
+ notDispatched(
594
+ "cmr/membership-changed",
595
+ `CMR device ${device.identity} no longer matched the labels at dispatch, so the command was not sent to it.`,
596
+ { id: device.id },
597
+ ),
598
+ );
599
+ }
600
+ const row = outcome.row;
601
+ if (!outcome.sent && !(row && FINAL_STATUSES.has(row.status))) {
602
+ return memberError(member, resolved, outcome.error ?? cancelledError());
603
+ }
604
+ // CMR itself declined to send: nothing ran, whichever client this was.
605
+ if (row?.status === "skipped") {
606
+ return memberError(member, resolved, disconnectedError(device, row));
607
+ }
608
+ // A sent call whose member cannot be re-bound after dispatch (its `ids`
609
+ // changed, or S3 was not read) has no attributable result.
610
+ if (binding !== "bound") {
611
+ return memberError(
612
+ member,
613
+ resolved,
614
+ unknownOutcome(
615
+ device,
616
+ undefined,
617
+ row,
618
+ binding === "drifted"
619
+ ? "its `ids` changed during the call"
620
+ : "the controller inventory was not re-read (cancelled or unreadable) to confirm which client answered",
621
+ ),
622
+ );
623
+ }
624
+ if (!row || !FINAL_STATUSES.has(row.status)) {
625
+ return memberError(
626
+ member,
627
+ resolved,
628
+ unknownOutcome(device, outcome.error, row),
629
+ );
630
+ }
631
+ if (row.status === "failed") {
632
+ if (TIMEOUT_OUTPUT.test(row.output.trim())) {
633
+ return memberError(
634
+ member,
635
+ resolved,
636
+ unknownOutcome(device, undefined, row),
637
+ );
638
+ }
639
+ // The diagnostic follows whatever the script printed before it.
640
+ const denied = POLICY_DENIED.exec(row.output.replaceAll("\r\n", "\n"));
641
+ if (denied) {
642
+ return memberError(
643
+ member,
644
+ resolved,
645
+ new CentrsError({
646
+ code: "cmr/policy-denied",
647
+ summary: `${device.identity} refused a statement: CMR runs scripts with only the read, write, test and reboot policies (\`${denied[0].trim()}\`). Any statements before it already ran.`,
648
+ remediation:
649
+ "A statement that needs another policy (`/user`, `/tool/sniffer`) cannot run through CMR. Check what the earlier statements changed (the output is in `context.output`), then run only the refused statement and the ones after it directly on the device, e.g. `centrs execute <device> -- '<command>'`.",
650
+ context: {
651
+ id: device.id,
652
+ identity: device.identity,
653
+ output: row.output,
654
+ },
655
+ }),
656
+ );
657
+ }
658
+ return memberError(
659
+ member,
660
+ resolved,
661
+ mapRouterOsError(row.output.trim(), {
662
+ transport:
663
+ resolved.via.value === "rest-api" ? "rest-api" : "native-api",
664
+ context: { cmr: { id: device.id, identity: device.identity } },
665
+ }),
666
+ );
667
+ }
668
+ const warnings: Warning[] = [];
669
+ const clipped = TRUNCATION_MARKER.exec(row.output);
670
+ if (clipped) {
671
+ warnings.push({
672
+ code: "cmr/output-truncated",
673
+ message: `CMR clipped the output from ${device.identity}: ${clipped[1]} more bytes were not returned.`,
674
+ context: { id: device.id, missingBytes: Number(clipped[1]) },
675
+ });
676
+ }
677
+ return {
678
+ ok: true,
679
+ data: { ret: row.output },
680
+ warnings,
681
+ tips: [],
682
+ meta: {
683
+ ...memberMeta(member, resolved),
684
+ ...(clipped
685
+ ? {
686
+ truncated: {
687
+ truncated: true,
688
+ reason: "CMR run-script output cap (~60 KiB)",
689
+ serializedBytes: row.output.length + Number(clipped[1]),
690
+ },
691
+ }
692
+ : {}),
693
+ },
694
+ };
695
+ }
696
+
697
+ /**
698
+ * `--max-results` per member, as plain execute fan-out applies it per target:
699
+ * the member's rendered envelope in the requested format, in UTF-8 bytes. The
700
+ * command already ran, so the error says so rather than implying it did not.
701
+ */
702
+ function applyMemberBudget(
703
+ envelope: ExecuteEnvelope,
704
+ member: Member,
705
+ resolved: ResolvedExecuteRequest,
706
+ ): ExecuteEnvelope {
707
+ const budget = resolved.maxResultsBytes?.value;
708
+ if (!budget || !envelope.ok) return envelope;
709
+ const format = resolved.format.value;
710
+ const requiredBytes = new TextEncoder().encode(
711
+ renderExecuteEnvelope(envelope, format, {
712
+ verbose: format === "text" && resolved.verbose,
713
+ }),
714
+ ).length;
715
+ if (requiredBytes <= budget) return envelope;
716
+ const device = member.device;
717
+ return memberError(
718
+ member,
719
+ resolved,
720
+ new CentrsError({
721
+ code: "input/max-results-exceeded",
722
+ summary: `The command ran on ${device.identity}, but its result (${requiredBytes} bytes) exceeded the requested ${budget}-byte budget, so it is not returned.`,
723
+ remediation:
724
+ "Increase `--max-results` or narrow the command's output. The command already ran on this client; check its state before re-running anything that is not idempotent.",
725
+ context: {
726
+ id: device.id,
727
+ identity: device.identity,
728
+ requiredBytes,
729
+ maxResultsBytes: budget,
730
+ command: resolved.command,
731
+ dispatched: true,
732
+ },
733
+ }),
734
+ );
735
+ }
736
+
737
+ // ---------------------------------------------------------------------------
738
+ // Errors
739
+ // ---------------------------------------------------------------------------
740
+
741
+ function notDispatched(
742
+ code: "cmr/membership-changed" | "cmr/validation-incomplete",
743
+ summary: string,
744
+ context: Record<string, unknown>,
745
+ ): CentrsError {
746
+ return new CentrsError({
747
+ code,
748
+ summary,
749
+ remediation:
750
+ code === "cmr/membership-changed"
751
+ ? "Re-run to validate the current selection; a member is only run with the validation it passed."
752
+ : "The command was not sent to this client. Check the controller and client are responsive, keep the command under ~60 KiB of `:parse` output, and retry.",
753
+ context,
754
+ });
755
+ }
756
+
757
+ function disconnectedError(device: CmrDevice, row: CmrScriptRow): CentrsError {
758
+ return new CentrsError({
759
+ code: "cmr/client-disconnected",
760
+ summary: `CMR skipped ${device.identity}: ${row.output.trim() || "skipped"}. The command was not sent to it.`,
761
+ remediation:
762
+ "Check the client's `/cmr/client` status (`paired,connected`) and its route to the controller, then retry.",
763
+ context: { id: device.id, identity: device.identity, output: row.output },
764
+ });
765
+ }
766
+
767
+ function unknownOutcome(
768
+ device: CmrDevice,
769
+ cause: CentrsError | undefined,
770
+ row: CmrScriptRow | undefined,
771
+ why?: string,
772
+ ): CentrsError {
773
+ const reason =
774
+ why ??
775
+ (row && row.status === "failed"
776
+ ? `CMR reported \`${row.output.trim()}\``
777
+ : cause
778
+ ? `the call ended with ${cause.code}`
779
+ : "the call ended without a final status");
780
+ return new CentrsError({
781
+ code: "cmr/outcome-unknown",
782
+ summary: `The command was sent to ${device.identity}, but its outcome is unknown: ${reason}. It may still be running.`,
783
+ remediation:
784
+ "CMR timeouts and cancellation do not stop a client script. Check the client's state before re-running anything that is not idempotent; centrs never retries it.",
785
+ context: {
786
+ id: device.id,
787
+ identity: device.identity,
788
+ ...(row ? { lastStatus: row.status, output: row.output } : {}),
789
+ ...(cause ? { cause: cause.code } : {}),
790
+ },
791
+ ...(cause ? { cause } : {}),
792
+ });
793
+ }
794
+
795
+ function cancelledError(cause?: unknown): CentrsError {
796
+ return new CentrsError({
797
+ code: "cmr/cancelled",
798
+ summary:
799
+ "The operation was cancelled before the command was sent to this CMR client.",
800
+ remediation: "Nothing ran on this client. Re-run when ready.",
801
+ ...(cause ? { cause } : {}),
802
+ });
803
+ }
804
+
805
+ function internalUndecided(): CentrsError {
806
+ return new CentrsError({
807
+ code: "internal/unhandled",
808
+ summary: "A CMR member finished without a recorded result.",
809
+ remediation:
810
+ "This is a centrs bug; report it with the command and labels used.",
811
+ });
812
+ }
813
+
814
+ function isNoDevicesSelected(error: unknown): boolean {
815
+ if (!(error instanceof CentrsError)) return false;
816
+ const detail = error.context?.["detail"];
817
+ return NO_DEVICES_SELECTED.test(
818
+ `${error.summary} ${typeof detail === "string" ? detail : ""}`,
819
+ );
820
+ }
821
+
822
+ /**
823
+ * The per-member check before its call: `undefined` when the row still carries
824
+ * the `.id` + `ids` the member was validated on, otherwise why it is not sent.
825
+ * The original member set is fixed; a replacement row is never adopted, even
826
+ * when it matches the labels. A host-side read is not atomic with the call:
827
+ * a re-key inside that gap is still caught, after the fact, by S3.
828
+ */
829
+ async function rebindBeforeSend(
830
+ controller: CmrController,
831
+ member: Member,
832
+ signal: AbortSignal,
833
+ ): Promise<CentrsError | undefined> {
834
+ const device = member.device;
835
+ let current: CmrDevice[];
836
+ try {
837
+ current = await readInventoryBeforeDispatch(controller, signal);
838
+ } catch (error) {
839
+ const cause = asCmrError(error);
840
+ return new CentrsError({
841
+ code: cause.code,
842
+ summary: `The CMR inventory could not be re-read before sending to ${device.identity}, so the command was not sent to it: ${cause.summary}`,
843
+ remediation:
844
+ cause.remediation ??
845
+ "Check the controller is reachable and responsive, then re-run.",
846
+ context: { id: device.id, identity: device.identity, dispatched: false },
847
+ cause,
848
+ });
849
+ }
850
+ const now = current.find((d) => d.id === device.id);
851
+ if (now && now.ids === device.ids) return undefined;
852
+ return notDispatched(
853
+ "cmr/membership-changed",
854
+ `CMR device ${device.id} (${device.identity}) changed or left the inventory before its command was sent.`,
855
+ { before: device, after: now ?? null },
856
+ );
857
+ }
858
+
859
+ /**
860
+ * S1/S2: nothing has been sent yet, so a caller abort is `cmr/cancelled`
861
+ * whether the read resolved or the adapter rejected because of it.
862
+ */
863
+ async function readInventoryBeforeDispatch(
864
+ controller: CmrController,
865
+ signal: AbortSignal,
866
+ ): Promise<CmrDevice[]> {
867
+ if (signal.aborted) throw cancelledError();
868
+ let devices: CmrDevice[];
869
+ try {
870
+ devices = await readInventory(controller);
871
+ } catch (error) {
872
+ if (signal.aborted) throw cancelledError(error);
873
+ throw error;
874
+ }
875
+ if (signal.aborted) throw cancelledError();
876
+ return devices;
877
+ }
878
+
879
+ async function readInventory(controller: CmrController): Promise<CmrDevice[]> {
880
+ try {
881
+ return await controller.devices();
882
+ } catch (error) {
883
+ const detail =
884
+ error instanceof CentrsError ? error.context?.["detail"] : undefined;
885
+ if (typeof detail === "string" && MISSING_MENU.test(detail)) {
886
+ throw await noCmrError(controller, detail, error);
887
+ }
888
+ throw error;
889
+ }
890
+ }
891
+
892
+ /** CMR first shipped in 7.26beta1 (manual; the quickchr CMR lab). */
893
+ const CMR_CONTROLLER: CapabilityRequirement = {
894
+ capability: "cmr-controller",
895
+ summary: "be a CMR controller (`/cmr` first shipped in 7.26beta1, a beta)",
896
+ supported: [{ min: "7.26beta1" }],
897
+ remediation:
898
+ "Upgrade the controller to a RouterOS build with CMR (7.26beta1 or later), install the optional `cmr` package, and run `/cmr set enabled=yes`.",
899
+ };
900
+
901
+ /**
902
+ * A missing `/cmr/device` has three causes, told apart only on this failure
903
+ * path: RouterOS older than CMR (`routeros/version-unsupported`), or a 7.26+
904
+ * router without the `cmr` package, possibly a CMR client.
905
+ */
906
+ async function noCmrError(
907
+ controller: CmrController,
908
+ detail: string,
909
+ cause: unknown,
910
+ ): Promise<CentrsError> {
911
+ const found = (await controller.absence?.().catch(() => undefined)) ?? {};
912
+ if (found.version !== undefined) {
913
+ try {
914
+ assertCapability(CMR_CONTROLLER, {
915
+ version: found.version,
916
+ source: "live",
917
+ });
918
+ } catch (error) {
919
+ return error as CentrsError;
920
+ }
921
+ }
922
+ const client = found.controllerIdentity
923
+ ? ` It is a CMR client of ${found.controllerIdentity}${found.controllerAddress ? ` (${found.controllerAddress})` : ""}; point \`--cmr\` at that controller.`
924
+ : "";
925
+ return new CentrsError({
926
+ code: "cmr/not-a-controller",
927
+ summary:
928
+ found.version !== undefined
929
+ ? `RouterOS ${found.version} supports CMR, but the target has no \`/cmr/device\`: the optional \`cmr\` package is not installed.${client}`
930
+ : "The target did not answer `/cmr/device`; it is not a CMR controller.",
931
+ remediation:
932
+ "Point `--cmr` at the router running the optional `cmr` package (RouterOS 7.26beta1+) with `/cmr set enabled=yes`, or install that package on this router.",
933
+ context: {
934
+ detail,
935
+ ...(found.version !== undefined ? { version: found.version } : {}),
936
+ ...(found.controllerIdentity
937
+ ? {
938
+ controllerIdentity: found.controllerIdentity,
939
+ controllerAddress: found.controllerAddress,
940
+ }
941
+ : {}),
942
+ },
943
+ cause,
944
+ });
945
+ }
946
+
947
+ // ---------------------------------------------------------------------------
948
+ // Envelope helpers
949
+ // ---------------------------------------------------------------------------
950
+
951
+ function memberError(
952
+ member: Member,
953
+ resolved: ResolvedExecuteRequest,
954
+ error: CentrsError,
955
+ ): ExecuteEnvelope {
956
+ return {
957
+ ok: false,
958
+ error: serializeCentrsError(error),
959
+ warnings: [],
960
+ tips: [],
961
+ meta: memberMeta(member, resolved),
962
+ };
963
+ }
964
+
965
+ function memberMeta(
966
+ member: Member,
967
+ resolved: ResolvedExecuteRequest,
968
+ ): ExecuteEnvelope["meta"] {
969
+ const device = member.device;
970
+ const target: EnvelopeTargetMeta = {
971
+ input: device.identity,
972
+ identity: device.identity,
973
+ cmr: {
974
+ id: device.id,
975
+ deviceKey: device.ids.split("/")[0] || undefined,
976
+ address: device.address,
977
+ },
978
+ };
979
+ return {
980
+ target,
981
+ via: resolved.via.value,
982
+ settings: settingsMeta(resolved),
983
+ validation: member.validation,
984
+ operation: {
985
+ kind: "execute",
986
+ objectCount: 1,
987
+ request: requestSummary(resolved),
988
+ auth: {
989
+ username: resolved.auth.username,
990
+ passwordProvided: resolved.auth.passwordProvided,
991
+ },
992
+ },
993
+ };
994
+ }
995
+
996
+ function devicePassed(
997
+ resolved: ResolvedExecuteRequest,
998
+ offline: OfflineGateResult | undefined,
999
+ ): EnvelopeValidationMeta {
1000
+ return {
1001
+ enabled: true,
1002
+ source: CMR_VALIDATION_SOURCE,
1003
+ result: "passed",
1004
+ syntax: true,
1005
+ semantic:
1006
+ resolved.canonical.mode === "structured" ? true : "not-applicable",
1007
+ stages: passedStages(
1008
+ offline ?? { verdict: "pass", warnings: [] },
1009
+ CMR_VALIDATION_SOURCE,
1010
+ ),
1011
+ };
1012
+ }
1013
+
1014
+ function deviceRejected(
1015
+ resolved: ResolvedExecuteRequest,
1016
+ offline: OfflineGateResult | undefined,
1017
+ error: CentrsError,
1018
+ ): EnvelopeValidationMeta {
1019
+ const structured = resolved.canonical.mode === "structured";
1020
+ const nameRejected =
1021
+ structured && error.code === "validation/unknown-attribute";
1022
+ return {
1023
+ enabled: true,
1024
+ source: CMR_VALIDATION_SOURCE,
1025
+ result: "failed",
1026
+ ...(nameRejected
1027
+ ? { syntax: true, semantic: false }
1028
+ : {
1029
+ syntax: false,
1030
+ ...(structured ? {} : { semantic: "not-applicable" as const }),
1031
+ }),
1032
+ stages: [
1033
+ offlineStagePassed(offline?.verdict),
1034
+ { stage: "device", source: CMR_VALIDATION_SOURCE, result: "failed" },
1035
+ ],
1036
+ };
1037
+ }
1038
+
1039
+ function deviceNotReached(
1040
+ resolved: ResolvedExecuteRequest,
1041
+ offline: OfflineGateResult | undefined,
1042
+ code: string,
1043
+ ): EnvelopeValidationMeta {
1044
+ if (!resolved.validate.value) return disabledValidationMeta();
1045
+ return {
1046
+ enabled: true,
1047
+ source: CMR_VALIDATION_SOURCE,
1048
+ stages: [
1049
+ offlineStagePassed(offline?.verdict),
1050
+ {
1051
+ stage: "device",
1052
+ source: CMR_VALIDATION_SOURCE,
1053
+ result: "skipped",
1054
+ reason: notReachedReason(code),
1055
+ },
1056
+ ],
1057
+ };
1058
+ }
1059
+
1060
+ function controllerTargetMeta(
1061
+ resolved: ResolvedExecuteRequest,
1062
+ ): EnvelopeTargetMeta {
1063
+ const target = resolved.target;
1064
+ return {
1065
+ input: target.input,
1066
+ host: target.host,
1067
+ port: target.port,
1068
+ baseUrl: target.baseUrl,
1069
+ identity: target.identity,
1070
+ recordIndex: target.recordIndex,
1071
+ source: toCoreSource(target.source),
1072
+ };
1073
+ }
1074
+
1075
+ function settingsMeta(resolved: ResolvedExecuteRequest): CommonSettingsMeta {
1076
+ return {
1077
+ via: toCoreSource(resolved.via.source),
1078
+ host: toCoreSource(resolved.target.hostSource),
1079
+ port: toCoreSource(resolved.target.portSource),
1080
+ timeoutMs: toCoreSource(resolved.timeoutMs.source),
1081
+ format: toCoreSource(resolved.format.source),
1082
+ validate: toCoreSource(resolved.validate.source),
1083
+ username: resolved.auth.usernameSource
1084
+ ? toCoreSource(resolved.auth.usernameSource)
1085
+ : undefined,
1086
+ password: resolved.auth.passwordSource
1087
+ ? toCoreSource(resolved.auth.passwordSource)
1088
+ : undefined,
1089
+ };
1090
+ }
1091
+
1092
+ function requestSummary(
1093
+ resolved: ResolvedExecuteRequest,
1094
+ ): ExecuteRequestSummary {
1095
+ return {
1096
+ command: resolved.command,
1097
+ canonical: resolved.canonical,
1098
+ write: isWriteShaped(resolved.canonical),
1099
+ yes: resolved.yes,
1100
+ validate: resolved.validate.value,
1101
+ verbose: resolved.verbose,
1102
+ timeoutMs: resolved.timeoutMs.value,
1103
+ format: resolved.format.value,
1104
+ maxResultsBytes: resolved.maxResultsBytes?.value,
1105
+ };
1106
+ }
1107
+
1108
+ async function confirmCmrWrite(
1109
+ request: ExecuteCmrRequest,
1110
+ signal: AbortSignal,
1111
+ resolved: ResolvedExecuteRequest,
1112
+ controllerName: string,
1113
+ runnable: number,
1114
+ excluded: number,
1115
+ notEnforced: Warning | undefined,
1116
+ ): Promise<void> {
1117
+ if (resolved.yes) return;
1118
+ const scope = `${runnable} CMR client(s) via controller ${controllerName}${excluded > 0 ? ` (${excluded} excluded)` : ""}`;
1119
+ const policyNote = notEnforced ? ` ${notEnforced.message}` : "";
1120
+ if (request.stdinIsTty ?? process.stdin.isTTY) {
1121
+ // The prompt reads stdin and cannot see `signal`, so race it: a cancel at
1122
+ // the prompt returns here and the dispatch loop reports every member
1123
+ // `cmr/cancelled` without sending anything.
1124
+ const cancelled = new Promise<false>((resolve) => {
1125
+ if (signal.aborted) resolve(false);
1126
+ signal.addEventListener("abort", () => resolve(false), { once: true });
1127
+ });
1128
+ const confirmed = await Promise.race([
1129
+ (request.confirm ?? promptForWriteConfirmation)(
1130
+ `Run write-shaped \`${resolved.command}\` on ${scope}?${policyNote} Type yes to continue: `,
1131
+ ),
1132
+ cancelled,
1133
+ ]);
1134
+ if (confirmed || signal.aborted) return;
1135
+ }
1136
+ throw new CentrsError({
1137
+ code: "usage/confirmation-required",
1138
+ summary: `A write-shaped \`${resolved.command}\` would run on ${scope} and needs explicit confirmation.${policyNote}`,
1139
+ remediation:
1140
+ "Pass `--yes` after reviewing the selection, or answer `yes` at the TTY prompt. Clients that failed validation are never sent the command.",
1141
+ context: {
1142
+ command: resolved.command,
1143
+ routers: runnable,
1144
+ excluded,
1145
+ controller: controllerName,
1146
+ ...(notEnforced ? { callerPolicy: notEnforced.context } : {}),
1147
+ },
1148
+ });
1149
+ }
1150
+
1151
+ function defaultNonce(): string {
1152
+ return `centrs-cmr-${crypto.randomUUID().slice(0, 8)}`;
1153
+ }
1154
+
1155
+ // ---------------------------------------------------------------------------
1156
+ // Production controller: one protocol adapter to the controller
1157
+ // ---------------------------------------------------------------------------
1158
+
1159
+ function adapterController(
1160
+ resolved: ResolvedExecuteRequest,
1161
+ signal: AbortSignal,
1162
+ ): CmrController {
1163
+ const via = resolved.via.value;
1164
+ const config = (timeoutMs: number, signal?: AbortSignal) => ({
1165
+ protocol: via,
1166
+ host: resolved.target.host,
1167
+ port: resolved.target.port,
1168
+ tls: resolved.target.tls,
1169
+ baseUrl: resolved.target.baseUrl,
1170
+ username: resolved.auth.username,
1171
+ password: resolved.auth.password,
1172
+ timeoutMs,
1173
+ insecure: resolved.insecure.value,
1174
+ signal,
1175
+ });
1176
+ // Native: one session for every call (concurrent tagged commands).
1177
+ // REST: an adapter per call, so each carries its own deadline signal.
1178
+ const shared: ProtocolAdapter = createProtocolAdapter(
1179
+ config(resolved.timeoutMs.value, signal),
1180
+ );
1181
+ return {
1182
+ async devices() {
1183
+ const result = await shared.apiRequest({
1184
+ verb: "print",
1185
+ path: "/cmr/device",
1186
+ });
1187
+ const rows = Array.isArray(result.data)
1188
+ ? (result.data as Record<string, string>[])
1189
+ : [];
1190
+ return rows.map((row) => ({
1191
+ id: row[".id"] ?? "",
1192
+ ids: row["ids"] ?? "",
1193
+ identity: row["identity"] ?? "",
1194
+ address: row["address"] || undefined,
1195
+ }));
1196
+ },
1197
+ runScript(call, signal) {
1198
+ return via === "native-api"
1199
+ ? nativeRunScript(shared, call, signal)
1200
+ : restRunScript(
1201
+ (ms, s) => createProtocolAdapter(config(ms, s)),
1202
+ call,
1203
+ signal,
1204
+ );
1205
+ },
1206
+ async absence() {
1207
+ const version = await probeDeviceVersion(shared).catch(() => undefined);
1208
+ const client = (await shared
1209
+ .getSingleton("/cmr/client")
1210
+ .catch(() => undefined)) as Record<string, string> | undefined;
1211
+ return {
1212
+ version: version?.version,
1213
+ controllerIdentity: client?.["controller-identity"] || undefined,
1214
+ controllerAddress: client?.["controller-address"] || undefined,
1215
+ };
1216
+ },
1217
+ callerPolicy: () =>
1218
+ resolved.auth.username
1219
+ ? readCmrCallerPolicy(shared, resolved.auth.username)
1220
+ : Promise.resolve(undefined),
1221
+ close: () => shared.close(),
1222
+ };
1223
+ }
1224
+
1225
+ function runScriptAttributes(call: CmrScriptCall): Record<string, string> {
1226
+ return {
1227
+ numbers: call.numbers,
1228
+ labels: call.labels,
1229
+ script: call.script,
1230
+ timeout: `${Math.max(1, Math.ceil(call.timeoutMs / 1000))}s`,
1231
+ ...RUN_SCRIPT_PINNED,
1232
+ };
1233
+ }
1234
+
1235
+ function toRow(attributes: Record<string, string>): CmrScriptRow {
1236
+ return {
1237
+ device: attributes["device"] ?? "",
1238
+ status: attributes["status"] ?? "",
1239
+ output: attributes["output"] ?? "",
1240
+ section: Number(attributes[".section"] ?? 0),
1241
+ };
1242
+ }
1243
+
1244
+ function newer(
1245
+ current: CmrScriptRow | undefined,
1246
+ next: CmrScriptRow,
1247
+ ): CmrScriptRow {
1248
+ return current && current.section > next.section ? current : next;
1249
+ }
1250
+
1251
+ function deadlineSignal(signal: AbortSignal, call: CmrScriptCall): AbortSignal {
1252
+ return AbortSignal.any([
1253
+ signal,
1254
+ AbortSignal.timeout(call.timeoutMs + CMR_CALL_GRACE_MS),
1255
+ ]);
1256
+ }
1257
+
1258
+ async function nativeRunScript(
1259
+ adapter: ProtocolAdapter,
1260
+ call: CmrScriptCall,
1261
+ outer: AbortSignal,
1262
+ ): Promise<CmrScriptOutcome> {
1263
+ const signal = deadlineSignal(outer, call);
1264
+ let sent = false;
1265
+ let row: CmrScriptRow | undefined;
1266
+ try {
1267
+ for await (const reply of adapter.stream(
1268
+ {
1269
+ verb: "run",
1270
+ path: CMR_RUN_SCRIPT_PATH,
1271
+ attributes: runScriptAttributes(call),
1272
+ },
1273
+ {
1274
+ signal,
1275
+ cancelGraceMs: CMR_CALL_GRACE_MS,
1276
+ onListening: () => {
1277
+ sent = true;
1278
+ },
1279
+ },
1280
+ )) {
1281
+ if (reply.type === "re") row = newer(row, toRow(reply.attributes));
1282
+ }
1283
+ } catch (error) {
1284
+ if (isNoDevicesSelected(error)) return { selected: false, sent: false };
1285
+ return { selected: true, sent, row, error: asCmrError(error) };
1286
+ }
1287
+ if (signal.aborted && !(row && FINAL_STATUSES.has(row.status))) {
1288
+ return { selected: true, sent, row, error: abortError(outer, call) };
1289
+ }
1290
+ return { selected: true, sent, row };
1291
+ }
1292
+
1293
+ async function restRunScript(
1294
+ adapterFor: (timeoutMs: number, signal: AbortSignal) => ProtocolAdapter,
1295
+ call: CmrScriptCall,
1296
+ outer: AbortSignal,
1297
+ ): Promise<CmrScriptOutcome> {
1298
+ if (outer.aborted)
1299
+ return { selected: true, sent: false, error: cancelledError() };
1300
+ const signal = deadlineSignal(outer, call);
1301
+ const adapter = adapterFor(call.timeoutMs + CMR_CALL_GRACE_MS, signal);
1302
+ try {
1303
+ const result = await adapter.apiRequest({
1304
+ verb: "run",
1305
+ path: CMR_RUN_SCRIPT_PATH,
1306
+ attributes: runScriptAttributes(call),
1307
+ });
1308
+ let row: CmrScriptRow | undefined;
1309
+ for (const attributes of Array.isArray(result.data)
1310
+ ? (result.data as Record<string, string>[])
1311
+ : []) {
1312
+ row = newer(row, toRow(attributes));
1313
+ }
1314
+ return { selected: true, sent: true, row };
1315
+ } catch (error) {
1316
+ if (isNoDevicesSelected(error)) return { selected: false, sent: false };
1317
+ const mapped = signal.aborted ? abortError(outer, call) : asCmrError(error);
1318
+ // REST cannot say whether a failed request reached RouterOS, except when
1319
+ // the connection itself never opened.
1320
+ const neverOpened = NOT_SENT_TRANSPORT_CODES.includes(mapped.code);
1321
+ return { selected: true, sent: !neverOpened, error: mapped };
1322
+ } finally {
1323
+ await adapter.close();
1324
+ }
1325
+ }
1326
+
1327
+ function abortError(outer: AbortSignal, call: CmrScriptCall): CentrsError {
1328
+ if (outer.aborted) {
1329
+ return new CentrsError({
1330
+ code: "cmr/cancelled",
1331
+ summary: "The CMR call was cancelled.",
1332
+ remediation:
1333
+ "Cancellation does not stop a client script that already started.",
1334
+ });
1335
+ }
1336
+ return new CentrsError({
1337
+ code: "transport/timeout",
1338
+ summary: `The controller gave no final status within ${call.timeoutMs + CMR_CALL_GRACE_MS} ms.`,
1339
+ remediation: "Raise `--timeout` (CMR caps one call at ~60 s).",
1340
+ context: { timeoutMs: call.timeoutMs, graceMs: CMR_CALL_GRACE_MS },
1341
+ });
1342
+ }
1343
+
1344
+ function asCmrError(error: unknown): CentrsError {
1345
+ return error instanceof CentrsError
1346
+ ? error
1347
+ : new CentrsError({
1348
+ code: "internal/unhandled",
1349
+ summary: "A CMR controller call failed unexpectedly.",
1350
+ remediation:
1351
+ "Re-run with `--format json` to capture the structured cause.",
1352
+ cause: error,
1353
+ });
1354
+ }
1355
+
1356
+ // ---------------------------------------------------------------------------
1357
+ // Rendering
1358
+ // ---------------------------------------------------------------------------
1359
+
1360
+ /** Outer error envelope for a CMR run that failed before any member result. */
1361
+ export function buildExecuteCmrErrorEnvelope(
1362
+ request: Pick<ExecuteRequest, "targetInput" | "via">,
1363
+ error: unknown,
1364
+ ): ExecuteCmrErrorEnvelope {
1365
+ const centrsError = asCmrError(error);
1366
+ const via =
1367
+ request.via === "rest-api" || request.via === "native-api"
1368
+ ? (request.via as RouterOsProtocol)
1369
+ : null;
1370
+ return {
1371
+ ok: false,
1372
+ error: serializeCentrsError(centrsError),
1373
+ warnings: OUTER_ERROR_WARNINGS.get(centrsError) ?? [],
1374
+ tips: [],
1375
+ meta: {
1376
+ target: { input: redactTargetInput(request.targetInput) },
1377
+ via,
1378
+ settings: {},
1379
+ },
1380
+ };
1381
+ }
1382
+
1383
+ /** Text: a summary line, then each member's status and (indented) output. */
1384
+ export function renderExecuteCmrEnvelope(
1385
+ envelope: ExecuteCmrEnvelope | ExecuteCmrErrorEnvelope,
1386
+ format: "json" | "yaml" | "text",
1387
+ options: { verbose?: boolean } = {},
1388
+ ): string {
1389
+ if (format === "json") return JSON.stringify(envelope, null, 2);
1390
+ if (format === "yaml") return toYaml(envelope);
1391
+ if (!envelope.ok) return formatCentrsErrorText(envelope.error, options);
1392
+ const operation = envelope.meta.operation;
1393
+ const { total, ok, failed } = envelope.data.summary;
1394
+ const controller =
1395
+ envelope.meta.target.identity ??
1396
+ envelope.meta.target.input ??
1397
+ envelope.meta.target.host ??
1398
+ "?";
1399
+ const lines = [
1400
+ terminalSafeLine(
1401
+ `execute --cmr ${operation?.cmr.labels ?? "?"} via ${controller}: ${ok}/${total} ok, ${failed} failed`,
1402
+ ),
1403
+ ];
1404
+ for (const target of envelope.data.targets) {
1405
+ const id = target.meta.target.cmr?.id ?? "-";
1406
+ const label = target.meta.target.identity ?? "(unknown)";
1407
+ if (target.ok) {
1408
+ lines.push(terminalSafeLine(` [${id}] ok ${label}`));
1409
+ const ret = (target.data as { ret?: string }).ret ?? "";
1410
+ for (const line of ret
1411
+ .replaceAll("\r\n", "\n")
1412
+ .replace(/\n+$/, "")
1413
+ .split("\n")) {
1414
+ if (line.length > 0) lines.push(terminalSafeLine(` ${line}`));
1415
+ }
1416
+ } else {
1417
+ lines.push(
1418
+ terminalSafeLine(
1419
+ ` [${id}] FAIL ${label} [${target.error.code}] ${target.error.summary}`,
1420
+ ),
1421
+ );
1422
+ if (options.verbose && target.error.remediation) {
1423
+ lines.push(
1424
+ terminalSafeLine(` Fix: ${target.error.remediation}`),
1425
+ );
1426
+ }
1427
+ }
1428
+ for (const warning of target.warnings) {
1429
+ lines.push(
1430
+ terminalSafeLine(
1431
+ ` warning [${warning.code}]: ${warning.message}`,
1432
+ ),
1433
+ );
1434
+ }
1435
+ }
1436
+ for (const warning of envelope.warnings) {
1437
+ lines.push(
1438
+ terminalSafeLine(`warning [${warning.code}]: ${warning.message}`),
1439
+ );
1440
+ }
1441
+ return lines.join("\n");
1442
+ }