@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
@@ -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
 
@@ -265,6 +278,204 @@ the command alone (`canonicalizeExecuteCommand` + `isWriteShaped`), so the gate
265
278
  fires before any target is dialed. Per-target validation still runs because
266
279
  RouterOS schemas can differ by version.
267
280
 
281
+ A sent write is **never retried** (#448). Fan-out retries a target only for
282
+ `transport/network` and `transport/connection-closed`, and for a write only
283
+ when the failure came before the request was sent (for example while it was
284
+ being validated). A write that failed after it was sent may have run, so its
285
+ error envelope, single-target or fan-out, carries the warning
286
+ `transport/write-outcome-unknown`.
287
+
288
+ ## CMR clients through their controller (`--cmr`)
289
+
290
+ `execute <controller> --cmr --labels <expr> -- '<command>'` runs one command on
291
+ the CMR clients (RouterOS 7.26beta1+) that a RouterOS label expression selects
292
+ on the controller (#450). The `<controller>` is an ordinary single target: a
293
+ positional or CDB identity, one `--quickchr`, or `--host`. It is resolved and
294
+ authenticated as usual, and **only the controller's credentials are used**. A
295
+ client is never dialed, so a client behind NAT or reachable only through its
296
+ controller works the same way. CMR is a selection source, not a protocol:
297
+ `--via` pins the **controller** transport (native-api, the default, or
298
+ rest-api). A CMR failure never falls back to dialing a client directly.
299
+
300
+ CMR is beta-only: it first shipped in 7.26beta1, and the controller also needs
301
+ the optional `cmr` package. When the target has no `/cmr/device`, centrs reads
302
+ its version to say why. A router older than 7.26beta1 is
303
+ `routeros/version-unsupported` (`capability: cmr-controller`). A newer one
304
+ without the package is `cmr/not-a-controller`, which also names the controller
305
+ when the target is itself a CMR client.
306
+
307
+ - `--labels` is required and passed to RouterOS unchanged: a comma list where
308
+ plain labels are OR, `+label` must match, `-label` excludes, and `all` also
309
+ selects the controller. CDB selectors (`--group`/`--where`/`--all`/
310
+ `--default`/geo), several targets, and `--via mac-telnet|ssh` are
311
+ `usage/conflicting-flags`. `--labels` without `--cmr` is refused the same way.
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
318
+ identity selects it only while the controller's `/cmr auto-labels` includes
319
+ `identity` (the default `all` does). On 7.26beta1, after `auto-labels=version`
320
+ a client's row still *showed* its identity label but no longer matched it.
321
+ - **One `run-script` call per device.** On 7.26beta1, `run-script` result rows
322
+ carry no device id, and their `device` key (`identity@address`) changed
323
+ *during* a call when a client dropped. So centrs never matches rows to
324
+ devices by name. Every controller call selects exactly one `/cmr/device` row
325
+ with `numbers=<.id>`, together with `labels=<expr>`, so a device that left
326
+ the labels is refused up front instead of run.
327
+ - **Preflight, per inventory row:** the call sends `:put [:parse <command>]`
328
+ followed by `:put "<nonce>"`, or the nonce alone with `--no-validate`.
329
+ - A refusal (`no devices selected`) means the device is not a member.
330
+ - Any row means it is a member; its validation result must be **complete**
331
+ before it is classified:
332
+ - status `success`;
333
+ - the nonce as the last line;
334
+ - no `...(+N B)` clip marker;
335
+ - a non-empty return that is IL.
336
+ - A device with no answer at all (timeout, lost connection) makes the
337
+ selection incomplete: `cmr/selection-incomplete`, and nothing runs. A
338
+ partial selection is never run as if it were the whole one.
339
+ - **Binding:** `/cmr/device` is read before preflight, between preflight and
340
+ the write gate, again for each member just before its own call (the prompt
341
+ and the fan-out queue can both wait), and after dispatch. A device whose row
342
+ is gone or whose `ids` key changed is not sent the command
343
+ (`cmr/membership-changed`); a replacement row is never adopted, even when it
344
+ matches the labels. If that per-member read fails, the member is not sent the
345
+ command and its error says so (`context.dispatched: false`). The per-member
346
+ read is host-side, not atomic with the call: on 7.26beta1 a client script
347
+ cannot check its own key (`/cmr/client get local-id` reads `*****` inside
348
+ `run-script`) and `numbers=` does not take `ids`. After dispatch, a sent result is reported
349
+ as `cmr/outcome-unknown` if the key changed during the call, or if the
350
+ inventory cannot be re-read (cancelled or unreadable) to confirm which client
351
+ answered. A CMR `skipped` stays `cmr/client-disconnected`, because nothing was
352
+ sent.
353
+ - **Write gate:** one `--yes`/TTY confirmation, naming how many clients will run
354
+ it, how many were excluded, and through which controller. Clients that failed
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.
371
+ - **Dispatch:** one call per validated member, sending the input unchanged with
372
+ `timeout=` set from `--timeout` (capped at 55 s, under CMR's ~60 s per-call
373
+ ceiling). Every call pins `strategy=parallel fail-policy=continue
374
+ freeze-frame-interval=20ms`. **Never retried.**
375
+ - **Outcomes** are a `FanoutData` envelope in inventory order, with exit codes
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
380
+ output adds `cmr/output-truncated` and `meta.truncated`. `--max-results`
381
+ applies per member, as in plain execute fan-out: a member whose rendered
382
+ envelope (with its full meta, as it would render alone) exceeds it (UTF-8 bytes) becomes `input/max-results-exceeded`, and
383
+ that error says the command already ran (`context.dispatched: true`). A `failed` runtime
384
+ error maps through the usual `routeros/*` table. `skipped` →
385
+ `cmr/client-disconnected`.
386
+ - **What a client script may do:** CMR runs it with only the `read`, `write`,
387
+ `test` and `reboot` policies and no user session. A command that needs
388
+ another policy (`/user add`, `/tool sniffer`) passes `:parse` on the client
389
+ and is refused when it runs: `cmr/policy-denied`. The script stops at that
390
+ statement, but statements before it already ran and are not rolled back;
391
+ their output is in `context.output`. Sensitive
392
+ values read back as `*` or empty. Multi-line commands run every line (the
393
+ manual says single-line; 7.26beta1 ran both LF and CRLF scripts in full), and
394
+ `/export` works.
395
+ - **Unknown outcomes:** a CMR timeout (`timed out (6)`, `std failure: timeout
396
+ (13)`), a local deadline, a cancellation, or a lost call after sending all
397
+ give `cmr/outcome-unknown`. A cancellation before anything is sent, including
398
+ one that makes an inventory read fail, is `cmr/cancelled`. On 7.26beta1 the client script **kept running**
399
+ in each of these cases.
400
+ - **Validation metadata** reports what ran: `source: ":put [:parse ...] via cmr
401
+ run-script"`, the same claim the console gate makes. There is no client
402
+ `/console/inspect` stage.
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).
412
+
413
+ `/cmr/device/dashboard` is an interactive console view, refused as
414
+ `cmr/console-only`. A plain path, abbreviated or not (`/cmr/dev/dash`), is
415
+ refused before dialing. Any other spelling is found in the `:parse` return
416
+ during validation, which resolves console spacing, a relative menu change
417
+ (`/cmr/device; dashboard`), blocks and `api POST /execute` scripts, and ignores
418
+ strings and comments. With `--validate=false` only the plain path is refused.
419
+ On 7.26beta1 it closed a native API session, and over REST it held the
420
+ controller's REST service for every caller for 10–30 s.
421
+
422
+ Calling `/cmr/device/run-script` yourself, through `api` or plain `execute`,
423
+ validates only the controller request. The envelope then carries the warning
424
+ `cmr/client-script-not-validated`.
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
+
268
479
  ## mac-telnet L2 validation
269
480
 
270
481
  Decided (2026-06-06): the real-L2 integration path is `@tikoci/quickchr`'s
@@ -573,6 +573,47 @@ argument happens to be `print`, so it returns `usage/confirmation-required`.
573
573
  Every command head in a statement is checked, including `[…]` and `do={…}`
574
574
  bodies.
575
575
 
576
+ ### W4. Pure script functions are reads; an output file is not (#458)
577
+
578
+ ```bash
579
+ centrs execute $R ':put [:serialize to=json [/system/resource/print as-value]]' --via rest-api --username $U --password $P </dev/null
580
+ ```
581
+
582
+ Envelope: `ok: true` without `--yes`; `data.ret` parses as JSON whose
583
+ `version` is the router's. `:delay`, `:convert`, `:deserialize`, `:find`,
584
+ `:timestamp`, `:rndnum`, `:rndstr`, the `:to*` converters, `:retry` and `:time` are reads
585
+ too; any command inside their arguments or `{…}` bodies is still checked.
586
+ RouterOS runs only a `{…}` block in a script-typed argument and refuses a quoted
587
+ string there, so no body hides in text. `:serialize … file-name=centrs-w4`
588
+ creates a device file, so it returns `usage/confirmation-required`, as `file=`
589
+ does under a read verb. `:global`, `:set`, `:execute` and `:parse` stay writes.
590
+
591
+ ### W5. Abbreviated output-file arguments keep the write gate
592
+
593
+ ```bash
594
+ centrs execute $R '/system identity export fi=centrs-w5' --via rest-api --username $U --password $P </dev/null
595
+ centrs execute $R ':serialize to=json value=1 file-n=centrs-w5.json' --via rest-api --username $U --password $P </dev/null
596
+ ```
597
+
598
+ Both return `usage/confirmation-required` and create no file. The same
599
+ commands with `--yes` pass validation and create `centrs-w5.rsc` and
600
+ `centrs-w5.json`, respectively. Native API has the same result. Through
601
+ `centrs_execute`, an `mcp=ro` record returns `cdb/write-not-permitted` with or
602
+ without `confirm: true`, and creates no file.
603
+
604
+ RouterOS also accepts `fi "centrs-w5"` and `file-n "centrs-w5.json"`, without
605
+ `=`. They keep the same confirmation and MCP policy gates, including when
606
+ serialize's file argument follows a nested value expression.
607
+
608
+ `test/integration/execute-write-gate.test.ts` proves the refusal, absence and
609
+ confirmed creation. The export control runs on the selected channel; the
610
+ serialize control pins CHR 7.26beta1, where its file output was reproduced.
611
+ The export uses CLI script spelling: structured attribute validation requires
612
+ full argument names and rejects `fi` as `validation/unknown-attribute`.
613
+ This is a grounding version, not an introduction boundary. Unknown or
614
+ ambiguous output-file prefixes stay write-shaped until the device resolves
615
+ them.
616
+
576
617
  ### R1. A post-validation `/execute` rejection fails the overall envelope
577
618
 
578
619
  ```bash
@@ -622,6 +663,16 @@ centrs execute --group no-such-group --via rest-api --cdb-file $CDB --json -- /s
622
663
  `ok: true`, `data.summary = { total: 0, ok: 0, failed: 0 }`,
623
664
  `warnings` include `cdb/empty-group`. Exit `0`.
624
665
 
666
+ The same group with a write is an outer error, with or without `--yes`, so a
667
+ misspelled group cannot pass for a successful bulk write (#460):
668
+
669
+ ```bash
670
+ centrs execute --group no-such-group --via rest-api --cdb-file $CDB --yes --json -- '/system/identity/set name=x'
671
+ ```
672
+
673
+ `ok: false`, `error.code` `cdb/empty-group`, `error.context.write` `true`.
674
+ Exit `1`. Nothing is dialed.
675
+
625
676
  ### F3. `--where` device-class selector (subset)
626
677
 
627
678
  ```bash
@@ -763,9 +814,235 @@ transport.
763
814
  ### Q3. Repeated `--quickchr` fans out; an unknown machine is an inner failure
764
815
 
765
816
  ```bash
766
- centrs execute --quickchr $NAME --quickchr no-such-machine -- ':put 1' --json
817
+ centrs execute --quickchr $NAME --quickchr no-such-machine --json -- ':put 1'
767
818
  ```
768
819
 
769
820
  Envelope: outer `ok: true`, `data.summary = { total: 2, ok: 1, failed: 1 }`;
770
821
  the unknown machine's inner error is `quickchr/machine-not-found`. Exit code 2
771
822
  (partial).
823
+
824
+ ## CMR clients through a controller (#450)
825
+
826
+ `test/integration/execute-cmr.test.ts` runs these on a two-CHR CMR lab
827
+ (`test/integration/cmr-lab.ts`): `$CTL` is a 7.26beta1 controller with the
828
+ `cmr` package, paired with one client over a socket link. Examples CMR1–CMR7
829
+ and CMR10–CMR18 run over **both** `--via native-api` and `--via rest-api` to
830
+ the controller, with validation on. The same test first asserts the device
831
+ behaviour the design depends on (CMR-D1–D9 in the test file).
832
+
833
+ ### CMR0. A router without CMR
834
+
835
+ ```bash
836
+ centrs execute <router> --cmr --labels all -- ':put x'
837
+ ```
838
+
839
+ On RouterOS older than 7.26beta1 this is `routeros/version-unsupported` with
840
+ `context.capability` `cmr-controller` and the running version. On 7.26beta1 or
841
+ later without the `cmr` package it is `cmr/not-a-controller` with the version.
842
+ Nothing is dispatched. `test/integration/execute.test.ts` runs this on the
843
+ channel's CHR and expects the code its version calls for.
844
+
845
+ ### CMR1. A read on every client
846
+
847
+ ```bash
848
+ centrs execute --quickchr $CTL --cmr --labels all -- ':put [/system/identity/get name]'
849
+ ```
850
+
851
+ `data.summary` is `{total: 2, ok: 2}`. Each target's `data.ret` is that
852
+ device's own identity. `meta.validation.source` is `:put [:parse ...] via cmr
853
+ run-script` with `result: passed`, and `meta.target.cmr.id` is its
854
+ `/cmr/device` row.
855
+
856
+ ### CMR2. Per-client validation: a controller-only menu
857
+
858
+ ```bash
859
+ centrs execute --quickchr $CTL --cmr --labels all -- /cmr/device/print
860
+ ```
861
+
862
+ The controller runs it. The client has no `cmr` package and is rejected on
863
+ the client itself with `validation/package-missing`, so it is not sent the
864
+ command (`meta.operation.cmr.dispatched` is 1). Exit code 2.
865
+
866
+ ### CMR3. A confirmed write
867
+
868
+ ```bash
869
+ centrs execute --quickchr $CTL --cmr --labels all --yes -- '/system/note/set note="centrs-cmr3"'
870
+ ```
871
+
872
+ Both targets are `ok`, and both routers' `/system/note` reads `centrs-cmr3`.
873
+
874
+ ### CMR4. An unconfirmed write is refused before dispatch
875
+
876
+ ```bash
877
+ centrs execute --quickchr $CTL --cmr --labels all -- '/system/note/set note="centrs-cmr4"'
878
+ ```
879
+
880
+ Outer `usage/confirmation-required` naming the client count and the
881
+ controller. No note changes.
882
+
883
+ ### CMR5. A command every client rejects is never sent
884
+
885
+ ```bash
886
+ centrs execute --quickchr $CTL --cmr --labels all --yes -- '/system/note/set note="x" bogus-arg=1'
887
+ ```
888
+
889
+ Both targets are `validation/unknown-attribute`, `dispatched` is 0, and no note
890
+ changes.
891
+
892
+ ### CMR6. A CMR timeout is an unknown outcome
893
+
894
+ ```bash
895
+ centrs execute --quickchr $CTL --cmr --labels <client> --timeout 2s --yes -- ':delay 4s; :log info "<marker>"'
896
+ ```
897
+
898
+ The target is `cmr/outcome-unknown`, and `<marker>` still appears in the
899
+ client's log afterwards.
900
+
901
+ ### CMR7. Labels that match nothing
902
+
903
+ ```bash
904
+ centrs execute --quickchr $CTL --cmr --labels no-such-label -- ':put x'
905
+ ```
906
+
907
+ `ok: true`, `data.summary.total` is 0, warning `cmr/empty-selection`, exit
908
+ code 0.
909
+
910
+ The same labels with a write are an outer error, with or without `--yes`, as
911
+ RouterOS itself refuses an empty selection (#460):
912
+
913
+ ```bash
914
+ centrs execute --quickchr $CTL --cmr --labels no-such-label --yes -- '/system/note/set note="x"'
915
+ ```
916
+
917
+ `ok: false`, `error.code` `cmr/empty-selection`, `error.context.write` `true`,
918
+ exit code 1. No note changes.
919
+
920
+ ### CMR8. CLI exit code for a mixed result
921
+
922
+ CMR2 through `runCli` exits `2`.
923
+
924
+ ### CMR9. A client that is down
925
+
926
+ With the client VM stopped and the controller showing it `connected=false`:
927
+
928
+ ```bash
929
+ centrs execute --quickchr $CTL --cmr --labels <client> -- ':put x'
930
+ ```
931
+
932
+ The target is `cmr/client-disconnected` and `dispatched` is 0.
933
+
934
+ ### CMR10. Cancelling after dispatch is an unknown outcome
935
+
936
+ A library caller aborts `signal` two seconds into:
937
+
938
+ ```bash
939
+ centrs execute --quickchr $CTL --cmr --labels <client> --timeout 20s --yes -- ':delay 6s; :log info "<marker>"'
940
+ ```
941
+
942
+ The target is `cmr/outcome-unknown` with `dispatched` 1. `<marker>` is not in
943
+ the client's log when the call returns, and appears afterwards: cancelling
944
+ the controller call did not stop the client script.
945
+
946
+ ### CMR11. A command needing a policy CMR scripts lack
947
+
948
+ ```bash
949
+ centrs execute --quickchr $CTL --cmr --labels <client> --yes -- '/user/add name=<user> group=read password=<x>'
950
+ ```
951
+
952
+ Validation passes on the client (`:parse` accepts it), then the client refuses
953
+ it when it runs: the target is `cmr/policy-denied`, and `<user>` does not exist
954
+ on the client afterwards.
955
+
956
+ ### CMR12. A multi-line command
957
+
958
+ ```bash
959
+ centrs execute --quickchr $CTL --cmr --labels <client> -- $':put centrs-l1\n:put centrs-l2'
960
+ ```
961
+
962
+ The target is `ok` and `data.ret` holds both lines.
963
+
964
+ ### CMR11b. A refused statement after others have run
965
+
966
+ ```bash
967
+ centrs execute --quickchr $CTL --cmr --labels <client> --yes -- $':put before\n/system/note/set note="<m>-before"\n/user/add name=<user> group=read password=<x>\n/system/note/set note="<m>-after"'
968
+ ```
969
+
970
+ The target is `cmr/policy-denied`, and its `context.output` starts with
971
+ `before` followed by the `not enough permissions (9)` line. The client's note
972
+ is `<m>-before`: the statement before the refused one ran and stays, and the
973
+ script stopped at the refused one. `<user>` does not exist.
974
+
975
+ ### CMR14. `--cmr` pointed at a client
976
+
977
+ ```bash
978
+ centrs execute --quickchr <client> --cmr --labels all -- ':put x'
979
+ ```
980
+
981
+ `cmr/not-a-controller`: the client runs 7.26beta1 but has no `cmr` package.
982
+ `context.controllerIdentity` names the controller it is paired to.
983
+
984
+ ### CMR15. A controller login without `write` or `reboot`
985
+
986
+ The controller has a group with `read,test,api,rest-api,!write,!reboot,!sensitive,!policy`
987
+ and a user `$READER` in it. That login cannot write on the controller
988
+ directly, but:
989
+
990
+ ```bash
991
+ centrs execute 127.0.0.1 --port $API --username $READER --password $PW --cmr --labels <client> --yes -- '/system/note/set note="<marker>"'
992
+ ```
993
+
994
+ The target is `ok`, the client's note reads `<marker>`, and the envelope warns
995
+ `cmr/caller-policy-not-enforced` with `context.missing` `["write", "reboot"]`:
996
+ CMR ran the script with `read,write,test,reboot` (#456). The same write as the
997
+ lab's admin login has no such warning.
998
+
999
+ ### CMR13. The dashboard behind a relative menu change
1000
+
1001
+ ```bash
1002
+ centrs execute --quickchr $CTL -- '/cmr/dev; dash'
1003
+ centrs execute --quickchr $CTL -- ':put "CMR device dashboard"'
1004
+ ```
1005
+
1006
+ The first is `cmr/console-only`: the controller's `:parse` resolves it to
1007
+ `/cmr/device/;(evl /cmr/device/dashboard)`, so it is refused before it runs.
1008
+ The second is `ok` with `data.ret` `CMR device dashboard`: a string that spells
1009
+ the words is not a command.
1010
+
1011
+ ### CMR16. A fleet command names the devices it reaches (#453)
1012
+
1013
+ ```bash
1014
+ centrs execute --quickchr $CTL -- '/cmr/device/reboot labels=all'
1015
+ ```
1016
+
1017
+ Without `--yes` (no TTY) this is `usage/confirmation-required`. Its summary
1018
+ says it would reach 2 CMR devices, the controller itself among them, and
1019
+ `error.context.cmr` is `{devices: 2, controllerIncluded: true}` with both
1020
+ identities. The envelope warns `cmr/selection-includes-controller`.
1021
+ Nothing reboots.
1022
+
1023
+ ### CMR17. A rule with no labels
1024
+
1025
+ ```bash
1026
+ centrs execute --quickchr $CTL --yes -- '/cmr/vlan/add vlan-ids=4001 disabled=yes comment=<marker>'
1027
+ ```
1028
+
1029
+ `ok: true` with the warning `cmr/rule-selects-every-device`: the rule has no
1030
+ `labels` and no `port-labels`. It is added disabled, so nothing is
1031
+ provisioned. Removing it by its `.id` warns the same way, read from the rule:
1032
+
1033
+ ```bash
1034
+ centrs execute --quickchr $CTL --yes -- '/cmr/vlan/remove numbers=<id>'
1035
+ ```
1036
+
1037
+ ### CMR18. `run-script` called directly by a login without `write` or `reboot`
1038
+
1039
+ With CMR15's restricted login:
1040
+
1041
+ ```bash
1042
+ centrs execute 127.0.0.1 --port $API --username $READER --password $PW --yes -- '/cmr/device/run-script labels=<client> script=":put centrs-cmr18"'
1043
+ ```
1044
+
1045
+ `ok: true`, with the warnings `cmr/caller-policy-not-enforced`
1046
+ (`context.missing` `["write", "reboot"]`) and
1047
+ `cmr/client-script-not-validated`. The same command as the lab's admin login
1048
+ has neither the policy warning nor `cmr/selection-includes-controller`.
@@ -392,7 +392,8 @@ bare menu path. The positional grammar stays **target-first like every other
392
392
  router-taking command** (amended in PR review — the shared resolver/selection
393
393
  helpers assume it): one positional means offline and it *is* the input; two
394
394
  positionals mean live with the router first; `--` is accepted before the
395
- input; **`--file` replaces the input positional in either form**, so with it
395
+ input, and one of explain's own long flags after it is refused as
396
+ `usage/flag-after-separator` (#459); **`--file` replaces the input positional in either form**, so with it
396
397
  every positional is a target. Adding a
397
398
  router therefore never reinterprets a previously valid offline invocation.
398
399
 
@@ -178,6 +178,17 @@ MCP server applies it as a hard allowlist:
178
178
  regardless of `confirm`; a local CDB mutation through `centrs_devices` or
179
179
  `centrs_discover` `save` fails with `usage/confirmation-required` unless
180
180
  explicitly confirmed.
181
+ - **`mcp=ro` does not rest on RouterOS policy.** On a 7.26beta1 CMR
182
+ controller, a login without `write` or `reboot` wrote to a client and to the
183
+ controller's own row through `/cmr/device/run-script`, and rebooted a client
184
+ through `run-script` and `/cmr/device/reboot`. That is the CHR reproduction
185
+ on #456; the integration test re-proves the `run-script` write (CMR15).
186
+ MikroTik [says](https://forum.mikrotik.com/t/7-26beta-development-is-released/273582/139) 7.26beta2 will refuse this for a `read` group, but
187
+ centrs' classification does not depend on it: every CMR fleet command
188
+ (`run-script`, `reboot`, `upgrade`, `pair`, `/cmr/upgrade/trigger`, …) is
189
+ write-shaped over every path, pinned by `test/unit/write-gate-tables.test.ts`,
190
+ and `mcp=ro` refuses them whatever the controller login's group is. Any
191
+ future MCP route to CMR clients (#449) must keep that assumption.
181
192
  - **`--allow-adhoc-targets`** (off by default) is reserved for one-off lab/power
182
193
  user flows. Current RouterOS-facing tools expose no inline host+credential
183
194
  schema, so the CDB allowlist remains the only active target source.
@@ -131,6 +131,12 @@ With `$NAME` registered `mcp=ro` (default):
131
131
  Envelope: `ok: false`, `error.code = "cdb/write-not-permitted"`. The router is not
132
132
  mutated even though `confirm` is true — the CDB policy is authoritative.
133
133
 
134
+ Output-file arguments count as writes even when abbreviated:
135
+ `:serialize to=json value=1 file-n=out.json` and
136
+ `/system identity export fi=out` are refused with or without `confirm`.
137
+ The CHR refusal and file read-back controls are
138
+ [execute example W5](../execute/examples.md#w5-abbreviated-output-file-arguments-keep-the-write-gate).
139
+
134
140
  ### 8. Write against an `mcp=rw` target succeeds with `confirm: true`
135
141
 
136
142
  With `$NAME` registered `mcp=rw`: