@tikoci/centrs 0.1.8-next.0 → 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 (44) hide show
  1. package/CHANGELOG.md +69 -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/mcp/README.md +5 -1
  7. package/commands/mcp/examples.md +18 -1
  8. package/commands/retrieve/README.md +2 -1
  9. package/docs/CLI.md +2 -0
  10. package/docs/CONSTITUTION.md +29 -1
  11. package/docs/MATRIX.md +19 -3
  12. package/docs/errors/cmr/cancelled.md +15 -0
  13. package/docs/errors/cmr/client-disconnected.md +19 -0
  14. package/docs/errors/cmr/client-script-not-validated.md +22 -0
  15. package/docs/errors/cmr/console-only.md +29 -0
  16. package/docs/errors/cmr/empty-selection.md +19 -0
  17. package/docs/errors/cmr/membership-changed.md +20 -0
  18. package/docs/errors/cmr/not-a-controller.md +26 -0
  19. package/docs/errors/cmr/outcome-unknown.md +29 -0
  20. package/docs/errors/cmr/output-truncated.md +16 -0
  21. package/docs/errors/cmr/policy-denied.md +31 -0
  22. package/docs/errors/cmr/selection-incomplete.md +18 -0
  23. package/docs/errors/cmr/validation-incomplete.md +23 -0
  24. package/docs/errors/routeros/version-unsupported.md +4 -0
  25. package/docs/errors/transport/write-outcome-unknown.md +24 -0
  26. package/docs/errors/usage/missing-labels.md +19 -0
  27. package/package.json +1 -1
  28. package/src/api-fanout.ts +7 -1
  29. package/src/api.ts +25 -6
  30. package/src/cli/execute.ts +132 -1
  31. package/src/cli/retrieve.ts +7 -0
  32. package/src/core/cmr.ts +104 -0
  33. package/src/core/envelope.ts +16 -0
  34. package/src/core/error-catalog.ts +71 -0
  35. package/src/core/fanout.ts +77 -8
  36. package/src/errors.ts +1 -0
  37. package/src/execute-cmr.ts +1425 -0
  38. package/src/execute-fanout.ts +4 -0
  39. package/src/execute.ts +68 -17
  40. package/src/index.ts +17 -0
  41. package/src/mcp/safety.ts +43 -18
  42. package/src/mcp/tools.ts +5 -3
  43. package/src/protocols/adapter.ts +54 -12
  44. package/src/retrieve.ts +9 -4
