@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.
- package/CHANGELOG.md +69 -0
- package/README.md +4 -0
- package/commands/api/README.md +5 -0
- package/commands/execute/README.md +117 -0
- package/commands/execute/examples.md +162 -0
- package/commands/mcp/README.md +5 -1
- package/commands/mcp/examples.md +18 -1
- package/commands/retrieve/README.md +2 -1
- package/docs/CLI.md +2 -0
- package/docs/CONSTITUTION.md +29 -1
- package/docs/MATRIX.md +19 -3
- package/docs/errors/cmr/cancelled.md +15 -0
- package/docs/errors/cmr/client-disconnected.md +19 -0
- package/docs/errors/cmr/client-script-not-validated.md +22 -0
- package/docs/errors/cmr/console-only.md +29 -0
- package/docs/errors/cmr/empty-selection.md +19 -0
- package/docs/errors/cmr/membership-changed.md +20 -0
- package/docs/errors/cmr/not-a-controller.md +26 -0
- package/docs/errors/cmr/outcome-unknown.md +29 -0
- package/docs/errors/cmr/output-truncated.md +16 -0
- package/docs/errors/cmr/policy-denied.md +31 -0
- package/docs/errors/cmr/selection-incomplete.md +18 -0
- package/docs/errors/cmr/validation-incomplete.md +23 -0
- package/docs/errors/routeros/version-unsupported.md +4 -0
- package/docs/errors/transport/write-outcome-unknown.md +24 -0
- package/docs/errors/usage/missing-labels.md +19 -0
- package/package.json +1 -1
- package/src/api-fanout.ts +7 -1
- package/src/api.ts +25 -6
- package/src/cli/execute.ts +132 -1
- package/src/cli/retrieve.ts +7 -0
- package/src/core/cmr.ts +104 -0
- package/src/core/envelope.ts +16 -0
- package/src/core/error-catalog.ts +71 -0
- package/src/core/fanout.ts +77 -8
- package/src/errors.ts +1 -0
- package/src/execute-cmr.ts +1425 -0
- package/src/execute-fanout.ts +4 -0
- package/src/execute.ts +68 -17
- package/src/index.ts +17 -0
- package/src/mcp/safety.ts +43 -18
- package/src/mcp/tools.ts +5 -3
- package/src/protocols/adapter.ts +54 -12
- 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.
|
package/commands/api/README.md
CHANGED
|
@@ -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.
|
package/commands/mcp/README.md
CHANGED
|
@@ -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
|
|
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.
|
package/commands/mcp/examples.md
CHANGED
|
@@ -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`
|
|
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` | <host\|url> | 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`. |
|
package/docs/CONSTITUTION.md
CHANGED
|
@@ -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).
|
|
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
|
|
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.
|
|
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.
|