@tikoci/centrs 0.1.9-next.22 → 0.1.9-next.24

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 (68) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/GLOSSARY.txt +38 -0
  3. package/README.md +2 -2
  4. package/commands/api/AGENTS.md +18 -1
  5. package/commands/api/README.md +10 -6
  6. package/commands/api/examples.md +1 -1
  7. package/commands/execute/README.md +26 -6
  8. package/commands/execute/examples.md +43 -5
  9. package/commands/explain/README.md +873 -182
  10. package/commands/explain/examples.md +447 -38
  11. package/commands/retrieve/README.md +13 -2
  12. package/commands/retrieve/examples.md +30 -1
  13. package/commands/transfer/README.md +8 -4
  14. package/docs/CLI.md +19 -5
  15. package/docs/CONSTITUTION.md +18 -9
  16. package/docs/MATRIX.md +51 -26
  17. package/docs/errors/cmr/caller-policy-not-enforced.md +5 -3
  18. package/docs/errors/routeros/permission-denied.md +24 -0
  19. package/docs/errors/transport/edge-auth-failed.md +22 -0
  20. package/docs/errors/transport/write-outcome-unknown.md +3 -0
  21. package/docs/errors/usage/invalid-cursor.md +20 -0
  22. package/docs/errors/validation/unknown-path.md +9 -0
  23. package/package.json +5 -2
  24. package/src/api.ts +34 -39
  25. package/src/cli/explain.ts +206 -47
  26. package/src/core/envelope.ts +10 -3
  27. package/src/core/error-catalog.ts +15 -0
  28. package/src/core/execute-command.ts +53 -0
  29. package/src/core/fanout.ts +12 -2
  30. package/src/core/inspect.ts +4 -5
  31. package/src/core/routeros-errors.ts +25 -0
  32. package/src/execute-cmr.ts +2 -8
  33. package/src/execute.ts +48 -20
  34. package/src/explain/args.ts +181 -15
  35. package/src/explain/blocks.ts +3 -4
  36. package/src/explain/brace-slots.ts +5 -0
  37. package/src/explain/catalog.ts +13 -6
  38. package/src/explain/defects.ts +1 -0
  39. package/src/explain/group-faults.ts +1060 -0
  40. package/src/explain/highlight-projection.ts +10 -8
  41. package/src/explain/operators.ts +22 -20
  42. package/src/explain/pathresolve.ts +102 -20
  43. package/src/explain/quoted-string.ts +41 -6
  44. package/src/explain/run-tokens.ts +223 -0
  45. package/src/explain/scope-brace.ts +319 -7
  46. package/src/explain/segment.ts +177 -4
  47. package/src/explain/spaced-args.ts +116 -0
  48. package/src/explain/symbols.ts +163 -7
  49. package/src/explain/transport.ts +13 -0
  50. package/src/explain/values.ts +55 -4
  51. package/src/explain/verbsplit.ts +14 -188
  52. package/src/explain/write.ts +8 -1
  53. package/src/explain-live-facets.ts +739 -0
  54. package/src/explain-live-types.ts +490 -0
  55. package/src/explain-live.ts +593 -0
  56. package/src/explain.ts +772 -60
  57. package/src/index.ts +6 -0
  58. package/src/offline-gate.ts +30 -9
  59. package/src/protocols/adapter.ts +88 -10
  60. package/src/protocols/btest-session.ts +30 -10
  61. package/src/protocols/mac-telnet-console.ts +16 -0
  62. package/src/protocols/mac-telnet.ts +38 -10
  63. package/src/protocols/native-api.ts +13 -12
  64. package/src/retrieve-follow.ts +2 -1
  65. package/src/retrieve-sample.ts +2 -1
  66. package/src/retrieve-wait.ts +10 -6
  67. package/src/retrieve.ts +77 -27
  68. package/src/transfer.ts +38 -13
@@ -96,7 +96,7 @@ size-aware. Grounding: [Files](https://help.mikrotik.com/docs/spaces/ROS/pages/2
96
96
  | `rest` / `native` | rest-api / native-api | small via `/file/get … contents` (**≤ 60 KB**); **large via chunked `/file/read offset chunk-size≤32768`** | **`/file/set contents=` ≤ 60 KB only** — no chunked write |
97
97
  | `sftp` (default secure) | ssh | any size | any size |
98
98
  | `scp` | ssh | any size | any size |
99
- | `fetch` *(deferred)* | rest-api/native-api + inbound HTTP | router PUTs to centrs (`upload=yes http-method=put src-path=…`) | router GETs from centrs (`url=http://centrs/… dst-path=…`) |
99
+ | `fetch` *(deferred, upload-only)* | rest-api/native-api + inbound HTTP | chunked `/file/read` (router PUT fails: `only [s]ftp modes support upload`, #548) | router GETs from centrs (`url=http://centrs/… dst-path=…`) |
100
100
  | `ftp` *(gated)* | ftp | any size | any size |
101
101
 
102
102
  Auto-selection (no `--via`):
@@ -302,6 +302,10 @@ gate as in `retrieve` / `execute`; RouterOS's own menu errors (missing remote
302
302
  file, permission denied) map to `routeros/*`, and SFTP/SCP/fetch errors map to
303
303
  `transport/*` and `auth/*`.
304
304
 
305
+ `meta.validation` reports that existence probe as one `device` stage. `list`
306
+ runs no probe, so it is not a gate: its `meta.validation` is
307
+ `{ enabled: false }`, on success and on failure (#535).
308
+
305
309
  A failure **after** target resolution keeps what resolution found: the error
306
310
  envelope carries the resolved `meta.target` (host, port, identity, sources),
307
311
  `meta.via`, the setting sources in `meta.settings`, and `meta.operation`
@@ -371,9 +375,9 @@ done rule. The first coded pass targets `rest`/`native` (no SSH dependency);
371
375
  (random URL token, bound to the interface facing the router) and drives the
372
376
  router with `/tool/fetch` over `rest`/`native`. **upload**: serve the local
373
377
  file, `fetch url=http://<advertise>/<token> dst-path=<remote>` → router GETs.
374
- **download**: `fetch upload=yes http-method=put url=http://<advertise>/<token>
375
- src-path=<remote>` → router PUTs to centrs (`http-method` any since 7.21;
376
- binary REST serialization fixed 7.17). Open questions: advertise-host
378
+ **download** stays on chunked `/file/read`: router PUTs fail with `only
379
+ [s]ftp modes support upload` (#548), and HTTP `http-data` caps at 60000
380
+ bytes, so the PUT half cannot carry files. Open questions: advertise-host
377
381
  auto-detection behind NAT, HTTP-vs-HTTPS + `check-certificate`, and whether to
378
382
  add fetch's `user`/`password` on top of the URL token.
379
383
  - **ssh** — `sftp` is **built** (`src/protocols/sftp.ts`, host OpenSSH `sftp`
package/docs/CLI.md CHANGED
@@ -15,7 +15,7 @@ implemented flags live in each command's `commands/<name>/README.md`.
15
15
  | [`api`](#api) | Structured RouterOS API passthrough (gh-api style) over REST or native API. |
16
16
  | [`transfer`](#transfer) | Copy files to/from a RouterOS device and manage device files (rest/native/sftp). |
17
17
  | [`terminal`](#terminal) | Open an interactive RouterOS console (ssh or mac-telnet). |
18
- | [`explain`](#explain) | Analyze a RouterOS command offline: canonical form, structure, and syntax diagnostics. |
18
+ | [`explain`](#explain) | Analyze RouterOS input offline or add read-only device evidence from one router. |
19
19
  | [`devices`](#devices) | Inspect and mutate the CDB-backed device registry. `devices` is the only command that writes the CDB. |
20
20
  | [`discover`](#discover) | Discover RouterOS neighbors over MNDP and optionally save them into the CDB. |
21
21
  | [`btest`](#btest) | Run MikroTik's bandwidth test as a client or server (peer measurement). |
@@ -220,18 +220,32 @@ Usage: centrs terminal <router> [flags] | centrs terminal --host <host> [flags]
220
220
 
221
221
  ## explain
222
222
 
223
- Analyze a RouterOS command offline: canonical form, structure, and syntax diagnostics.
223
+ Analyze RouterOS input offline or add read-only device evidence from one router.
224
224
 
225
225
  ```text
226
- Usage: centrs explain '<input>' [flags]
226
+ Usage: centrs explain [<router>] '<input>' [flags]
227
227
  ```
228
228
 
229
229
  | Flag | Value | Description |
230
230
  | ---- | ----- | ----------- |
231
+ | `--via` | &lt;rest-api\|native-api&gt; | Pin the transport; no silent downgrade. Default rest-api. |
232
+ | `--host` | &lt;host\|url&gt; | Host or base URL to dial. With no `<router>` it is the target (no CDB lookup); with one it overrides the resolved host. |
233
+ | `--port` | `<port>` | Override the resolved management port. |
234
+ | `--username / --user / -u` | `<name>` | RouterOS username. Falls back to `CENTRS_USERNAME`. |
235
+ | `--password` | `<secret>` | RouterOS password. Falls back to `CENTRS_PASSWORD`. |
236
+ | `--insecure` | | Accept a self-signed `api-ssl`/REST TLS cert. Default verifies. |
237
+ | `--cdb-file` | `<path>` | Read target credentials from a WinBox CDB file. |
238
+ | `--cdb-password` | `<password>` | Decrypt an encrypted WinBox CDB file. |
239
+ | `--resolve` | &lt;none\|arp&gt; | Resolve a MAC-address target to an IP via the host ARP cache (default none). |
240
+ | `--timeout` | `<duration>` | Per-request timeout (for REST, max 60s). |
241
+ | `-p` | `<secret>` | Alias for --password. |
242
+ | `--quickchr` | `<name>` | Resolve one running quickchr VM by name. |
231
243
  | `--file` | `<path>` | Read the input from a file instead of the positional (`-` reads stdin). Piped stdin is used automatically when no input positional is given. |
232
244
  | `--fail-on` | &lt;error\|warning\|never&gt; | Exit 2 when the verdict reaches this severity. Default `error`; an ambiguous or unknown statement is a warning, never an error. |
233
- | `--complete` | | Continuation candidates at the cursor. Live evidence — offline emits a tip and enumerates nothing. |
234
- | `--schema` | | Path enumeration (verbs, args, types, enums). Live evidence — offline emits a tip and enumerates nothing. |
245
+ | `--complete` | | Candidates at the cursor: menus and verbs, argument names, or values. Live evidence — offline emits a tip and enumerates nothing. |
246
+ | `--schema` | | Describe the path at the cursor: a menu's verbs and submenus, a command's arguments, `print`'s proplist. Live evidence — offline emits a tip and enumerates nothing. |
247
+ | `--cursor` | `<byte>` | UTF-8 byte offset for --complete/--schema. Default: the end of the input. |
248
+ | `--full` | | Lift --complete/--schema sizing to its safety ceiling (1024 names, 128 expansion probes). |
235
249
  | `--curl` | | Render a ready-to-edit REST curl for statements covered by a runtime-tested mapping rule. |
236
250
  | `--tokens` | | Emit the total, gapless token partition behind `data.tokens[]` (every byte not claimed by an analyzer is `unclassified`). |
237
251
  | `--format` | &lt;text\|json\|yaml&gt; | Output format for the CLI response. |
@@ -58,6 +58,10 @@ is unsafe the offline stage is what keeps the gate from degrading to nothing. A
58
58
  clean offline pass is **necessary, never sufficient** — `explain` publishes
59
59
  `runtimeAcceptance: "not-proven"` for exactly this reason. An offline `warn` is
60
60
  an ABSTENTION and passes through to the device stage; only a `fail` rejects.
61
+ The one exception is `discarded-close`: a stray top-level `}` or `]` that the
62
+ device accepts and then silently stops reading at (#504). It is a warning,
63
+ because nothing is rejected, and the gate refuses it by code
64
+ (`src/offline-gate.ts`), because the device would run a different script.
61
65
 
62
66
  `meta.validation.stages[]` reports what each stage concluded, including a stage
63
67
  that was skipped and why. A reader must always be able to tell "the device
@@ -69,15 +73,20 @@ gate passed keeps `passed`. When something stopped the gate before it gave a
69
73
  verdict (a `transport/*` or `auth/*` failure, a device error that is not a
70
74
  gate answer, or a refusal before the gate ran), `result` and the
71
75
  `syntax`/`semantic` summaries are absent. Each stage the gate did not reach is
72
- then `skipped` with `reason: "not reached: <error.code>"` (GH#435). On an
73
- `execute` rejection, `syntax: true` survives only for a name-level refusal (an
74
- unknown attribute in a structured command). Any other device or offline
75
- refusal is `syntax: false`, and `semantic` is then absent (`not-applicable` for
76
- script input), because the name-level check was never asked. `api`'s
77
- structured requests run no `:parse`, so their `syntax: false` means "not run",
78
- even on success. That overload is part of the review below. This is an
79
- interim shape: the validation meta across commands is reviewed again with live
80
- `explain` (#236).
76
+ then `skipped` with `reason: "not reached: <error.code>"` (GH#435).
77
+
78
+ The flat `syntax` / `semantic` fields summarize `stages[]`, and each is present
79
+ **only when a stage gave that verdict** (#535). Absent means no stage was
80
+ asked, never "passed" and never "failed". So `--validate=false` carries
81
+ neither. A script has no `semantic` field, because it has no names to check,
82
+ and `api`'s structured requests have no `syntax` field, because they run no
83
+ `:parse`. On a rejection, `syntax: true` with `semantic: false` is a
84
+ name-level refusal (an unknown attribute in a structured command). A `:parse`
85
+ or offline refusal is `syntax: false` with no `semantic`. Every gated success
86
+ reports its `stages[]`, retrieve and transfer included. `explain` is not a
87
+ gate, because it never runs the input: its `meta.validation` is
88
+ `{ enabled: false }` with no `result`, offline and live, and its verdict is
89
+ `data.verdict`.
81
90
 
82
91
  `--validate=false` disables **both** stages — one flag, one meaning. A bypass
83
92
  that covered only the device stage would leave a caller stranded behind a
package/docs/MATRIX.md CHANGED
@@ -40,7 +40,7 @@ their command-specific evidence contracts.
40
40
  | devices | — | — | — | — | — | — | — | — |
41
41
  | discover | — | — | — | — | — | `CHR-passed` | — | — |
42
42
  | check | `designed` | `designed` | `designed` | `designed` | `not-started` | `not-started` | `not-started` | `not-started` |
43
- | explain | `designed` | `designed` | — | — | — | — | — | — |
43
+ | explain | `CHR-passed` | `CHR-passed` | — | — | — | — | — | — |
44
44
  | settings | — | — | — | — | — | — | — | — |
45
45
 
46
46
  ## Status pointers
@@ -101,10 +101,14 @@ and implementation sequencing in the linked issues.
101
101
  `winbox-terminal` stay `not-started`. Its per-host fan-out is the machinery the
102
102
  IP-scan discovery (#149) will iterate; the L2-default timing evidence (#136) is
103
103
  recorded in the README before the cells advance past `designed`.
104
- - `explain`'s two `designed` protocol cells are the future live probes
105
- (`/console/inspect` + `:parse`, #236). The live grammar
106
- (`explain <router> '<input>'`) is parsed and refused with
107
- `usage/not-implemented`, never degraded to an offline run. Offline analysis
104
+ - `explain`'s two `CHR-passed` protocol cells are the whole live capability
105
+ (#236). `explain <router> '<input>'` collects read-only `:parse`, highlight
106
+ and child argument-name evidence (#491), and `--schema`/`--complete` describe
107
+ paths and complete at a cursor (#492). Live value types (#225) fill
108
+ `observedType` from visible `:parse` conversions and `schemaType` from
109
+ argument `syntax`. Examples 7–16, 19, 24, 48 and 52–53 pass on REST and
110
+ native API in `test/integration/explain-live*.test.ts`, on stable 7.24.5 and
111
+ long-term 7.23.8. `runtimeAcceptance` stays `not-proven`. Offline analysis
108
112
  has its own capability entry below. The generated structure tables
109
113
  `src/explain/menus.ts` and `src/explain/catalog.ts` keep analysis offline;
110
114
  their ownership and regeneration contracts live in `commands/explain/README.md`.
@@ -182,17 +186,35 @@ Wire, auth, TCP/UDP, and CI caveats live in `commands/btest/README.md` and the
182
186
 
183
187
  ## Current priority
184
188
 
185
- **First: the CMR track** (maintainer decision 2026-10-12 on
186
- [#441](https://github.com/tikoci/centrs/issues/441)). Done: `execute --cmr`
189
+ Live `explain` (#236) is `CHR-passed` on REST and native API: the #491
190
+ foundation, #492's describe and completion facets, and #225's live value types,
191
+ with every applicable example green on stable and long-term CHR. Issue #90
192
+ owns the remaining sequencing, including #507 context grounding.
193
+
194
+ **First: `explain`** (maintainer decision 2026-10-16 on
195
+ [#90](https://github.com/tikoci/centrs/issues/90)). In order: make the
196
+ published offline readings honest (#350, #211; complete), give the result a
197
+ statement tree (#497; complete), then the live probe foundation (#491;
198
+ complete). Those probes grounded #351, #358 and #371 (complete), #492
199
+ added the describe/completion facets, and #225 the live value types. The
200
+ closeout of #236 promoted both live cells (complete). The
201
+ plan, and what is deliberately out of this run, is the 2026-10-16 plan
202
+ comment on #90. It is step 3 of the validation track below.
203
+
204
+ **The CMR track is paused** (maintainer decision 2026-10-16; held for the
205
+ 7.26beta caller-policy fix — #527 now carries direct 7.26beta3 evidence that a
206
+ restricted caller is refused, with the first refusing release still
207
+ unbracketed). It was first from the 2026-10-12 decision on
208
+ [#441](https://github.com/tikoci/centrs/issues/441). Done: `execute --cmr`
187
209
  (#450), which runs one validated command on CMR clients through their
188
210
  controller. The contract is in
189
211
  [`commands/execute/README.md` → CMR](../commands/execute/README.md#cmr-clients-through-their-controller---cmr),
190
212
  with examples CMR0–CMR15. Also done: the fleet blast-radius gate for
191
213
  controller fleet commands run with plain `execute` or `api` (#453), with
192
214
  examples CMR16–CMR18 and api CMR1. The feature map of CMR against centrs is
193
- on #441 (2026-10-13). Next is the read-only slice of #449 (a `devices` view
194
- of members and a default controller). Deferred: CDB linkage (#449) and `retrieve --cmr` (#441).
195
- Live `explain` resumes after this.
215
+ on #441 (2026-10-13). When it resumes, next is the read-only slice of #449 (a
216
+ `devices` view of members and a default controller). Deferred: CDB linkage
217
+ (#449) and `retrieve --cmr` (#441).
196
218
 
197
219
  **The native-api / NDJSON line** (maintainer decision on
198
220
  [#396](https://github.com/tikoci/centrs/issues/396), 2026-10-07): agents in the
@@ -213,11 +235,11 @@ order agreed on [#236](https://github.com/tikoci/centrs/issues/236):
213
235
  2. **Done** — front the `execute` / `api` device preflight with the offline
214
236
  analyzer (#354). The gate is now two-stage; the contract is in
215
237
  [`docs/CONSTITUTION.md` → Validation is the product](CONSTITUTION.md#validation-is-the-product).
216
- 3. **Next** — **live `explain` (#236)**, as stage two of a gate that now exists.
217
- Start with [the #90 task index](https://github.com/tikoci/centrs/issues/90),
218
- then follow [`commands/explain/README.md`](../commands/explain/README.md) and
219
- the live examples in
220
- [`commands/explain/examples.md`](../commands/explain/examples.md).
238
+ 3. **Done** — **live `explain` (#236)**, as stage two of a gate that now exists.
239
+ The contract is [`commands/explain/README.md`](../commands/explain/README.md)
240
+ and the live examples in
241
+ [`commands/explain/examples.md`](../commands/explain/examples.md);
242
+ [#90](https://github.com/tikoci/centrs/issues/90) indexes what follows.
221
243
 
222
244
  The offline baseline stays `verified`: #264 closed the measured token vocabulary
223
245
  and #322 closed the final bounded-execution gap, including the public depth-axis
@@ -226,20 +248,23 @@ guard.
226
248
  The live pass is the device-backed cross-check on the offline result, not a
227
249
  replacement for it. It adds read-only `/console/inspect` and `:parse` evidence,
228
250
  the describe/completion facets, and the live `observedType`/`schemaType`
229
- producers owned with #225. REST and native-api cells advance only when their
251
+ producers (#225). REST and native-api cells advance only when their
230
252
  applicable examples pass on CHR via `bun run test:integration`.
231
253
 
232
254
  Open offline follow-ups remain explicit but do not reopen the verified baseline.
233
- Issue #211 retains the fail-closed unlisted-path decision; #272 owns
234
- non-authoritative `.scratch/` citation cleanup. #350 (the broad optional `=`,
235
- schema-dependent and so belonging to step 3), #351 (an unresolvable menu that
236
- abstains at 2-deep nesting but hard-fails at 3-deep) and #355 (`'` is not a
237
- RouterOS token — a false ACCEPT the corpus join cannot see) are bounded analyzer
238
- defects that step 2 now surfaces through the `execute`/`api` gate. #336 and #342 are complete: the
239
- corpus is repinned to three *complete* builds (long-term 7.23.5, stable 7.24.2,
240
- development 7.25beta3), and the operator sweep and highlight slice are re-swept
241
- onto those same builds — plus 7.21.5 retained as an age anchor — so the operator
242
- axis and the token partition are scored on the same firmware. The #335
255
+ Issue #211 made an unlisted absolute bare path abstain (rule F, R14); rule
256
+ P's closed-world reading is still not adopted (#372). #272 owns
257
+ non-authoritative `.scratch/` citation cleanup. Issues #350 (the broad
258
+ optional `=`), #351 (a relative submenu brace read as a command operand)
259
+ and #355 (`'` is not a RouterOS token) are complete. A submenu block after a navigation statement
260
+ (`/ip⏎address { … }`) keeps the pre-#351 reading; see
261
+ `commands/explain/README.md`. #336, #342 and #498 are complete: the
262
+ corpus is pinned to schema 5, with one recorded session per release channel
263
+ (long-term 7.23.8, stable 7.24.5, testing 7.25rc2, development 7.26beta1) read
264
+ through `scripts/corpus-db.ts`. The highlight slice is cut from those sessions
265
+ and gated in CI, and the operator sweep is re-run on the same builds — plus
266
+ 7.21.5 retained as an age anchor — so the operator axis and the token partition
267
+ are scored on the same firmware. The #335
243
268
  source refresh pins both architectures of those versions beside the 7.10.2 /
244
269
  7.16 history anchors and accepts the current CLI-Reference drift. Issue #223 is
245
270
  a separate MCP response migration. If live evidence finds a wrong offline
@@ -22,9 +22,11 @@ re-proves it (CMR15 in `commands/execute/examples.md`):
22
22
  | `read,test,api,rest-api` | `not enough permissions (9)` | `run-script` wrote to the controller's own row; `/cmr/device/reboot` rebooted a client |
23
23
 
24
24
  MikroTik [says](https://forum.mikrotik.com/t/7-26beta-development-is-released/273582/139) this is fixed for 7.26beta2: a `read` group will not
25
- be allowed to do it. That has not been checked on a beta2 build yet. centrs
26
- still warns on any version, because the warning reads the login's policy, not
27
- the RouterOS version. On a build that refuses the write, the members fail
25
+ be allowed to do it. #527 now carries direct 7.26beta3 evidence that a
26
+ restricted caller is refused, with the first refusing release still
27
+ unbracketed. centrs still warns on any version, because the warning reads
28
+ the login's policy, not the RouterOS version. On a build that refuses the
29
+ write, the members fail
28
30
  instead and the warning only says what the login lacks.
29
31
 
30
32
  `context` names the login (`username`, `group`), the policies it lacks
@@ -0,0 +1,24 @@
1
+ # `routeros/permission-denied`
2
+
3
+ RouterOS refused the command because the user's group lacks a policy it needs.
4
+ The router answered, and a retry gives the same result. A single refused
5
+ command did not run. A multi-statement script run through `/execute` can be
6
+ partly applied: statements before the refused one already ran.
7
+
8
+ ## Typical RouterOS strings
9
+
10
+ - `not enough permissions (9)`: the command needs a policy the group does not
11
+ have, for example a `set` without `write`.
12
+ - `std failure: not allowed (9)`: on REST before RouterOS 7.26beta3, every
13
+ request fails this way (a plain GET included) when the group has `rest-api`
14
+ but not `api`. 7.26beta3 fixed it.
15
+
16
+ Over REST both arrive as HTTP 500 with the string in `detail`; over the native
17
+ API as a `!trap`. Grounded on CHR 7.23.8, 7.25, 7.26beta1 and 7.26beta3.
18
+
19
+ ## Fix
20
+
21
+ Grant the missing policy to the user's group in `/user/group`: `read` for
22
+ reads, `write` for changes. Before 7.26beta3 a REST user needs both `api` and
23
+ `rest-api`. Avoid the built-in `read` group for a least-privilege user: it also
24
+ carries `reboot`, `sensitive`, `password` and others.
@@ -0,0 +1,22 @@
1
+ # `transport/edge-auth-failed`
2
+
3
+ An HTTP layer in front of RouterOS (auth proxy, tunnel edge, WAF) refused the
4
+ request before RouterOS answered it.
5
+
6
+ ## How centrs tells
7
+
8
+ RouterOS answers every REST auth refusal with a JSON body whose `error` equals
9
+ the HTTP status (`{"error":401,"message":"Unauthorized"}`), adding
10
+ `WWW-Authenticate: Basic` only when no credentials were sent. A 401 or 403 with
11
+ any other body, or a challenge for another scheme (Cloudflare Access sends
12
+ `Bearer`), came from something in front of the router. `context` records the
13
+ challenge scheme, `server`, content type and whether a `cf-ray` header was
14
+ present. A `cf-ray` header alone does not decide it: a tunnel passes RouterOS's
15
+ own 401 through with one.
16
+
17
+ ## Fix
18
+
19
+ centrs sends only RouterOS Basic credentials, so changing the RouterOS username
20
+ or password will not help. Point `--host` at an address that reaches RouterOS
21
+ directly, or at a local proxy that authenticates to the front layer for you.
22
+ The request never reached RouterOS, so a write was not applied.
@@ -9,6 +9,9 @@ whether the write happened.
9
9
 
10
10
  - A connection closed or reset after an `execute` or `api` write was sent.
11
11
  - A REST 5xx or a timeout on a write.
12
+ - A multi-statement `execute` or `api /execute` script refused with
13
+ `routeros/permission-denied`: statements before the refused one may already
14
+ have run (#549). A single refused statement did not run and warns nothing.
12
15
 
13
16
  centrs never retries a write that it has sent, in single-target or fan-out
14
17
  mode, because replaying an `add`, a script run or a counter change would apply
@@ -0,0 +1,20 @@
1
+ # `usage/invalid-cursor`
2
+
3
+ An explain --cursor is not a byte offset inside the input being analyzed.
4
+
5
+ ## Typical trigger
6
+
7
+ `centrs explain <router> '<input>' --complete --cursor <byte>` with a value that
8
+ is not a whole number, or that is larger than the input's UTF-8 byte length.
9
+ Library callers get the same code for an original-document position
10
+ (`{ line, character }`) that addresses no character in the input.
11
+
12
+ The check runs before any connection is made, so nothing is sent to the router.
13
+
14
+ ## Fix
15
+
16
+ - Count the cursor in **UTF-8 bytes**, not characters: `é` is two bytes and
17
+ `🛰` is four. The end of the input (the default) is the input's byte length.
18
+ - A cursor inside a multi-byte character is accepted and moves back to that
19
+ character's first byte.
20
+ - Omit `--cursor` to complete or describe at the end of the input.
@@ -19,3 +19,12 @@ Two sources raise it:
19
19
 
20
20
  Check the spelling of the segment in `error.context.segment`. `centrs explain
21
21
  '<command>'` shows how the rest of the command reads offline.
22
+
23
+ One path is not a typo: `/system/routerboard` exists only on MikroTik
24
+ hardware, so `retrieve` answers its absence on CHR and x86 systems with a
25
+ targeted remediation instead of the generic one. For the device's license
26
+ ID, read `/system/license` (`system-id` on CHR, `software-id` on x86):
27
+
28
+ ```bash
29
+ centrs retrieve <router> /system/license
30
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/centrs",
3
- "version": "0.1.9-next.22",
3
+ "version": "0.1.9-next.24",
4
4
  "main": "src/index.ts",
5
5
  "module": "src/index.ts",
6
6
  "repository": {
@@ -40,7 +40,7 @@
40
40
  "bin": {
41
41
  "centrs": "src/cli.ts"
42
42
  },
43
- "description": "All-in-one TypeScript library/CLI/TUI/MCP for interacting with MikroTik RouterOS devices",
43
+ "description": "All-in-one TypeScript library/CLI/MCP for interacting with MikroTik RouterOS devices (TUI stub)",
44
44
  "engines": {
45
45
  "bun": ">=1.3.11 <2"
46
46
  },
@@ -103,11 +103,14 @@
103
103
  "explain:catalog": "bun run scripts/gen-explain-catalog.ts",
104
104
  "explain:catalog:check": "bun run scripts/gen-explain-catalog.ts --check",
105
105
  "explain:corpus-census": "bun run scripts/explain-corpus-census.ts",
106
+ "explain:conformance": "bun run scripts/explain-conformance.ts",
107
+ "explain:conformance:check": "bun run scripts/explain-conformance.ts --check",
106
108
  "explain:highlight-agreement": "bun run scripts/explain-highlight-agreement.ts",
107
109
  "explain:highlight-agreement:check": "bun run scripts/explain-highlight-agreement.ts --check",
108
110
  "explain:highlight-agreement:readme": "bun run scripts/explain-highlight-agreement.ts --readme",
109
111
  "explain:highlight-agreement:readme:check": "bun run scripts/explain-highlight-agreement.ts --readme --check",
110
112
  "explain:highlight-slice": "bun run scripts/explain-highlight-slice.ts",
113
+ "explain:highlight-slice:check": "bun run scripts/explain-highlight-slice.ts --check",
111
114
  "explain:menus": "bun run scripts/gen-explain-menus.ts",
112
115
  "explain:operator-census": "bun run scripts/explain-operator-census.ts",
113
116
  "explain:operator-census:check": "bun run scripts/explain-operator-census.ts --check",
package/src/api.ts CHANGED
@@ -62,6 +62,7 @@ import {
62
62
  promptForWriteConfirmation,
63
63
  validateRouterOsScript,
64
64
  } from "./execute.ts";
65
+ import { countExecutableStatements } from "./explain/segment.ts";
65
66
  import {
66
67
  assertOfflineSyntax,
67
68
  isOfflineGateRejection,
@@ -541,7 +542,7 @@ async function* streamResolvedApi(
541
542
  trace.validation = validation;
542
543
  resolved = await withQueryBooleans(resolved, backend, result.queryMenu);
543
544
  } else {
544
- validation = disabledValidationMeta();
545
+ validation = disabledApiValidationMeta(resolved);
545
546
  }
546
547
  } catch (error) {
547
548
  await backend.close();
@@ -824,16 +825,6 @@ function adapterForResolved(resolved: ResolvedApiRequest): ProtocolAdapter {
824
825
  });
825
826
  }
826
827
 
827
- function disabledValidationMeta(): EnvelopeValidationMeta {
828
- return {
829
- enabled: false,
830
- source: "disabled",
831
- result: "skipped",
832
- syntax: false,
833
- semantic: false,
834
- };
835
- }
836
-
837
828
  export async function resolveApiRequest(
838
829
  request: ApiRequest,
839
830
  env: Record<string, string | undefined>,
@@ -1347,7 +1338,8 @@ interface ApiValidationResult {
1347
1338
  * validator is `/console/inspect`, never `:put [:parse]`. Path existence and
1348
1339
  * add/set attribute validity both come from `request=child` — command-level
1349
1340
  * `request=completion` is unsafe on RouterOS 7.12.2 (`core/inspect.ts`). A
1350
- * `/execute` script is a CLI string → `semantic: not-applicable`.
1341
+ * `/execute` script is a CLI string, so it has no `semantic` summary; a
1342
+ * structured request runs no `:parse`, so it has no `syntax` summary (#535).
1351
1343
  */
1352
1344
  async function validateApiRequest(
1353
1345
  resolved: ResolvedApiRequest,
@@ -1383,8 +1375,7 @@ async function validateApiRequest(
1383
1375
  ? "not-applicable"
1384
1376
  : API_SCRIPT_DEVICE_STAGE_SOURCE,
1385
1377
  result: "passed",
1386
- syntax: script !== undefined,
1387
- semantic: "not-applicable",
1378
+ ...(script !== undefined ? { syntax: true } : {}),
1388
1379
  stages: [
1389
1380
  {
1390
1381
  stage: "offline",
@@ -1468,13 +1459,7 @@ async function validateApiRequest(
1468
1459
  }
1469
1460
  }
1470
1461
  return {
1471
- validation: {
1472
- enabled: true,
1473
- source: "/console/inspect request=child",
1474
- result: "passed",
1475
- syntax: false,
1476
- semantic: true,
1477
- },
1462
+ validation: structuredValidationPassed(resolved),
1478
1463
  tips,
1479
1464
  queryMenu: isCommand ? `/${parent.join("/")}` : resolved.path,
1480
1465
  };
@@ -1513,11 +1498,7 @@ async function validateApiRequest(
1513
1498
  }
1514
1499
  return {
1515
1500
  validation: {
1516
- enabled: true,
1517
- source: "/console/inspect request=child",
1518
- result: "passed",
1519
- syntax: false,
1520
- semantic: true,
1501
+ ...structuredValidationPassed(resolved),
1521
1502
  availableAttributes: available,
1522
1503
  },
1523
1504
  tips,
@@ -1526,13 +1507,7 @@ async function validateApiRequest(
1526
1507
  }
1527
1508
 
1528
1509
  return {
1529
- validation: {
1530
- enabled: true,
1531
- source: "/console/inspect request=child",
1532
- result: "passed",
1533
- syntax: false,
1534
- semantic: true,
1535
- },
1510
+ validation: structuredValidationPassed(resolved),
1536
1511
  tips,
1537
1512
  queryMenu: resolved.path,
1538
1513
  };
@@ -1857,6 +1832,12 @@ export function buildApiErrorEnvelopeFromResolved(
1857
1832
  trace?: ValidationTrace,
1858
1833
  ): ApiErrorEnvelope {
1859
1834
  const centrsError = asApiError(error);
1835
+ // A structured request is one operation; only a `/execute` script can
1836
+ // carry several statements into one dispatch (#549), at the top level or
1837
+ // inside an executed control scope.
1838
+ const script = resolved.scriptMode ? scriptOf(resolved) : undefined;
1839
+ const multiStatement =
1840
+ script !== undefined && countExecutableStatements(script) > 1;
1860
1841
  return {
1861
1842
  ok: false,
1862
1843
  error: serializeCentrsError(centrsError),
@@ -1866,6 +1847,7 @@ export function buildApiErrorEnvelopeFromResolved(
1866
1847
  isApiMutating(resolved.method, resolved.path),
1867
1848
  centrsError,
1868
1849
  trace,
1850
+ multiStatement,
1869
1851
  ),
1870
1852
  ],
1871
1853
  tips: [],
@@ -1877,6 +1859,22 @@ export function buildApiErrorEnvelopeFromResolved(
1877
1859
  };
1878
1860
  }
1879
1861
 
1862
+ /** A structured request the device stage passed; stage 1 does not apply to it. */
1863
+ function structuredValidationPassed(
1864
+ resolved: ResolvedApiRequest,
1865
+ ): EnvelopeValidationMeta {
1866
+ return {
1867
+ enabled: true,
1868
+ source: API_DEVICE_STAGE_SOURCE,
1869
+ result: "passed",
1870
+ semantic: true,
1871
+ stages: [
1872
+ offlineApiStage(resolved),
1873
+ { stage: "device", source: API_DEVICE_STAGE_SOURCE, result: "passed" },
1874
+ ],
1875
+ };
1876
+ }
1877
+
1880
1878
  /**
1881
1879
  * `meta.validation` for an api error envelope. A stage-1 rejection opened no
1882
1880
  * connection, so the meta must not report `/console/inspect` as the validator
@@ -1896,7 +1894,6 @@ function failedApiValidationMeta(
1896
1894
  source: OFFLINE_GATE_SOURCE,
1897
1895
  result: "failed",
1898
1896
  syntax: false,
1899
- semantic: "not-applicable",
1900
1897
  stages: [
1901
1898
  { stage: "offline", source: OFFLINE_GATE_SOURCE, result: "failed" },
1902
1899
  {
@@ -1911,14 +1908,14 @@ function failedApiValidationMeta(
1911
1908
  // Only a `validation/*` error identifies the device stage as the rejecter.
1912
1909
  // Everything downstream of a passed gate — a RouterOS fault on the request,
1913
1910
  // a transport drop — is served by `trace.validation` before this function is
1914
- // ever reached.
1911
+ // ever reached. A script is refused by `:parse` (syntax); a structured
1912
+ // request by `/console/inspect` (names) (#535).
1915
1913
  if (error.code.startsWith("validation/")) {
1916
1914
  return {
1917
1915
  enabled: true,
1918
1916
  source: apiDeviceStageSource(resolved),
1919
1917
  result: "failed",
1920
- syntax: false,
1921
- semantic: resolved.scriptMode ? "not-applicable" : false,
1918
+ ...(resolved.scriptMode ? { syntax: false } : { semantic: false }),
1922
1919
  stages: [offlineApiStage(resolved, trace), deviceStageFailed(resolved)],
1923
1920
  };
1924
1921
  }
@@ -2012,8 +2009,6 @@ function disabledApiValidationMeta(
2012
2009
  enabled: false,
2013
2010
  source: "disabled",
2014
2011
  result: "skipped",
2015
- syntax: false,
2016
- semantic: resolved.scriptMode ? "not-applicable" : false,
2017
2012
  stages: [
2018
2013
  { stage: "offline", source: OFFLINE_GATE_SOURCE, result: "skipped" },
2019
2014
  {