@tikoci/centrs 0.1.9-next.0 → 0.1.9-next.23

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 (88) hide show
  1. package/CHANGELOG.md +177 -40
  2. package/GLOSSARY.txt +32 -0
  3. package/README.md +12 -8
  4. package/SECURITY.md +11 -3
  5. package/commands/api/README.md +39 -15
  6. package/commands/api/examples.md +44 -6
  7. package/commands/execute/README.md +108 -14
  8. package/commands/execute/examples.md +137 -3
  9. package/commands/explain/README.md +430 -129
  10. package/commands/explain/examples.md +260 -1
  11. package/commands/mcp/README.md +11 -0
  12. package/commands/mcp/examples.md +6 -0
  13. package/commands/retrieve/README.md +30 -13
  14. package/commands/retrieve/examples.md +10 -6
  15. package/commands/transfer/examples.md +9 -0
  16. package/docs/CLI.md +8 -8
  17. package/docs/CONSTITUTION.md +29 -2
  18. package/docs/MATRIX.md +26 -13
  19. package/docs/errors/cdb/empty-group.md +19 -3
  20. package/docs/errors/cdb/empty-selection.md +9 -3
  21. package/docs/errors/cmr/caller-policy-not-enforced.md +46 -0
  22. package/docs/errors/cmr/empty-selection.md +17 -3
  23. package/docs/errors/cmr/rule-selects-every-device.md +32 -0
  24. package/docs/errors/cmr/selection-includes-controller.md +27 -0
  25. package/docs/errors/usage/flag-after-separator.md +34 -0
  26. package/package.json +10 -5
  27. package/src/api-fanout.ts +11 -1
  28. package/src/api.ts +205 -65
  29. package/src/btest.ts +7 -2
  30. package/src/cli/api.ts +30 -16
  31. package/src/cli/common.ts +180 -1
  32. package/src/cli/devices.ts +0 -2
  33. package/src/cli/discover.ts +0 -1
  34. package/src/cli/execute.ts +44 -20
  35. package/src/cli/explain.ts +2 -0
  36. package/src/cli/mcp.ts +0 -1
  37. package/src/cli/retrieve.ts +40 -18
  38. package/src/cli/settings.ts +0 -3
  39. package/src/cli/terminal.ts +0 -2
  40. package/src/cli/transfer.ts +10 -8
  41. package/src/cli/version.ts +87 -0
  42. package/src/cli.ts +5 -3
  43. package/src/core/cmr-fleet.ts +486 -0
  44. package/src/core/cmr.ts +78 -0
  45. package/src/core/envelope.ts +32 -3
  46. package/src/core/error-catalog.ts +25 -3
  47. package/src/core/execute-command.ts +144 -19
  48. package/src/core/fanout.ts +130 -6
  49. package/src/errors.ts +2 -1
  50. package/src/execute-cmr.ts +95 -78
  51. package/src/execute-fanout.ts +11 -1
  52. package/src/execute.ts +105 -23
  53. package/src/explain/args.ts +69 -8
  54. package/src/explain/blocks.ts +3 -4
  55. package/src/explain/brace-slots.ts +5 -0
  56. package/src/explain/catalog.ts +13 -6
  57. package/src/explain/defects.ts +1 -0
  58. package/src/explain/group-faults.ts +927 -0
  59. package/src/explain/highlight-projection.ts +10 -8
  60. package/src/explain/operators.ts +20 -19
  61. package/src/explain/pathresolve.ts +102 -20
  62. package/src/explain/quoted-string.ts +41 -6
  63. package/src/explain/run-tokens.ts +223 -0
  64. package/src/explain/scope-brace.ts +103 -6
  65. package/src/explain/segment.ts +41 -3
  66. package/src/explain/spaced-args.ts +116 -0
  67. package/src/explain/symbols.ts +17 -1
  68. package/src/explain/transport.ts +13 -0
  69. package/src/explain/verbsplit.ts +14 -188
  70. package/src/explain/write.ts +8 -1
  71. package/src/explain.ts +378 -18
  72. package/src/index.ts +18 -1
  73. package/src/mcp/safety.ts +11 -0
  74. package/src/mcp/tools.ts +4 -1
  75. package/src/offline-gate.ts +30 -9
  76. package/src/protocols/btest-session.ts +30 -10
  77. package/src/protocols/mac-telnet-console.ts +16 -0
  78. package/src/resolver/cdb.ts +13 -1
  79. package/src/resolver/index.ts +4 -0
  80. package/src/resolver/selection.ts +37 -1
  81. package/src/resolver/target.ts +96 -8
  82. package/src/retrieve-fanout.ts +4 -1
  83. package/src/retrieve-follow.ts +29 -33
  84. package/src/retrieve-sample.ts +14 -8
  85. package/src/retrieve-stream.ts +38 -6
  86. package/src/retrieve.ts +73 -59
  87. package/src/transfer-fanout.ts +8 -1
  88. package/src/transfer.ts +15 -19
