@tikoci/centrs 0.1.8 → 0.1.9-next.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 (38) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +4 -0
  3. package/commands/api/README.md +5 -0
  4. package/commands/execute/README.md +117 -0
  5. package/commands/execute/examples.md +162 -0
  6. package/commands/retrieve/README.md +2 -1
  7. package/docs/CLI.md +2 -0
  8. package/docs/CONSTITUTION.md +29 -1
  9. package/docs/MATRIX.md +19 -3
  10. package/docs/errors/cmr/cancelled.md +15 -0
  11. package/docs/errors/cmr/client-disconnected.md +19 -0
  12. package/docs/errors/cmr/client-script-not-validated.md +22 -0
  13. package/docs/errors/cmr/console-only.md +29 -0
  14. package/docs/errors/cmr/empty-selection.md +19 -0
  15. package/docs/errors/cmr/membership-changed.md +20 -0
  16. package/docs/errors/cmr/not-a-controller.md +26 -0
  17. package/docs/errors/cmr/outcome-unknown.md +29 -0
  18. package/docs/errors/cmr/output-truncated.md +16 -0
  19. package/docs/errors/cmr/policy-denied.md +31 -0
  20. package/docs/errors/cmr/selection-incomplete.md +18 -0
  21. package/docs/errors/cmr/validation-incomplete.md +23 -0
  22. package/docs/errors/routeros/version-unsupported.md +4 -0
  23. package/docs/errors/transport/write-outcome-unknown.md +24 -0
  24. package/docs/errors/usage/missing-labels.md +19 -0
  25. package/package.json +1 -1
  26. package/src/api-fanout.ts +7 -1
  27. package/src/api.ts +25 -6
  28. package/src/cli/execute.ts +132 -1
  29. package/src/core/cmr.ts +104 -0
  30. package/src/core/envelope.ts +16 -0
  31. package/src/core/error-catalog.ts +71 -0
  32. package/src/core/fanout.ts +77 -8
  33. package/src/errors.ts +1 -0
  34. package/src/execute-cmr.ts +1425 -0
  35. package/src/execute-fanout.ts +4 -0
  36. package/src/execute.ts +68 -17
  37. package/src/index.ts +17 -0
  38. package/src/protocols/adapter.ts +54 -12