package/CHANGELOG.md CHANGED
@@ -8,6 +8,75 @@ documenting cross-cutting shifts that affect contributors and consumers.
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ ## 0.1.9-next.0 — 2026-10-13
12
+
13
+ Pre-release (npm tag `next`; `latest` stays on 0.1.8). Install with
14
+ `bunx @tikoci/centrs@next`. First cut of `execute --cmr`; expect another
15
+ `next` after the next round of CMR work.
16
+
17
+ ### Added
18
+
19
+ - `execute <controller> --cmr --labels <expr> -- '<command>'` runs one
20
+ validated command on CMR clients through their controller (RouterOS
21
+ 7.26beta1+; #450, #451). Only the controller is dialed, with its own
22
+ credentials. Each selected client validates the command with `:parse`
23
+ first, and a client whose answer is incomplete or rejected is not sent it.
24
+ The command then goes to each client in its own `run-script` call, bound to
25
+ the `/cmr/device` row and key it was validated on, and is never retried; a
26
+ timeout, cancellation or lost call is `cmr/outcome-unknown`. One `--yes`
27
+ covers the whole selection. Native API (default) and REST to the controller
28
+ are both CHR-tested. Raw `run-script` through `api` or plain `execute` warns
29
+ `cmr/client-script-not-validated`. Not on MCP yet.
30
+ - `cmr/policy-denied`: a statement CMR scripts may not run (`/user add`,
31
+ `/tool sniffer`: the client allows only `read`, `write`, `test` and
32
+ `reboot`) is reported as such instead of a generic RouterOS failure, also
33
+ after earlier output. `:parse` accepts these commands, so validation cannot
34
+ catch them, and statements before the refused one have already run.
35
+ - `cmr/console-only`: `execute` and `api` refuse `/cmr/device/dashboard`. A
36
+ plain path is refused before dialing; any other spelling (a relative menu
37
+ change, a block, an `/execute` script) is found in the controller's `:parse`
38
+ return. On 7.26beta1 it closed a native API session, and over REST it held
39
+ the controller's REST service for every caller for 10–30 s.
40
+ - `execute --cmr` against a router with no `/cmr/device` says why. RouterOS
41
+ older than 7.26beta1 (CMR is beta-only) is `routeros/version-unsupported`
42
+ with `capability: cmr-controller`. A 7.26beta1+ router without the `cmr`
43
+ package stays `cmr/not-a-controller`, now naming its version and, for a CMR
44
+ client, its controller.
45
+ - `execute --cmr` accepts a command that opens with a relative menu change
46
+ (`/system; identity/print`); its `:parse` return was read as incomplete.
47
+
48
+ ### Fixed
49
+
50
+ - `execute` and `api` never replay a write that was sent (#448). Fan-out
51
+ retried every `transport/network` and `transport/connection-closed` failure
52
+ up to twice, so a write whose connection dropped after the router had run
53
+ it could be applied up to three times. A write is now retried only when one
54
+ of those failures came before it was sent, for example during validation or
55
+ a native API login. A write that failed after it was sent, in single-target
56
+ or fan-out mode, carries the new warning `transport/write-outcome-unknown`,
57
+ because it may have run. Reads keep the retry.
58
+
59
+ ## 0.1.8 — 2026-10-12
60
+
61
+ Release (npm tag `latest`; `next` moves forward to 0.1.8). Everything in
62
+ 0.1.8-next.0 below, plus two fixes from field-testing it.
63
+
64
+ ### Fixed
65
+
66
+ - `retrieve --wait` with no deadline value names `--wait` (#445). Written as a
67
+ bare switch, `--wait --until 'expr'` read `--until` as the deadline and
68
+ shifted the predicate into a second target, so the CLI reported
69
+ `usage/fanout-not-supported`. It now fails `settings/invalid-timeout`
70
+ ("Received the flag: --until"), and every deadline without a unit gets the
71
+ `--wait`-specific message. A real multi-target `--wait` still gets the
72
+ fanout refusal.
73
+ - MCP accepts a registered device by its `identity=`, `mac=` or `ip=` comment
74
+ key, as the CLI does (#444). The allowlist gate now matches with the same
75
+ resolver the call dials with, so the `mcp=` write policy checked is the
76
+ policy of the record the call runs against. A name on more than one record
77
+ fails `identity/ambiguous` with the candidates in `context.matches` and an
78
+ MCP remediation, instead of resolving to the first.
79
+
11
80
  ## 0.1.8-next.0 — 2026-10-12
12
81
 
13
82
  Pre-release (npm tag `next`; `latest` stays on 0.1.7). Install with
package/README.md CHANGED
@@ -149,6 +149,10 @@ so you can always see *why* a given port or transport was chosen.
149
149
  metadata) with a granular exit code (`0` all ok / `2` partial / `1` all failed);
150
150
  a multi-target write is gated once by `--yes` naming the blast radius. See
151
151
  `docs/CONSTITUTION.md` (Target selection grammar).
152
+ - **CMR fleets** (RouterOS 7.26beta1+) are selected on the controller instead:
153
+ `execute <controller> --cmr --labels <expr> -- '<command>'` validates the
154
+ command on each client through CMR and runs it there, using only the
155
+ controller's credentials. See `commands/execute/README.md` (CMR).
152
156
  - **Discovery** writes back into the CDB: `discover --save` records MNDP
153
157
  neighbors as new entries tagged `group=discovered` with a `source=mndp`
154
158
  marker — hints to curate, not authoritative inventory.
@@ -110,6 +110,11 @@ vs `--query` distinction.
110
110
  - **Writes fan out under `--yes`**, confirmed once up front (not per target).
111
111
  Without `--yes`, the error names the blast radius (how many routers) and that
112
112
  `--yes` is required.
113
+ - **A sent write is never retried** (#448). Reads, and writes that failed
114
+ before they were sent, keep the `transport/network` /
115
+ `transport/connection-closed` retry. A mutating request that failed after it
116
+ was sent carries the warning `transport/write-outcome-unknown`, in single-target
117
+ and fan-out mode alike.
113
118
  - **`--stream` (or a `/listen` endpoint) is single-session** → `usage/fanout-not-supported` in
114
119
  fan-out mode. **`--raw` strips the envelope** → `usage/conflicting-flags` in
115
120
  fan-out mode (per-target envelopes can't be bare).
@@ -265,6 +265,123 @@ the command alone (`canonicalizeExecuteCommand` + `isWriteShaped`), so the gate
265
265
  fires before any target is dialed. Per-target validation still runs because
266
266
  RouterOS schemas can differ by version.
267
267
 
268
+ A sent write is **never retried** (#448). Fan-out retries a target only for
269
+ `transport/network` and `transport/connection-closed`, and for a write only
270
+ when the failure came before the request was sent (for example while it was
271
+ being validated). A write that failed after it was sent may have run, so its
272
+ error envelope, single-target or fan-out, carries the warning
273
+ `transport/write-outcome-unknown`.
274
+
275
+ ## CMR clients through their controller (`--cmr`)
276
+
277
+ `execute <controller> --cmr --labels <expr> -- '<command>'` runs one command on
278
+ the CMR clients (RouterOS 7.26beta1+) that a RouterOS label expression selects
279
+ on the controller (#450). The `<controller>` is an ordinary single target: a
280
+ positional or CDB identity, one `--quickchr`, or `--host`. It is resolved and
281
+ authenticated as usual, and **only the controller's credentials are used**. A
282
+ client is never dialed, so a client behind NAT or reachable only through its
283
+ controller works the same way. CMR is a selection source, not a protocol:
284
+ `--via` pins the **controller** transport (native-api, the default, or
285
+ rest-api). A CMR failure never falls back to dialing a client directly.
286
+
287
+ CMR is beta-only: it first shipped in 7.26beta1, and the controller also needs
288
+ the optional `cmr` package. When the target has no `/cmr/device`, centrs reads
289
+ its version to say why. A router older than 7.26beta1 is
290
+ `routeros/version-unsupported` (`capability: cmr-controller`). A newer one
291
+ without the package is `cmr/not-a-controller`, which also names the controller
292
+ when the target is itself a CMR client.
293
+
294
+ - `--labels` is required and passed to RouterOS unchanged: a comma list where
295
+ plain labels are OR, `+label` must match, `-label` excludes, and `all` also
296
+ selects the controller. CDB selectors (`--group`/`--where`/`--all`/
297
+ `--default`/geo), several targets, and `--via mac-telnet|ssh` are
298
+ `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
301
+ identity selects it only while the controller's `/cmr auto-labels` includes
302
+ `identity` (the default `all` does). On 7.26beta1, after `auto-labels=version`
303
+ a client's row still *showed* its identity label but no longer matched it.
304
+ - **One `run-script` call per device.** On 7.26beta1, `run-script` result rows
305
+ carry no device id, and their `device` key (`identity@address`) changed
306
+ *during* a call when a client dropped. So centrs never matches rows to
307
+ devices by name. Every controller call selects exactly one `/cmr/device` row
308
+ with `numbers=<.id>`, together with `labels=<expr>`, so a device that left
309
+ the labels is refused up front instead of run.
310
+ - **Preflight, per inventory row:** the call sends `:put [:parse <command>]`
311
+ followed by `:put "<nonce>"`, or the nonce alone with `--no-validate`.
312
+ - A refusal (`no devices selected`) means the device is not a member.
313
+ - Any row means it is a member; its validation result must be **complete**
314
+ before it is classified:
315
+ - status `success`;
316
+ - the nonce as the last line;
317
+ - no `...(+N B)` clip marker;
318
+ - a non-empty return that is IL.
319
+ - A device with no answer at all (timeout, lost connection) makes the
320
+ selection incomplete: `cmr/selection-incomplete`, and nothing runs. A
321
+ partial selection is never run as if it were the whole one.
322
+ - **Binding:** `/cmr/device` is read before preflight, between preflight and
323
+ the write gate, again for each member just before its own call (the prompt
324
+ and the fan-out queue can both wait), and after dispatch. A device whose row
325
+ is gone or whose `ids` key changed is not sent the command
326
+ (`cmr/membership-changed`); a replacement row is never adopted, even when it
327
+ matches the labels. If that per-member read fails, the member is not sent the
328
+ command and its error says so (`context.dispatched: false`). The per-member
329
+ read is host-side, not atomic with the call: on 7.26beta1 a client script
330
+ cannot check its own key (`/cmr/client get local-id` reads `*****` inside
331
+ `run-script`) and `numbers=` does not take `ids`. After dispatch, a sent result is reported
332
+ as `cmr/outcome-unknown` if the key changed during the call, or if the
333
+ inventory cannot be re-read (cancelled or unreadable) to confirm which client
334
+ answered. A CMR `skipped` stays `cmr/client-disconnected`, because nothing was
335
+ sent.
336
+ - **Write gate:** one `--yes`/TTY confirmation, naming how many clients will run
337
+ it, how many were excluded, and through which controller. Clients that failed
338
+ validation are never sent the command.
339
+ - **Dispatch:** one call per validated member, sending the input unchanged with
340
+ `timeout=` set from `--timeout` (capped at 55 s, under CMR's ~60 s per-call
341
+ ceiling). Every call pins `strategy=parallel fail-policy=continue
342
+ freeze-frame-interval=20ms`. **Never retried.**
343
+ - **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
346
+ output adds `cmr/output-truncated` and `meta.truncated`. `--max-results`
347
+ 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
349
+ that error says the command already ran (`context.dispatched: true`). A `failed` runtime
350
+ error maps through the usual `routeros/*` table. `skipped` →
351
+ `cmr/client-disconnected`.
352
+ - **What a client script may do:** CMR runs it with only the `read`, `write`,
353
+ `test` and `reboot` policies and no user session. A command that needs
354
+ another policy (`/user add`, `/tool sniffer`) passes `:parse` on the client
355
+ and is refused when it runs: `cmr/policy-denied`. The script stops at that
356
+ statement, but statements before it already ran and are not rolled back;
357
+ their output is in `context.output`. Sensitive
358
+ values read back as `*` or empty. Multi-line commands run every line (the
359
+ manual says single-line; 7.26beta1 ran both LF and CRLF scripts in full), and
360
+ `/export` works.
361
+ - **Unknown outcomes:** a CMR timeout (`timed out (6)`, `std failure: timeout
362
+ (13)`), a local deadline, a cancellation, or a lost call after sending all
363
+ give `cmr/outcome-unknown`. A cancellation before anything is sent, including
364
+ one that makes an inventory read fail, is `cmr/cancelled`. On 7.26beta1 the client script **kept running**
365
+ in each of these cases.
366
+ - **Validation metadata** reports what ran: `source: ":put [:parse ...] via cmr
367
+ run-script"`, the same claim the console gate makes. There is no client
368
+ `/console/inspect` stage.
369
+ - **Not on MCP** until delegated authorization is designed (#449). `retrieve
370
+ --cmr` is deferred (#441).
371
+
372
+ `/cmr/device/dashboard` is an interactive console view, refused as
373
+ `cmr/console-only`. A plain path, abbreviated or not (`/cmr/dev/dash`), is
374
+ refused before dialing. Any other spelling is found in the `:parse` return
375
+ during validation, which resolves console spacing, a relative menu change
376
+ (`/cmr/device; dashboard`), blocks and `api POST /execute` scripts, and ignores
377
+ strings and comments. With `--validate=false` only the plain path is refused.
378
+ On 7.26beta1 it closed a native API session, and over REST it held the
379
+ controller's REST service for every caller for 10–30 s.
380
+
381
+ Calling `/cmr/device/run-script` yourself, through `api` or plain `execute`,
382
+ validates only the controller request. The envelope then carries the warning
383
+ `cmr/client-script-not-validated`.
384
+
268
385
  ## mac-telnet L2 validation
269
386
 
270
387
  Decided (2026-06-06): the real-L2 integration path is `@tikoci/quickchr`'s
@@ -769,3 +769,165 @@ centrs execute --quickchr $NAME --quickchr no-such-machine -- ':put 1' --json
769
769
  Envelope: outer `ok: true`, `data.summary = { total: 2, ok: 1, failed: 1 }`;
770
770
  the unknown machine's inner error is `quickchr/machine-not-found`. Exit code 2
771
771
  (partial).
772
+
773
+ ## CMR clients through a controller (#450)
774
+
775
+ `test/integration/execute-cmr.test.ts` runs these on a two-CHR CMR lab
776
+ (`test/integration/cmr-lab.ts`): `$CTL` is a 7.26beta1 controller with the
777
+ `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
779
+ 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).
781
+
782
+ ### CMR0. A router without CMR
783
+
784
+ ```bash
785
+ centrs execute <router> --cmr --labels all -- ':put x'
786
+ ```
787
+
788
+ On RouterOS older than 7.26beta1 this is `routeros/version-unsupported` with
789
+ `context.capability` `cmr-controller` and the running version. On 7.26beta1 or
790
+ later without the `cmr` package it is `cmr/not-a-controller` with the version.
791
+ Nothing is dispatched. `test/integration/execute.test.ts` runs this on the
792
+ channel's CHR and expects the code its version calls for.
793
+
794
+ ### CMR1. A read on every client
795
+
796
+ ```bash
797
+ centrs execute --quickchr $CTL --cmr --labels all -- ':put [/system/identity/get name]'
798
+ ```
799
+
800
+ `data.summary` is `{total: 2, ok: 2}`. Each target's `data.ret` is that
801
+ device's own identity. `meta.validation.source` is `:put [:parse ...] via cmr
802
+ run-script` with `result: passed`, and `meta.target.cmr.id` is its
803
+ `/cmr/device` row.
804
+
805
+ ### CMR2. Per-client validation: a controller-only menu
806
+
807
+ ```bash
808
+ centrs execute --quickchr $CTL --cmr --labels all -- /cmr/device/print
809
+ ```
810
+
811
+ The controller runs it. The client has no `cmr` package and is rejected on
812
+ the client itself with `validation/package-missing`, so it is not sent the
813
+ command (`meta.operation.cmr.dispatched` is 1). Exit code 2.
814
+
815
+ ### CMR3. A confirmed write
816
+
817
+ ```bash
818
+ centrs execute --quickchr $CTL --cmr --labels all --yes -- '/system/note/set note="centrs-cmr3"'
819
+ ```
820
+
821
+ Both targets are `ok`, and both routers' `/system/note` reads `centrs-cmr3`.
822
+
823
+ ### CMR4. An unconfirmed write is refused before dispatch
824
+
825
+ ```bash
826
+ centrs execute --quickchr $CTL --cmr --labels all -- '/system/note/set note="centrs-cmr4"'
827
+ ```
828
+
829
+ Outer `usage/confirmation-required` naming the client count and the
830
+ controller. No note changes.
831
+
832
+ ### CMR5. A command every client rejects is never sent
833
+
834
+ ```bash
835
+ centrs execute --quickchr $CTL --cmr --labels all --yes -- '/system/note/set note="x" bogus-arg=1'
836
+ ```
837
+
838
+ Both targets are `validation/unknown-attribute`, `dispatched` is 0, and no note
839
+ changes.
840
+
841
+ ### CMR6. A CMR timeout is an unknown outcome
842
+
843
+ ```bash
844
+ centrs execute --quickchr $CTL --cmr --labels <client> --timeout 2s --yes -- ':delay 4s; :log info "<marker>"'
845
+ ```
846
+
847
+ The target is `cmr/outcome-unknown`, and `<marker>` still appears in the
848
+ client's log afterwards.
849
+
850
+ ### CMR7. Labels that match nothing
851
+
852
+ ```bash
853
+ centrs execute --quickchr $CTL --cmr --labels no-such-label -- ':put x'
854
+ ```
855
+
856
+ `ok: true`, `data.summary.total` is 0, warning `cmr/empty-selection`, exit
857
+ code 0.
858
+
859
+ ### CMR8. CLI exit code for a mixed result
860
+
861
+ CMR2 through `runCli` exits `2`.
862
+
863
+ ### CMR9. A client that is down
864
+
865
+ With the client VM stopped and the controller showing it `connected=false`:
866
+
867
+ ```bash
868
+ centrs execute --quickchr $CTL --cmr --labels <client> -- ':put x'
869
+ ```
870
+
871
+ The target is `cmr/client-disconnected` and `dispatched` is 0.
872
+
873
+ ### CMR10. Cancelling after dispatch is an unknown outcome
874
+
875
+ A library caller aborts `signal` two seconds into:
876
+
877
+ ```bash
878
+ centrs execute --quickchr $CTL --cmr --labels <client> --timeout 20s --yes -- ':delay 6s; :log info "<marker>"'
879
+ ```
880
+
881
+ The target is `cmr/outcome-unknown` with `dispatched` 1. `<marker>` is not in
882
+ the client's log when the call returns, and appears afterwards: cancelling
883
+ the controller call did not stop the client script.
884
+
885
+ ### CMR11. A command needing a policy CMR scripts lack
886
+
887
+ ```bash
888
+ centrs execute --quickchr $CTL --cmr --labels <client> --yes -- '/user/add name=<user> group=read password=<x>'
889
+ ```
890
+
891
+ Validation passes on the client (`:parse` accepts it), then the client refuses
892
+ it when it runs: the target is `cmr/policy-denied`, and `<user>` does not exist
893
+ on the client afterwards.
894
+
895
+ ### CMR12. A multi-line command
896
+
897
+ ```bash
898
+ centrs execute --quickchr $CTL --cmr --labels <client> -- $':put centrs-l1\n:put centrs-l2'
899
+ ```
900
+
901
+ The target is `ok` and `data.ret` holds both lines.
902
+
903
+ ### CMR11b. A refused statement after others have run
904
+
905
+ ```bash
906
+ 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"'
907
+ ```
908
+
909
+ The target is `cmr/policy-denied`, and its `context.output` starts with
910
+ `before` followed by the `not enough permissions (9)` line. The client's note
911
+ is `<m>-before`: the statement before the refused one ran and stays, and the
912
+ script stopped at the refused one. `<user>` does not exist.
913
+
914
+ ### CMR14. `--cmr` pointed at a client
915
+
916
+ ```bash
917
+ centrs execute --quickchr <client> --cmr --labels all -- ':put x'
918
+ ```
919
+
920
+ `cmr/not-a-controller`: the client runs 7.26beta1 but has no `cmr` package.
921
+ `context.controllerIdentity` names the controller it is paired to.
922
+
923
+ ### CMR13. The dashboard behind a relative menu change
924
+
925
+ ```bash
926
+ centrs execute --quickchr $CTL -- '/cmr/dev; dash'
927
+ centrs execute --quickchr $CTL -- ':put "CMR device dashboard"'
928
+ ```
929
+
930
+ The first is `cmr/console-only`: the controller's `:parse` resolves it to
931
+ `/cmr/device/;(evl /cmr/device/dashboard)`, so it is refused before it runs.
932
+ The second is `ok` with `data.ret` `CMR device dashboard`: a string that spells
933
+ the words is not a command.
@@ -149,7 +149,11 @@ The authorization boundary is the WinBox CDB (constitution: identity and CDB),
149
149
  which is already centrs's device registry **and** native credential store. The
150
150
  MCP server applies it as a hard allowlist:
151
151
 
152
- - **Targets resolve through the CDB only** — by name, MAC, or `group`. Inline
152
+ - **Targets resolve through the CDB only** — by record target, by the
153
+ `identity=`/`mac=`/`ip=` comment keys (the same matcher the CLI dials with, so
154
+ the write policy checked belongs to the record the call runs against), or by
155
+ `group`. A name on
156
+ more than one record fails `identity/ambiguous` rather than picking one. Inline
153
157
  ad-hoc `host` + `username` + `password` are **rejected by default** with
154
158
  `cdb/target-not-registered`, whose `fix` points at `centrs_devices add`. An
155
159
  agent therefore cannot reach an arbitrary router with arbitrary credentials.
@@ -37,10 +37,27 @@ Envelope: `ok: true`, `data.path = "/ip/route"`, `data.verb = "add"`,
37
37
 
38
38
  Envelope: `ok: false`, `error.code = "cdb/target-not-registered"`, and
39
39
  `error.fix`/`remediation` names `centrs_devices add`. No connection is attempted.
40
- RouterOS-facing MCP tools accept only a CDB `target` (name/MAC/URL) — there is
40
+ RouterOS-facing MCP tools accept only a CDB-registered `target` — the record's
41
+ address (IP/URL/MAC) or its `identity=`/`mac=`/`ip=` comment key — and there is
41
42
  no inline `host`/`username`/`password` executor parameter, so RouterOS actions
42
43
  use credentials already stored in the CDB.
43
44
 
45
+ ### 2b. A record's `identity=` name passes the allowlist
46
+
47
+ The harness registers `$NAME` with the comment `identity=mcp-lab mcp=rw
48
+ via=rest-api`.
49
+
50
+ ```json
51
+ { "name": "centrs_retrieve",
52
+ "arguments": { "target": "mcp-lab", "path": "/system/resource" } }
53
+ ```
54
+
55
+ Envelope: `ok: true`, and `meta.target` names the record the call dialed:
56
+ `identity = "mcp-lab"`, `recordIndex = 0`, and `baseUrl` is `$NAME`'s REST URL.
57
+ The allowlist gate and the dialing resolver use the same matcher, so a name on
58
+ more than one record fails `identity/ambiguous` instead of picking one (asserted
59
+ offline in `test/unit/mcp-safety.test.ts`; the CHR test registers one record).
60
+
44
61
  ### 3. `centrs_validate` accepts the bare `blackhole` flag
45
62
 
46
63
  ```json
@@ -513,7 +513,8 @@ retrieve-specific behavior on top of the shared engine:
513
513
  `transport/connection-closed` (REST 5xx is mapped to
514
514
  `transport/connection-closed`). It does **not** retry `routeros/*`,
515
515
  `validation/*`, `auth/*`, `cdb/*`, `target/*`, timeouts, DNS, TLS, or refused
516
- connections.
516
+ connections. (`execute` and `api` share this engine but never replay a write
517
+ that was sent.)
517
518
 
518
519
  ## Definition of done
519
520
 
package/docs/CLI.md CHANGED
@@ -87,6 +87,8 @@ Usage: centrs execute <target> <command> [flags] | centrs execute <target...> --
87
87
  | `--default` | | Select the reserved `__default__` record. |
88
88
  | `--quickchr` | `<name>` | Target a running quickchr-managed CHR VM by name (repeatable; fans out when repeated). Exclusive of positional targets and CDB selectors; conflicts with direct connection overrides (--host/--port/--username/--password, and --ssh-key where the command has it). |
89
89
  | `--concurrency` | `<n>` | Max in-flight targets during fan-out (transport-aware default: rest-api 8, native-api 4). |
90
+ | `--cmr` | | 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. |
91
+ | `--labels` | `<expr>` | With `--cmr`: RouterOS label expression selecting clients (comma list; plain = OR, `+` AND, `-` AND NOT, `all`). |
90
92
  | `--host` | &lt;host\|url&gt; | Host or base URL to dial. With no `<target>` it is the target (no CDB lookup); with one it overrides the resolved host. |
91
93
  | `--port` | `<port>` | Override the resolved management port. |
92
94
  | `--username / --user / -u` | `<name>` | RouterOS username (aliases `--user`, `-u`). Falls back to `CENTRS_USERNAME`. |
@@ -309,7 +309,9 @@ orchestration error or every target failed.
309
309
 
310
310
  A multi-target **RouterOS write** (e.g. `execute` / `api` add/set/remove across a
311
311
  selection) is gated by **`--yes`**, confirmed **once** up front; when it is missing
312
- the error names the blast radius (how many routers). The separate **`--force`**
312
+ the error names the blast radius (how many routers). A write that was **sent** is
313
+ never retried, because it may already have run; it fails with the warning
314
+ `transport/write-outcome-unknown` instead (#448). The separate **`--force`**
313
315
  gate is scoped to destructive **`devices` CDB mutations** (e.g. `devices remove`
314
316
  across a group), not RouterOS writes. `terminal`, `api --stream`,
315
317
  `retrieve --follow` and `retrieve --sample` are single-session, not fan-out
@@ -365,6 +367,32 @@ single host/port: that shape is the contract quickchr's descriptor and TikTOML's
365
367
  exists to prevent. A `--via` whose service key a provider does not supply is a
366
368
  typed unsupported-provider error, never a silent fallback.
367
369
 
370
+ #### Controller-mediated selection (CMR)
371
+
372
+ A **CMR controller** (RouterOS 7.26beta1+) is a third kind of source: a live
373
+ inventory whose members centrs never dials. `execute <controller> --cmr
374
+ --labels <expr>` (#450) resolves the **controller** as an ordinary single
375
+ target, and only its credentials are used. RouterOS's label expression selects
376
+ the members, and the command runs on each through the controller's
377
+ `run-script`. Normative:
378
+
379
+ - CMR is a selection source, **not a protocol**. `--via` pins the controller
380
+ transport, and a CMR failure never falls back to dialing a client.
381
+ - A member is bound by the controller's row id, never by its display name. A
382
+ result that cannot be bound to the member it was validated on is not run, or
383
+ is reported as an unknown outcome.
384
+ - Validation runs **on each client** (`:parse` through CMR) and must be complete
385
+ before it counts. A client that fails validation is never sent the command,
386
+ and an incomplete selection runs nothing.
387
+ - A CMR dispatch is **never retried**. A timeout, cancellation or lost call
388
+ after sending is `cmr/outcome-unknown`, because the client script may still
389
+ be running.
390
+ - CMR mode is not on MCP until delegated authorization is designed (#449).
391
+ Registering a controller must not authorize its clients.
392
+
393
+ The contract and its device evidence are in
394
+ [`commands/execute/README.md` → CMR](../commands/execute/README.md#cmr-clients-through-their-controller---cmr).
395
+
368
396
  ### MCP surface: the CDB is the allowlist
369
397
 
370
398
  The MCP frontend (`commands/mcp/`) is an adapter over this core, not a new set of
package/docs/MATRIX.md CHANGED
@@ -56,6 +56,11 @@ and implementation sequencing in the linked issues.
56
56
  - `retrieve`, `api`, `execute`, and `transfer` share the target-selection and
57
57
  fan-out contracts from `docs/CONSTITUTION.md`; command-specific boundaries and
58
58
  evidence are in their command READMEs and integration tests.
59
+ - `execute --cmr` (#450) is not a grid column: CMR is a selection source, and
60
+ its controller transports are the `execute` rest-api/native-api cells.
61
+ CMR1–CMR7 and CMR10–CMR14 pass on a 7.26beta1 CHR lab over both, and
62
+ CMR8–CMR9 over native-api (`test/integration/execute-cmr.test.ts`). CMR0 (a
63
+ router without CMR) runs on the channel's CHR in `execute.test.ts`.
59
64
  - There is no `update` command. RouterOS CLI-shaped writes ride `execute`; the
60
65
  structured operation surface is `api`.
61
66
  - `api --stream` absorbs the former `stream`/`tail` surface; native-api stream
@@ -177,15 +182,26 @@ Wire, auth, TCP/UDP, and CI caveats live in `commands/btest/README.md` and the
177
182
 
178
183
  ## Current priority
179
184
 
180
- **First: the native-api / NDJSON line** (maintainer decision on
185
+ **First: the CMR track** (maintainer decision 2026-10-12 on
186
+ [#441](https://github.com/tikoci/centrs/issues/441)). Done: `execute --cmr`
187
+ (#450), which runs one validated command on CMR clients through their
188
+ controller. The contract is in
189
+ [`commands/execute/README.md` → CMR](../commands/execute/README.md#cmr-clients-through-their-controller---cmr),
190
+ with examples CMR0–CMR14. The CMR-vs-centrs feature map is on #441
191
+ (2026-10-13). Next, in order: the fleet blast-radius gate for controller fleet
192
+ commands (#453), then #449's read-only slice (a `devices` view of members and a
193
+ default controller). Deferred: CDB linkage (#449) and `retrieve --cmr` (#441).
194
+ Live `explain` resumes after this.
195
+
196
+ **The native-api / NDJSON line** (maintainer decision on
181
197
  [#396](https://github.com/tikoci/centrs/issues/396), 2026-10-07): agents in the
182
198
  field poll with `sleep` where a native follow would answer. In order:
183
199
  (1) done: #402, literal `api --stream` with `!empty` frames and
184
200
  `--format ndjson`; (2) done: `retrieve --follow` (#396, unfiltered);
185
201
  (3) done: `retrieve --sample`; (4) #397: one-shot `retrieve --query` done,
186
202
  filtered `--follow` done; #364 (`--wait`/`--until`, same grammar) done;
187
- (5) #398 and #401. Each slice's contract lives in its command
188
- README. Live `explain` (below) resumes after this line.
203
+ (5) #398 and #401 remain open. Each slice's contract lives in its command
204
+ README.
189
205
 
190
206
  **Tightening validation as a whole** is the next track, in the three-step
191
207
  order agreed on [#236](https://github.com/tikoci/centrs/issues/236):
@@ -0,0 +1,15 @@
1
+ # `cmr/cancelled`
2
+
3
+ The operation was cancelled before the command was sent to this CMR client, so
4
+ nothing ran on it.
5
+
6
+ ## Typical trigger
7
+
8
+ The caller aborted (Ctrl-C, or an `AbortSignal` in the library) during
9
+ preflight or before this member's dispatch. Members that had already been sent
10
+ the command are reported as [`cmr/outcome-unknown`](./outcome-unknown.md)
11
+ instead, because cancelling does not stop a client script that has started.
12
+
13
+ ## Fix
14
+
15
+ Re-run when ready.
@@ -0,0 +1,19 @@
1
+ # `cmr/client-disconnected`
2
+
3
+ CMR skipped the client because it is not connected to the controller. The
4
+ command was **not** sent to it.
5
+
6
+ ## Typical trigger
7
+
8
+ The controller answered `run-script` for this client with status `skipped` and
9
+ output `disconnected`. The client is down, unpaired, or cannot reach the
10
+ controller on TCP/54321. A client that has just gone down may instead stall
11
+ until the controller notices; that case surfaces as
12
+ [`cmr/validation-incomplete`](./validation-incomplete.md) or
13
+ [`cmr/outcome-unknown`](./outcome-unknown.md).
14
+
15
+ ## Fix
16
+
17
+ - On the client, `/cmr/client print` should show `status: paired,connected`.
18
+ - Check its route and firewall towards the controller (TCP/54321).
19
+ - Re-run once it reconnects; the other members' results are unaffected.
@@ -0,0 +1,22 @@
1
+ # `cmr/client-script-not-validated`
2
+
3
+ A warning: a caller-addressed `/cmr/device/run-script`, sent through `api` or
4
+ plain `execute`, passed validation **as a controller request**. The `script=`
5
+ it carries was not validated on any CMR client.
6
+
7
+ ## Typical trigger
8
+
9
+ ```sh
10
+ centrs api ctl /cmr/device/run-script -X POST -f labels=all -f 'script=…' --yes
11
+ centrs execute ctl --yes -- '/cmr/device/run-script labels=all script="…"'
12
+ ```
13
+
14
+ `/console/inspect` on the controller checks `labels=` and `script=` as
15
+ arguments. To the controller's `:parse`, the script is an opaque string, so a
16
+ `passed` gate says nothing about whether each client accepts it.
17
+
18
+ ## Fix
19
+
20
+ Use `centrs execute <controller> --cmr --labels <expr> -- '<command>'`. It runs
21
+ `:parse` on every selected client through CMR and sends the command only to the
22
+ clients that accepted it.
@@ -0,0 +1,29 @@
1
+ # `cmr/console-only`
2
+
3
+ The command is one of CMR's interactive console views, so centrs refused it
4
+ before running it.
5
+
6
+ ## Typical trigger
7
+
8
+ `/cmr/device/dashboard` through `execute` or `api`. On RouterOS 7.26beta1:
9
+
10
+ - over the **native API** it closed the API session;
11
+ - over **REST** it never returned, and every other REST request to the
12
+ controller then waited 10–30 s until it timed out.
13
+
14
+ A plain path, abbreviated or not (`/cmr/dev/dash`), is refused before any
15
+ connection opens. Every other spelling is found in the controller's `:parse`
16
+ return during validation, which resolves it to one invocation: console
17
+ spacing, a relative menu change (`/cmr/device; dashboard`), a `:do`/`:if`
18
+ block, a `[...]` substitution, or an `api POST /execute` script. Strings and
19
+ comments that only spell the words are not refused.
20
+
21
+ With `--validate=false` there is no `:parse` return, so only the plain path is
22
+ refused.
23
+
24
+ ## Fix
25
+
26
+ - Open the dashboard in WinBox 4 or WebFig, or run it in
27
+ `centrs terminal <controller>`.
28
+ - For the data behind it, read the menus directly: `centrs retrieve
29
+ <controller> /cmr/device`, `/cmr/alert` or `/cmr/upgrade/job`.
@@ -0,0 +1,19 @@
1
+ # `cmr/empty-selection`
2
+
3
+ A warning: the `--labels` expression matched no device in the controller's
4
+ `/cmr/device` inventory, so nothing ran. The result is an empty selection with
5
+ exit code 0, the same as an empty CDB group.
6
+
7
+ ## Typical trigger
8
+
9
+ - A misspelled label. RouterOS label names are case-sensitive, and an unknown
10
+ label is accepted silently rather than rejected.
11
+ - A missing comma: `labels=office+ap` is one label named `office+ap`; use
12
+ `office,+ap`.
13
+ - Every matching device was removed from the controller.
14
+
15
+ ## Fix
16
+
17
+ Check the labels on the controller (`/cmr/device print` shows `labels` and
18
+ `auto-labels`) and re-run. `--labels all` selects every device, including the
19
+ controller.