@@ -47,12 +47,23 @@ selection**, examples F1–F5). `romon` and `winbox-terminal` remain
47
47
  write-shaped operation. In script mode every command head in a statement
48
48
  (the statement start and each `[…]` or `{…}` body) must be a read: its verb
49
49
  is the first word past the known menu prefix, so `/ip address print` is a
50
- read and `/user expire-password print` is not (examples W3). Root mutators such as `/import` and indirect
51
- execution through `:execute` are write-shaped too, and so is any command
52
- carrying `file=`: RouterOS redirects command output into a device file, so
53
- `/ip/address/export file=address` creates `address.rsc` and
54
- `/file/print file=test` creates `test.txt` even though `export` and `print`
55
- are read verbs. The shared
50
+ read and `/user expire-password print` is not (examples W3). Script
51
+ directives that only output, branch or compute a value are reads, including
52
+ the pure functions `:serialize`, `:deserialize`, `:convert`, `:delay`,
53
+ `:find`, `:timestamp`, `:rndnum`/`:rndstr` and the `:to*` converters, and the
54
+ `{…}`-bodied `:retry` and `:time` (example W4, #458). The absolute
55
+ `/serialize` spelling counts too; a bare `serialize` keeps the menu-verb rule,
56
+ because after navigation it could name a menu command. RouterOS runs only a
57
+ `{…}` block in a script-typed argument, so every body is visible to the gate.
58
+ Root mutators such as `/import` and indirect
59
+ execution through `:execute` are write-shaped too, as are `:global`, `:set`
60
+ and `:parse`, and so is any command carrying `file=` or `file-name=`
61
+ (including argument prefixes such as `fi=` and `file-n=`, and the spaced
62
+ `file-name value` spelling under print/export/serialize, example W5):
63
+ RouterOS redirects output into a device file, so
64
+ `/ip/address/export file=address` creates `address.rsc`,
65
+ `/file/print file=test` creates `test.txt` and `:serialize … file-name=x`
66
+ creates `x`, even though `export`, `print` and `:serialize` are reads. The shared
56
67
  `rosetta`/`lsp-routeros-ts`
57
68
  canonicalizer is for canonicalization only — never the structured predicate
58
69
  (widening `structured` is a product regression). Pinned by
@@ -180,7 +191,9 @@ duplicate that table. Behavior notes the generated reference cannot carry:
180
191
  - `--` — use it when the command contains tokens that would otherwise be
181
192
  claimed as centrs flags; otherwise the command must be quoted as a single
182
193
  argument. In fan-out, positional targets come BEFORE `--` and the command
183
- AFTER it (see **Target selection**).
194
+ AFTER it (see **Target selection**). centrs flags go before `--` too: one of
195
+ execute's own long flags after it (a trailing `--json`) is refused before
196
+ dialing as `usage/flag-after-separator`, with the corrected line (#459).
184
197
 
185
198
  ### Designed, not implemented
186
199
 
@@ -296,8 +309,12 @@ when the target is itself a CMR client.
296
309
  selects the controller. CDB selectors (`--group`/`--where`/`--all`/
297
310
  `--default`/geo), several targets, and `--via mac-telnet|ssh` are
298
311
  `usage/conflicting-flags`. `--labels` without `--cmr` is refused the same way.
299
- Labels that no device has are accepted silently by RouterOS; an empty
300
- selection is `ok: true` with the warning `cmr/empty-selection`. A device's
312
+ Labels that no device has are accepted silently by RouterOS. An empty
313
+ selection is `ok: true` with the warning `cmr/empty-selection` for a read,
314
+ and the outer error `cmr/empty-selection` for a write-shaped command, with
315
+ or without `--yes` (#460), as RouterOS refuses it (`no devices selected`). A
316
+ mistyped label then exits 1 instead of looking like a successful bulk write.
317
+ A device's
301
318
  identity selects it only while the controller's `/cmr auto-labels` includes
302
319
  `identity` (the default `all` does). On 7.26beta1, after `auto-labels=version`
303
320
  a client's row still *showed* its identity label but no longer matched it.
@@ -336,16 +353,33 @@ when the target is itself a CMR client.
336
353
  - **Write gate:** one `--yes`/TTY confirmation, naming how many clients will run
337
354
  it, how many were excluded, and through which controller. Clients that failed
338
355
  validation are never sent the command.
356
+ - **The controller login is not the limit (#456).** On 7.26beta1, RouterOS
357
+ does not check the caller's policy against a CMR script. `run-script`
358
+ needed only `read` + `api` on the controller login, and
359
+ `/cmr/device/reboot` only `read` + `test` + `api`. A login refused a direct
360
+ write (`not enough permissions (9)`) still wrote to clients and to the
361
+ controller's own row through `run-script`, and rebooted a client through
362
+ both. So a "reader" login on a 7.26beta1 controller is not read-only, and
363
+ centrs' write gate (and `mcp=ro` on MCP) is the only gate. MikroTik
364
+ [says](https://forum.mikrotik.com/t/7-26beta-development-is-released/273582/139) 7.26beta2 refuses this for a `read` group; that is not yet
365
+ checked on a beta2 build. For a write,
366
+ centrs reads the login's own `/user` row and `/user/group` once (both are
367
+ readable with just `read` + `api`). When the login lacks `write`, `test` or
368
+ `reboot`, the result and the confirmation error carry
369
+ `cmr/caller-policy-not-enforced` naming what it lacks. A failed read only
370
+ loses the warning.
339
371
  - **Dispatch:** one call per validated member, sending the input unchanged with
340
372
  `timeout=` set from `--timeout` (capped at 55 s, under CMR's ~60 s per-call
341
373
  ceiling). Every call pins `strategy=parallel fail-policy=continue
342
374
  freeze-frame-interval=20ms`. **Never retried.**
343
375
  - **Outcomes** are a `FanoutData` envelope in inventory order, with exit codes
344
- 0/2/1. Each member's `meta.target.cmr` carries `{controller, id, deviceKey,
345
- address}`. `success` → `data.ret` holds the client's output; a clipped
376
+ 0/2/1. The outer `meta.target` is the controller, and the settings,
377
+ request and login every member shares are written once on the outer
378
+ envelope ([constitution](../../docs/CONSTITUTION.md#result-envelope)). Each
379
+ member's `meta.target.cmr` carries `{id, deviceKey, address}`. `success` → `data.ret` holds the client's output; a clipped
346
380
  output adds `cmr/output-truncated` and `meta.truncated`. `--max-results`
347
381
  applies per member, as in plain execute fan-out: a member whose rendered
348
- envelope exceeds it (UTF-8 bytes) becomes `input/max-results-exceeded`, and
382
+ envelope (with its full meta, as it would render alone) exceeds it (UTF-8 bytes) becomes `input/max-results-exceeded`, and
349
383
  that error says the command already ran (`context.dispatched: true`). A `failed` runtime
350
384
  error maps through the usual `routeros/*` table. `skipped` →
351
385
  `cmr/client-disconnected`.
@@ -366,8 +400,15 @@ when the target is itself a CMR client.
366
400
  - **Validation metadata** reports what ran: `source: ":put [:parse ...] via cmr
367
401
  run-script"`, the same claim the console gate makes. There is no client
368
402
  `/console/inspect` stage.
369
- - **Not on MCP** until delegated authorization is designed (#449). `retrieve
370
- --cmr` is deferred (#441).
403
+ - **No progress stream.** `run-script` returns a client's output only when its
404
+ script ends, and no `/cmr` menu records a running script. To watch a long
405
+ CMR job, follow what it changes on a client:
406
+ `centrs retrieve <client> /ip/address --follow` while
407
+ `execute <controller> --cmr` adds addresses. That only works when the client
408
+ can also be reached directly; otherwise re-run a read through `--cmr`.
409
+ - **Not on MCP** until delegated authorization is designed (#449). That design
410
+ must not assume RouterOS refuses a fleet write for a `!write` login (see
411
+ above). `retrieve --cmr` is deferred (#441).
371
412
 
372
413
  `/cmr/device/dashboard` is an interactive console view, refused as
373
414
  `cmr/console-only`. A plain path, abbreviated or not (`/cmr/dev/dash`), is
@@ -382,6 +423,59 @@ Calling `/cmr/device/run-script` yourself, through `api` or plain `execute`,
382
423
  validates only the controller request. The envelope then carries the warning
383
424
  `cmr/client-script-not-validated`.
384
425
 
426
+ ### Fleet commands run directly (#453)
427
+
428
+ A controller's fleet commands are one request to one router that act on many
429
+ devices: `/cmr/device/{reboot,upgrade,pair,run-script}`,
430
+ `/cmr/upgrade/trigger`, `/cmr/upgrade/job/run-next`, and `add`, `set`,
431
+ `remove`, `enable` or `disable` on `/cmr/vlan`, `/cmr/wifi`,
432
+ `/cmr/wifi/radio` and `/cmr/upgrade`. They are write-shaped, so they need
433
+ `--yes`. Before that gate, plain `execute` (and `api`) reads the controller's
434
+ `/cmr/device` inventory, read-only, and resolves which rows the command
435
+ reaches, where it can (see "Not resolved" below). The command still runs
436
+ exactly as typed.
437
+
438
+ - **The gate names the reach.** The prompt and the `usage/confirmation-required`
439
+ summary say how many CMR devices the command reaches and whether the
440
+ controller's own row (`controller=true`) is one of them. `error.context.cmr`
441
+ has `devices`, `connected`, `controllerIncluded`, `identities` and
442
+ `inventory`.
443
+ - **How the selection is resolved.** `numbers` matches a row's `.id` or
444
+ identity. `labels` follows the manual's rules over the row's `labels` and
445
+ `auto-labels`: plain labels are OR, `+label` must match, `-label` excludes,
446
+ `all` selects every row, and an expression with only exclusions starts from
447
+ every row. On a 7.26beta1 lab this matched the device's own selection
448
+ (a `run-script` probe per row) for every expression tried; the lab test
449
+ re-checks it (CMR-D9). It is the controller's current view: a disconnected
450
+ device's `auto-labels` can be stale. `/cmr/upgrade/trigger` reaches the rows
451
+ whose `upgrade-rule` it names. A rule's `set`, `remove`, `enable` or
452
+ `disable` reads the rule's own `labels`; a `set` that changes `labels`
453
+ reaches the old and the new devices. WiFi band labels (`+2ghz`, `+5ghz`,
454
+ `+6ghz`) pick radios, not devices, so they are left out.
455
+ - **Warnings, with or without `--yes`:**
456
+ - `cmr/selection-includes-controller`: the selection includes the
457
+ controller's own row (`labels=all` does, per the manual).
458
+ - `cmr/rule-selects-every-device`: a rule command with no device `labels`
459
+ (for `/cmr/vlan`, no `port-labels` either). The manual warns that such a
460
+ VLAN rule provisions every port of every connected device, the controller
461
+ included, and cuts the fleet's management access.
462
+ - `cmr/empty-selection`: by the controller's inventory now, nothing is
463
+ selected. RouterOS refuses this for a `/cmr/device` command; it accepts
464
+ a rule whose labels match nothing.
465
+ - `cmr/caller-policy-not-enforced` for `run-script` and `reboot`, as for
466
+ `--cmr` above (#456).
467
+ - **Not resolved:** an abbreviated selector argument (`lab=all`),
468
+ `--via ssh` or `mac-telnet`, `/cmr/upgrade/job/run-next` (a job's devices
469
+ are not in the inventory), and a failed read. The gate still names the
470
+ fleet command and says why its reach is unknown, for example `would reach
471
+ the CMR devices it selects through controller ctl (not resolved: an upgrade
472
+ job's devices are not in the inventory)`, and `error.context.cmr` has
473
+ `unresolved` instead of the counts. A failed read never blocks the command,
474
+ and after a transport or login failure the policy read is skipped. Two
475
+ cases are not recognized as fleet commands at all and keep the plain
476
+ wording: script-shaped input (a positional id or `[find …]`) and CDB fan-out
477
+ to several controllers, which has its own gate.
478
+
385
479
  ## mac-telnet L2 validation
386
480
 
387
481
  Decided (2026-06-06): the real-L2 integration path is `@tikoci/quickchr`'s
@@ -530,6 +530,25 @@ has no `result`, `syntax` or `semantic`. Its stages are `offline: passed`, then
530
530
  `device: skipped` with `reason: "not reached: transport/auth-failed"`. RouterOS
531
531
  answered 401 before `:parse` could give a verdict.
532
532
 
533
+ ### V9. A stray `}` that RouterOS would silently discard is refused (#504)
534
+
535
+ A `}` or `]` that closes nothing at the top level makes RouterOS accept the
536
+ script and stop reading there: everything from that byte on is dropped without a
537
+ word. `/system identity set name=a}` sets the identity to `a`. `explain` reports
538
+ it as a `warning`, because nothing is rejected (`commands/explain/examples.md`
539
+ example 42). The gate refuses that one warning by code, because the script the
540
+ device would run is not the one that was written.
541
+
542
+ ```bash
543
+ centrs execute $R ':put "first"};:put "second"' --via rest-api --username $U --password $P --json
544
+ ```
545
+
546
+ Envelope: `ok: false`, `error.code=validation/syntax`,
547
+ `error.context.validationStage="offline"`, `error.context.span` covering the `}`
548
+ through the end of the input, and
549
+ `error.context.diagnostics[0].code="explain/canonicalizer/discarded-close"`. With
550
+ `--validate=false` the device runs it and prints only `first`.
551
+
533
552
  ## Write authorization and returned runtime faults
534
553
 
535
554
  These regressions are transport-independent and run in
@@ -573,6 +592,47 @@ argument happens to be `print`, so it returns `usage/confirmation-required`.
573
592
  Every command head in a statement is checked, including `[…]` and `do={…}`
574
593
  bodies.
575
594
 
595
+ ### W4. Pure script functions are reads; an output file is not (#458)
596
+
597
+ ```bash
598
+ centrs execute $R ':put [:serialize to=json [/system/resource/print as-value]]' --via rest-api --username $U --password $P </dev/null
599
+ ```
600
+
601
+ Envelope: `ok: true` without `--yes`; `data.ret` parses as JSON whose
602
+ `version` is the router's. `:delay`, `:convert`, `:deserialize`, `:find`,
603
+ `:timestamp`, `:rndnum`, `:rndstr`, the `:to*` converters, `:retry` and `:time` are reads
604
+ too; any command inside their arguments or `{…}` bodies is still checked.
605
+ RouterOS runs only a `{…}` block in a script-typed argument and refuses a quoted
606
+ string there, so no body hides in text. `:serialize … file-name=centrs-w4`
607
+ creates a device file, so it returns `usage/confirmation-required`, as `file=`
608
+ does under a read verb. `:global`, `:set`, `:execute` and `:parse` stay writes.
609
+
610
+ ### W5. Abbreviated output-file arguments keep the write gate
611
+
612
+ ```bash
613
+ centrs execute $R '/system identity export fi=centrs-w5' --via rest-api --username $U --password $P </dev/null
614
+ centrs execute $R ':serialize to=json value=1 file-n=centrs-w5.json' --via rest-api --username $U --password $P </dev/null
615
+ ```
616
+
617
+ Both return `usage/confirmation-required` and create no file. The same
618
+ commands with `--yes` pass validation and create `centrs-w5.rsc` and
619
+ `centrs-w5.json`, respectively. Native API has the same result. Through
620
+ `centrs_execute`, an `mcp=ro` record returns `cdb/write-not-permitted` with or
621
+ without `confirm: true`, and creates no file.
622
+
623
+ RouterOS also accepts `fi "centrs-w5"` and `file-n "centrs-w5.json"`, without
624
+ `=`. They keep the same confirmation and MCP policy gates, including when
625
+ serialize's file argument follows a nested value expression.
626
+
627
+ `test/integration/execute-write-gate.test.ts` proves the refusal, absence and
628
+ confirmed creation. The export control runs on the selected channel; the
629
+ serialize control pins CHR 7.26beta1, where its file output was reproduced.
630
+ The export uses CLI script spelling: structured attribute validation requires
631
+ full argument names and rejects `fi` as `validation/unknown-attribute`.
632
+ This is a grounding version, not an introduction boundary. Unknown or
633
+ ambiguous output-file prefixes stay write-shaped until the device resolves
634
+ them.
635
+
576
636
  ### R1. A post-validation `/execute` rejection fails the overall envelope
577
637
 
578
638
  ```bash
@@ -622,6 +682,16 @@ centrs execute --group no-such-group --via rest-api --cdb-file $CDB --json -- /s
622
682
  `ok: true`, `data.summary = { total: 0, ok: 0, failed: 0 }`,
623
683
  `warnings` include `cdb/empty-group`. Exit `0`.
624
684
 
685
+ The same group with a write is an outer error, with or without `--yes`, so a
686
+ misspelled group cannot pass for a successful bulk write (#460):
687
+
688
+ ```bash
689
+ centrs execute --group no-such-group --via rest-api --cdb-file $CDB --yes --json -- '/system/identity/set name=x'
690
+ ```
691
+
692
+ `ok: false`, `error.code` `cdb/empty-group`, `error.context.write` `true`.
693
+ Exit `1`. Nothing is dialed.
694
+
625
695
  ### F3. `--where` device-class selector (subset)
626
696
 
627
697
  ```bash
@@ -763,7 +833,7 @@ transport.
763
833
  ### Q3. Repeated `--quickchr` fans out; an unknown machine is an inner failure
764
834
 
765
835
  ```bash
766
- centrs execute --quickchr $NAME --quickchr no-such-machine -- ':put 1' --json
836
+ centrs execute --quickchr $NAME --quickchr no-such-machine --json -- ':put 1'
767
837
  ```
768
838
 
769
839
  Envelope: outer `ok: true`, `data.summary = { total: 2, ok: 1, failed: 1 }`;
@@ -775,9 +845,9 @@ the unknown machine's inner error is `quickchr/machine-not-found`. Exit code 2
775
845
  `test/integration/execute-cmr.test.ts` runs these on a two-CHR CMR lab
776
846
  (`test/integration/cmr-lab.ts`): `$CTL` is a 7.26beta1 controller with the
777
847
  `cmr` package, paired with one client over a socket link. Examples CMR1–CMR7
778
- and CMR10–CMR14 run over **both** `--via native-api` and `--via rest-api` to
848
+ and CMR10–CMR18 run over **both** `--via native-api` and `--via rest-api` to
779
849
  the controller, with validation on. The same test first asserts the device
780
- behaviour the design depends on (CMR-D1–D8 in the test file).
850
+ behaviour the design depends on (CMR-D1–D9 in the test file).
781
851
 
782
852
  ### CMR0. A router without CMR
783
853
 
@@ -856,6 +926,16 @@ centrs execute --quickchr $CTL --cmr --labels no-such-label -- ':put x'
856
926
  `ok: true`, `data.summary.total` is 0, warning `cmr/empty-selection`, exit
857
927
  code 0.
858
928
 
929
+ The same labels with a write are an outer error, with or without `--yes`, as
930
+ RouterOS itself refuses an empty selection (#460):
931
+
932
+ ```bash
933
+ centrs execute --quickchr $CTL --cmr --labels no-such-label --yes -- '/system/note/set note="x"'
934
+ ```
935
+
936
+ `ok: false`, `error.code` `cmr/empty-selection`, `error.context.write` `true`,
937
+ exit code 1. No note changes.
938
+
859
939
  ### CMR8. CLI exit code for a mixed result
860
940
 
861
941
  CMR2 through `runCli` exits `2`.
@@ -920,6 +1000,21 @@ centrs execute --quickchr <client> --cmr --labels all -- ':put x'
920
1000
  `cmr/not-a-controller`: the client runs 7.26beta1 but has no `cmr` package.
921
1001
  `context.controllerIdentity` names the controller it is paired to.
922
1002
 
1003
+ ### CMR15. A controller login without `write` or `reboot`
1004
+
1005
+ The controller has a group with `read,test,api,rest-api,!write,!reboot,!sensitive,!policy`
1006
+ and a user `$READER` in it. That login cannot write on the controller
1007
+ directly, but:
1008
+
1009
+ ```bash
1010
+ centrs execute 127.0.0.1 --port $API --username $READER --password $PW --cmr --labels <client> --yes -- '/system/note/set note="<marker>"'
1011
+ ```
1012
+
1013
+ The target is `ok`, the client's note reads `<marker>`, and the envelope warns
1014
+ `cmr/caller-policy-not-enforced` with `context.missing` `["write", "reboot"]`:
1015
+ CMR ran the script with `read,write,test,reboot` (#456). The same write as the
1016
+ lab's admin login has no such warning.
1017
+
923
1018
  ### CMR13. The dashboard behind a relative menu change
924
1019
 
925
1020
  ```bash
@@ -931,3 +1026,42 @@ The first is `cmr/console-only`: the controller's `:parse` resolves it to
931
1026
  `/cmr/device/;(evl /cmr/device/dashboard)`, so it is refused before it runs.
932
1027
  The second is `ok` with `data.ret` `CMR device dashboard`: a string that spells
933
1028
  the words is not a command.
1029
+
1030
+ ### CMR16. A fleet command names the devices it reaches (#453)
1031
+
1032
+ ```bash
1033
+ centrs execute --quickchr $CTL -- '/cmr/device/reboot labels=all'
1034
+ ```
1035
+
1036
+ Without `--yes` (no TTY) this is `usage/confirmation-required`. Its summary
1037
+ says it would reach 2 CMR devices, the controller itself among them, and
1038
+ `error.context.cmr` is `{devices: 2, controllerIncluded: true}` with both
1039
+ identities. The envelope warns `cmr/selection-includes-controller`.
1040
+ Nothing reboots.
1041
+
1042
+ ### CMR17. A rule with no labels
1043
+
1044
+ ```bash
1045
+ centrs execute --quickchr $CTL --yes -- '/cmr/vlan/add vlan-ids=4001 disabled=yes comment=<marker>'
1046
+ ```
1047
+
1048
+ `ok: true` with the warning `cmr/rule-selects-every-device`: the rule has no
1049
+ `labels` and no `port-labels`. It is added disabled, so nothing is
1050
+ provisioned. Removing it by its `.id` warns the same way, read from the rule:
1051
+
1052
+ ```bash
1053
+ centrs execute --quickchr $CTL --yes -- '/cmr/vlan/remove numbers=<id>'
1054
+ ```
1055
+
1056
+ ### CMR18. `run-script` called directly by a login without `write` or `reboot`
1057
+
1058
+ With CMR15's restricted login:
1059
+
1060
+ ```bash
1061
+ centrs execute 127.0.0.1 --port $API --username $READER --password $PW --yes -- '/cmr/device/run-script labels=<client> script=":put centrs-cmr18"'
1062
+ ```
1063
+
1064
+ `ok: true`, with the warnings `cmr/caller-policy-not-enforced`
1065
+ (`context.missing` `["write", "reboot"]`) and
1066
+ `cmr/client-script-not-validated`. The same command as the lab's admin login
1067
+ has neither the policy warning nor `cmr/selection-includes-controller`.