@@ -0,0 +1,29 @@
1
+ # `cmr/outcome-unknown`
2
+
3
+ `execute --cmr` sent the command to a CMR client, but no final status came back
4
+ for it. The command **may have run, may still be running, or may not have
5
+ run**.
6
+
7
+ ## Typical trigger
8
+
9
+ - CMR reported `failed` with `timed out (6)` (the `timeout=` centrs passes, from
10
+ `--timeout`) or `std failure: timeout (13)` (CMR's own ~60 s ceiling per call).
11
+ - centrs stopped waiting: its local deadline passed, the operation was
12
+ cancelled, or the controller connection dropped after the request was sent.
13
+ - The client changed its CMR key (`ids`) during the call, or the controller's
14
+ inventory could not be re-read afterwards to confirm which client answered.
15
+
16
+ On RouterOS 7.26beta1, a timed-out or cancelled `run-script` **kept running on
17
+ the client**, and its side effects landed afterwards (#441, #450). A timeout is
18
+ not a rollback.
19
+
20
+ ## Fix
21
+
22
+ - Check the client's state before doing anything else. Read the menu the
23
+ command changed, directly or through the controller.
24
+ - Do not re-run a command that is not idempotent until you know it did not run.
25
+ centrs never retries a CMR dispatch.
26
+ - For long work, raise `--timeout` (up to 55 s per call). Anything needing more
27
+ than CMR's ~60 s ceiling belongs in a script or scheduler entry on the client.
28
+
29
+ See [`commands/execute/README.md`](../../../commands/execute/README.md) → CMR.
@@ -0,0 +1,16 @@
1
+ # `cmr/output-truncated`
2
+
3
+ A warning: CMR clipped a client's command output at its cap (about 60 KiB on
4
+ 7.26beta1) and appended `...(+N B)`. The command **completed**; only the
5
+ returned text is incomplete. The envelope's `meta.truncated` records it.
6
+
7
+ ## Typical trigger
8
+
9
+ A command that prints a lot, such as a large `print` or `/export`, run with
10
+ `execute --cmr`.
11
+
12
+ ## Fix
13
+
14
+ Narrow the output on the client: `print` with `where` and `proplist=`, or
15
+ `:put` only the fields you need. For a full configuration, save it to a file on
16
+ the client instead of printing it.
@@ -0,0 +1,31 @@
1
+ # `cmr/policy-denied`
2
+
3
+ A CMR client ran the command and refused one of its statements with
4
+ `not enough permissions (9)`.
5
+
6
+ CMR runs `run-script` scripts on each client with only the `read`, `write`,
7
+ `test` and `reboot` policies, and without a user session. Validation cannot
8
+ catch this: the client's `:parse` accepts the command, and the policy check
9
+ happens only when it runs.
10
+
11
+ The refused statement did not run, and the script stopped there. Statements
12
+ before it **did** run: CMR does not roll them back. Their output, followed by
13
+ the diagnostic, is in `context.output`.
14
+
15
+ ## Typical trigger
16
+
17
+ - `/user add`, `/user group add` (needs `policy`).
18
+ - `/tool sniffer` (needs `sniff`).
19
+
20
+ Reading a sensitive value is not this error: it succeeds and returns `*` or an
21
+ empty value.
22
+
23
+ ## Fix
24
+
25
+ Check what the earlier statements changed. Then run only the refused
26
+ statement, and the ones after it, on the device directly with a user that has
27
+ the policy:
28
+
29
+ ```sh
30
+ centrs execute <device> -- '<command>'
31
+ ```
@@ -0,0 +1,18 @@
1
+ # `cmr/selection-incomplete`
2
+
3
+ centrs asked the controller, for every `/cmr/device` row, whether it matches
4
+ `--labels`. At least one row got no answer, so the selection is not known and
5
+ **nothing was run**.
6
+
7
+ ## Typical trigger
8
+
9
+ A per-device preflight call timed out, was cancelled, or lost its connection
10
+ before the controller either refused it (`no devices selected`, so not a
11
+ member) or returned a row (a member).
12
+
13
+ ## Fix
14
+
15
+ - Check that the controller is reachable and responsive, then retry.
16
+ - Raise `--timeout` if the controller is slow.
17
+ - centrs never runs a partial selection, because a smaller set that reports
18
+ success would hide the clients it skipped.
@@ -0,0 +1,23 @@
1
+ # `cmr/validation-incomplete`
2
+
3
+ The validation result from a CMR client was incomplete, so the command was
4
+ **not** sent to it. The other members are unaffected.
5
+
6
+ ## Typical trigger
7
+
8
+ centrs validates on each client with `:put [:parse <command>]; :put "<nonce>"`
9
+ through `run-script`. The result is rejected before it is classified when:
10
+
11
+ - the status was not `success` (for example `timed out (6)`);
12
+ - the output was clipped by CMR's ~60 KiB cap (`...(+N B)`);
13
+ - the nonce line that proves the output is complete is missing;
14
+ - the `:parse` return was empty or was not parse output (IL).
15
+
16
+ An empty or clipped result is not a pass. Without the completeness check,
17
+ missing output would have read as "no diagnostic".
18
+
19
+ ## Fix
20
+
21
+ - Make sure the client is connected and responsive, then retry.
22
+ - Very large commands can exceed the output cap once `:parse` echoes them back.
23
+ Split the work into smaller commands.
@@ -9,6 +9,10 @@ error.
9
9
  Distinct from `routeros/unsupported-capability`, where the chosen *protocol*
10
10
  cannot carry the operation at all regardless of device version.
11
11
 
12
+ `execute --cmr` raises it with `capability: cmr-controller` when the target has
13
+ no `/cmr/device` and runs RouterOS older than 7.26beta1, where CMR first
14
+ shipped. That check reads the version only after the missing menu, not before.
15
+
12
16
  ## Context fields
13
17
 
14
18
  - `capability` — stable slug of the missing operation.
@@ -0,0 +1,24 @@
1
+ # `transport/write-outcome-unknown`
2
+
3
+ A warning on a failed write. The request had already been sent when the
4
+ transport failed (a dropped connection, a REST 5xx, a timeout), so the router
5
+ may have run it. The error beside this warning says how the call ended, not
6
+ whether the write happened.
7
+
8
+ ## Typical trigger
9
+
10
+ - A connection closed or reset after an `execute` or `api` write was sent.
11
+ - A REST 5xx or a timeout on a write.
12
+
13
+ centrs never retries a write that it has sent, in single-target or fan-out
14
+ mode, because replaying an `add`, a script run or a counter change would apply
15
+ it twice (#448). In fan-out, a write is retried only when a retryable
16
+ transport failure (`transport/network` or `transport/connection-closed`) ends
17
+ it *before* it was sent, for example during remote validation or a native API
18
+ login. Any other failure, sent or not, is not retried.
19
+
20
+ ## Fix
21
+
22
+ Check the router's state before running the write again. For example, read
23
+ back the menu you changed with `centrs retrieve`. Re-run only what did not
24
+ apply.
@@ -0,0 +1,19 @@
1
+ # `usage/missing-labels`
2
+
3
+ `centrs execute --cmr` was given no `--labels` expression.
4
+
5
+ ## Typical trigger
6
+
7
+ `--cmr` runs the command on the CMR clients a label expression selects, and the
8
+ expression is required so the blast radius is always explicit.
9
+
10
+ ## Fix
11
+
12
+ Pass RouterOS label syntax: a comma list where plain labels are OR, `+label`
13
+ must match, `-label` excludes, and `all` selects every device, including the
14
+ controller:
15
+
16
+ ```sh
17
+ centrs execute ctl --cmr --labels all -- ':put [/system/identity/get name]'
18
+ centrs execute ctl --cmr --labels site-a,+edge,-ctl --yes -- '/ip/dns/set servers=192.0.2.53'
19
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/centrs",
3
- "version": "0.1.8",
3
+ "version": "0.1.9-next.0",
4
4
  "main": "src/index.ts",
5
5
  "module": "src/index.ts",
6
6
  "repository": {
package/src/api-fanout.ts CHANGED
@@ -47,6 +47,7 @@ import {
47
47
  buildFanoutResolveFailure,
48
48
  commonVia,
49
49
  defaultFanoutSleep,
50
+ mayRetryUnsentWrite,
50
51
  resolveFanoutConcurrency,
51
52
  runFanout,
52
53
  summarizeFanout,
@@ -105,7 +106,10 @@ export interface ApiFanoutInternals {
105
106
  env: Record<string, string | undefined>,
106
107
  config?: Record<string, string | undefined>,
107
108
  ) => Promise<SelectionExpansion>;
108
- execute?: (resolved: ResolvedApiRequest) => Promise<ApiSuccessEnvelope>;
109
+ execute?: (
110
+ resolved: ResolvedApiRequest,
111
+ trace: ValidationTrace,
112
+ ) => Promise<ApiSuccessEnvelope>;
109
113
  sleep?: (ms: number) => Promise<void>;
110
114
  }
111
115
 
@@ -318,6 +322,8 @@ export async function apiFanout(
318
322
  ),
319
323
  member,
320
324
  ),
325
+ mayRetry: (resolved) =>
326
+ mayRetryUnsentWrite(requestSummary.write, traces.get(resolved)),
321
327
  sleep,
322
328
  });
323
329
 
package/src/api.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  * structured validation, confirmation, and result-envelope surface.
16
16
  */
17
17
 
18
+ import { assertNotCmrDashboardPath, cmrRunScriptWarnings } from "./core/cmr.ts";
18
19
  import type {
19
20
  CentrsEnvelope,
20
21
  CentrsErrorEnvelope,
@@ -28,6 +29,7 @@ import type {
28
29
  Warning,
29
30
  } from "./core/envelope.ts";
30
31
  import { buildTip, notReachedReason } from "./core/envelope.ts";
32
+ import { sentWriteWarnings } from "./core/fanout.ts";
31
33
  import {
32
34
  inspectArgumentNames,
33
35
  inspectChildrenOrEmpty,
@@ -393,6 +395,11 @@ export async function runResolvedApi(
393
395
  resolved === request
394
396
  ? protocolRequest
395
397
  : buildProtocolApiRequest(resolved),
398
+ {
399
+ onSent: () => {
400
+ trace.dispatched = true;
401
+ },
402
+ },
396
403
  );
397
404
  const routerOsFailure = apiRouterOsFailureFromResult(resolved, result);
398
405
  if (routerOsFailure) {
@@ -563,7 +570,10 @@ async function* streamResolvedApi(
563
570
  const protocolRequest = buildProtocolApiRequest(resolved);
564
571
  for await (const reply of backend.stream(protocolRequest, {
565
572
  signal: controller.signal,
566
- onListening,
573
+ onListening: () => {
574
+ trace.dispatched = true;
575
+ onListening?.();
576
+ },
567
577
  onCancelUnacknowledged: () => {
568
578
  cancelUnacknowledged = true;
569
579
  },
@@ -602,7 +612,7 @@ async function* streamResolvedApi(
602
612
  done,
603
613
  );
604
614
  } catch (error) {
605
- const envelope = buildApiErrorEnvelopeFromResolved(resolved, error);
615
+ const envelope = buildApiErrorEnvelopeFromResolved(resolved, error, trace);
606
616
  envelope.meta.validation = validation;
607
617
  envelope.warnings = [...envelope.warnings, ...cancellationWarnings()];
608
618
  envelope.tips = [...envelope.tips, ...tips];
@@ -1029,9 +1039,10 @@ export async function resolveApiRequest(
1029
1039
  insecure,
1030
1040
  yes: request.yes ?? false,
1031
1041
  verbose: request.verbose ?? false,
1032
- warnings: connection
1033
- ? [...connection.warnings]
1034
- : (cdbResolution?.warnings ?? []),
1042
+ warnings: [
1043
+ ...(connection ? connection.warnings : (cdbResolution?.warnings ?? [])),
1044
+ ...(verb === "run" ? cmrRunScriptWarnings(normalized.path) : []),
1045
+ ],
1035
1046
  };
1036
1047
  }
1037
1048
 
@@ -1718,7 +1729,14 @@ export function buildApiErrorEnvelopeFromResolved(
1718
1729
  return {
1719
1730
  ok: false,
1720
1731
  error: serializeCentrsError(centrsError),
1721
- warnings: [...resolved.warnings],
1732
+ warnings: [
1733
+ ...resolved.warnings,
1734
+ ...sentWriteWarnings(
1735
+ isApiMutating(resolved.method, resolved.path),
1736
+ centrsError,
1737
+ trace,
1738
+ ),
1739
+ ],
1722
1740
  tips: [],
1723
1741
  meta: metaFromResolved(
1724
1742
  resolved,
@@ -2020,6 +2038,7 @@ export function validateApiRequestShape(request: ApiRequest): void {
2020
2038
  });
2021
2039
  }
2022
2040
  const normalized = normalizeApiEndpoint(request.endpoint);
2041
+ assertNotCmrDashboardPath(normalized.path);
2023
2042
  if (pathTokens(normalized.path).length === 0) {
2024
2043
  throw new CentrsError({
2025
2044
  code: "input/invalid-path",
@@ -1,5 +1,9 @@
1
1
  import { fanoutExitCode } from "../core/fanout.ts";
2
- import { asCentrsError, formatCentrsErrorText } from "../errors.ts";
2
+ import {
3
+ asCentrsError,
4
+ CentrsError,
5
+ formatCentrsErrorText,
6
+ } from "../errors.ts";
3
7
  import {
4
8
  buildExecuteErrorEnvelope,
5
9
  type ExecuteOutputFormat,
@@ -8,6 +12,11 @@ import {
8
12
  executeOutputFormats,
9
13
  renderExecuteEnvelope,
10
14
  } from "../execute.ts";
15
+ import {
16
+ buildExecuteCmrErrorEnvelope,
17
+ executeCmr,
18
+ renderExecuteCmrEnvelope,
19
+ } from "../execute-cmr.ts";
11
20
  import {
12
21
  buildExecuteFanoutErrorEnvelope,
13
22
  executeFanout,
@@ -54,6 +63,17 @@ export const executeCommand: CliCommandMetadata = {
54
63
  "Pin the protocol selector; no silent downgrade when set. A bare MAC target defaults to mac-telnet.",
55
64
  },
56
65
  ...selectionCommandOptions,
66
+ {
67
+ flag: "--cmr",
68
+ description:
69
+ "Run on CMR clients through their controller: the <target> is the controller (RouterOS 7.26beta1+, `cmr` package). Needs `--labels`; native-api or rest-api only.",
70
+ },
71
+ {
72
+ flag: "--labels",
73
+ valueName: "<expr>",
74
+ description:
75
+ "With `--cmr`: RouterOS label expression selecting clients (comma list; plain = OR, `+` AND, `-` AND NOT, `all`).",
76
+ },
57
77
  {
58
78
  flag: "--host",
59
79
  valueName: "<host|url>",
@@ -147,6 +167,8 @@ export const executeCommand: CliCommandMetadata = {
147
167
 
148
168
  interface ExecuteCliArgs extends ExecuteRequest {
149
169
  help?: boolean;
170
+ cmr?: boolean;
171
+ labels?: string;
150
172
  format?: ExecuteOutputFormat;
151
173
  selectionFlags?: SelectionFlags;
152
174
  targetPositionals?: readonly string[];
@@ -194,6 +216,12 @@ export function parseExecuteCliArgs(args: readonly string[]): ExecuteCliArgs {
194
216
  case "--yes":
195
217
  parsed.yes = true;
196
218
  break;
219
+ case "--cmr":
220
+ parsed.cmr = true;
221
+ break;
222
+ case "--labels":
223
+ parsed.labels = expectValue(args, ++index, arg);
224
+ break;
197
225
  case "--validate":
198
226
  parsed.validate = true;
199
227
  break;
@@ -322,6 +350,14 @@ export async function runExecuteCli(args: readonly string[]): Promise<number> {
322
350
  selectionFlags.quickchr[0] ?? "",
323
351
  );
324
352
  }
353
+ if (parsed.cmr || parsed.labels !== undefined) {
354
+ return await runExecuteCmrCli(
355
+ parsed,
356
+ selectionFlags,
357
+ targetPositionals,
358
+ args,
359
+ );
360
+ }
325
361
  if (isFanoutMode(selectionFlags, targetPositionals.length)) {
326
362
  return await runExecuteFanoutCli(
327
363
  parsed,
@@ -450,6 +486,101 @@ async function runExecuteFanoutCli(
450
486
  }
451
487
  }
452
488
 
489
+ /**
490
+ * `execute --cmr`: the single target is the CMR controller; `--labels` picks
491
+ * the clients. Fan-out selectors would mix two selection sources, so they are
492
+ * refused rather than combined (#450).
493
+ */
494
+ async function runExecuteCmrCli(
495
+ parsed: ExecuteCliArgs,
496
+ selectionFlags: SelectionFlags,
497
+ targetPositionals: readonly string[],
498
+ args: readonly string[],
499
+ ): Promise<number> {
500
+ const format = inferExecuteFormat(args, parsed);
501
+ const controller = new AbortController();
502
+ // First Ctrl-C cancels (unsent members become `cmr/cancelled`); a second
503
+ // one exits at once in case a controller call does not end promptly.
504
+ const onSigint = (): void => {
505
+ if (controller.signal.aborted) process.exit(130);
506
+ controller.abort();
507
+ };
508
+ process.on("SIGINT", onSigint);
509
+ try {
510
+ if (!parsed.cmr) {
511
+ throw new CentrsError({
512
+ code: "usage/conflicting-flags",
513
+ summary:
514
+ "`--labels` selects CMR clients and only applies with `--cmr`.",
515
+ remediation:
516
+ "Add `--cmr` and name the controller as the target, or drop `--labels`.",
517
+ });
518
+ }
519
+ if (
520
+ hasTargetSelector({ ...selectionFlags, quickchr: [] }) ||
521
+ selectionFlags.quickchr.length > 1 ||
522
+ targetPositionals.length > 1
523
+ ) {
524
+ throw new CentrsError({
525
+ code: "usage/conflicting-flags",
526
+ summary:
527
+ "`--cmr` takes one controller target; CDB fan-out selectors and several targets cannot be combined with it.",
528
+ remediation:
529
+ "Name one controller (a positional, `--host` or one `--quickchr`) and pick clients with `--labels`.",
530
+ context: {
531
+ positionals: targetPositionals.length,
532
+ quickchr: selectionFlags.quickchr.length,
533
+ },
534
+ });
535
+ }
536
+ if (selectionFlags.quickchr.length === 1) {
537
+ parsed.quickchr = selectionFlags.quickchr[0];
538
+ }
539
+ if (
540
+ !parsed.targetInput &&
541
+ parsed.quickchr === undefined &&
542
+ parsed.host === undefined
543
+ ) {
544
+ throw missingTargetError({
545
+ command: "execute",
546
+ summary:
547
+ "`centrs execute --cmr` requires the CMR controller as its <target>.",
548
+ remediation:
549
+ 'Name the controller, e.g. `centrs execute ctl --cmr --labels all -- ":put ok"`.',
550
+ });
551
+ }
552
+ const envelope = await executeCmr({
553
+ ...parsed,
554
+ labels: parsed.labels ?? "",
555
+ concurrency: selectionFlags.concurrency,
556
+ signal: controller.signal,
557
+ });
558
+ const resolvedFormat = envelope.meta.operation?.request.format ?? format;
559
+ console.log(
560
+ renderExecuteCmrEnvelope(envelope, resolvedFormat, {
561
+ verbose: parsed.verbose ?? false,
562
+ }),
563
+ );
564
+ return fanoutExitCode(envelope);
565
+ } catch (error) {
566
+ console.error(
567
+ renderExecuteCmrEnvelope(
568
+ buildExecuteCmrErrorEnvelope(
569
+ { targetInput: parsed.targetInput, via: parsed.via },
570
+ error,
571
+ ),
572
+ format,
573
+ {
574
+ verbose: parsed.verbose ?? false,
575
+ },
576
+ ),
577
+ );
578
+ return 1;
579
+ } finally {
580
+ process.off("SIGINT", onSigint);
581
+ }
582
+ }
583
+
453
584
  function inferExecuteFormat(
454
585
  args: readonly string[],
455
586
  parsed?: ExecuteCliArgs,
@@ -0,0 +1,104 @@
1
+ /**
2
+ * CMR facts shared by the surfaces that can reach `/cmr/device/run-script`
3
+ * directly (`api`, plain `execute`) and by `execute --cmr` (#450).
4
+ */
5
+
6
+ import { CentrsError } from "../errors.ts";
7
+ import type { Warning } from "./envelope.ts";
8
+
9
+ /** The controller command that runs a script on CMR clients. */
10
+ export const CMR_RUN_SCRIPT_PATH = "/cmr/device/run-script";
11
+
12
+ /**
13
+ * The console-only live view. Grounded on 7.26beta1 CHR: over the native API
14
+ * `/cmr/device/dashboard` closed the API session; over REST it never returned,
15
+ * and every other REST call on the controller then waited 10–30 s.
16
+ */
17
+ const CMR_DASHBOARD = ["cmr", "device", "dashboard"] as const;
18
+ const DASHBOARD_PATH = "/cmr/device/dashboard";
19
+
20
+ /**
21
+ * Whether a structured RouterOS path (an `api` endpoint, or a single
22
+ * `execute` command's path and verb) is the dashboard. Each segment may be an
23
+ * abbreviation, as RouterOS accepts (`/cmr/dev/dash`). This needs no router, so
24
+ * it refuses the plain spellings before anything is dialed.
25
+ */
26
+ export function isCmrDashboardPath(path: string): boolean {
27
+ const segments = path
28
+ .split("/")
29
+ .filter((segment) => segment.length > 0)
30
+ .map((segment) => segment.toLowerCase());
31
+ return (
32
+ segments.length === CMR_DASHBOARD.length &&
33
+ segments.every((segment, k) =>
34
+ (CMR_DASHBOARD[k] as string).startsWith(segment),
35
+ )
36
+ );
37
+ }
38
+
39
+ /**
40
+ * Whether a `:parse` return invokes the dashboard. The device resolves every
41
+ * spelling to one invocation: abbreviations, console spacing, a relative menu
42
+ * change across `;` or a newline, and `:do`/`:if`/`[...]` bodies. Strings stay
43
+ * `=` values and comments are dropped (7.26beta1 CHR). An invocation follows
44
+ * the start of the IL, a `;` or another `(evl `. A string value sits after `=`.
45
+ */
46
+ export function ilInvokesCmrDashboard(il: string): boolean {
47
+ return /(?:^|;|\(evl )\(evl \/cmr\/device\/dashboard/.test(il);
48
+ }
49
+
50
+ function consoleOnlyError(input: string): CentrsError {
51
+ return new CentrsError({
52
+ code: "cmr/console-only",
53
+ summary: `\`${DASHBOARD_PATH}\` (in any spelling) is an interactive console view, not an API command. Over the native API it closes the session; over REST it holds the controller's REST service for every caller until it times out.`,
54
+ remediation:
55
+ "Open it in WinBox 4 or WebFig, or run it in `centrs terminal <controller>`. For the data behind it, read `/cmr/device`, `/cmr/alert` or `/cmr/upgrade/job` with `centrs retrieve`.",
56
+ context: { path: DASHBOARD_PATH, input },
57
+ });
58
+ }
59
+
60
+ /** Refuse a structured dashboard path before anything is dialed. */
61
+ export function assertNotCmrDashboardPath(path: string): void {
62
+ if (isCmrDashboardPath(path)) throw consoleOnlyError(path);
63
+ }
64
+
65
+ /** Refuse a command whose accepted `:parse` return invokes the dashboard. */
66
+ export function assertNotCmrDashboardIl(il: string, command: string): void {
67
+ if (ilInvokesCmrDashboard(il)) throw consoleOnlyError(command);
68
+ }
69
+
70
+ function withoutTrailingSlashes(path: string): string {
71
+ // A loop, not `/\/+$/`: that regex is quadratic on a long run of `/`.
72
+ let end = path.length;
73
+ while (end > 0 && path[end - 1] === "/") end -= 1;
74
+ return path.slice(0, end);
75
+ }
76
+
77
+ const clientScriptNotValidated: Warning = {
78
+ code: "cmr/client-script-not-validated",
79
+ message:
80
+ "Validation covered the controller request only; the `script=` sent to CMR clients was not validated on them. Use `centrs execute <controller> --cmr --labels <expr> -- '<command>'` to validate on each client first.",
81
+ context: { path: CMR_RUN_SCRIPT_PATH },
82
+ };
83
+
84
+ /**
85
+ * A caller-addressed `run-script` is validated as a controller request only:
86
+ * `/console/inspect` checks its arguments, and the `script=` value is an opaque
87
+ * string to the controller's `:parse`. The script itself then runs without validation
88
+ * on every selected client, so say so next to the `passed` gate.
89
+ */
90
+ export function cmrRunScriptWarnings(commandPath: string): Warning[] {
91
+ if (withoutTrailingSlashes(commandPath) !== CMR_RUN_SCRIPT_PATH) return [];
92
+ return [clientScriptNotValidated];
93
+ }
94
+
95
+ /**
96
+ * Script-shaped `execute` input is opaque to the canonicalizer, so a
97
+ * `run-script` inside it (console spacing, a `:do {}` body) is found by text.
98
+ * A false positive costs only a warning.
99
+ */
100
+ export function cmrScriptWarnings(script: string): Warning[] {
101
+ return /cmr[\s/]+device[\s/]+run-script\b/.test(script)
102
+ ? [clientScriptNotValidated]
103
+ : [];
104
+ }
@@ -109,6 +109,17 @@ export interface EnvelopeTargetMeta {
109
109
  source?: SettingSource;
110
110
  /** Provenance per resolved field once the resolver is wired (WP-0c). */
111
111
  sources?: Record<string, SettingSource>;
112
+ /**
113
+ * `execute --cmr`: the CMR client this result belongs to, as the controller
114
+ * knows it. `id` is the controller's `/cmr/device` row; `deviceKey` is the
115
+ * device half of its `ids`. The client is never dialed directly.
116
+ */
117
+ cmr?: {
118
+ controller: string;
119
+ id: string;
120
+ deviceKey?: string;
121
+ address?: string;
122
+ };
112
123
  }
113
124
 
114
125
  /**
@@ -153,6 +164,11 @@ export interface ValidationTrace {
153
164
  * to judge.
154
165
  */
155
166
  offlineVerdict?: "pass" | "warn";
167
+ /**
168
+ * Set immediately before the RouterOS request is sent. A failure after this
169
+ * point may have run on the router, so a write must not be replayed (#448).
170
+ */
171
+ dispatched?: boolean;
156
172
  }
157
173
 
158
174
  export interface EnvelopeValidationMeta {