@tikoci/centrs 0.1.8-next.0 → 0.1.8

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 CHANGED
@@ -8,6 +8,27 @@ documenting cross-cutting shifts that affect contributors and consumers.
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ ## 0.1.8 — 2026-10-12
12
+
13
+ Release (npm tag `latest`; `next` moves forward to 0.1.8). Everything in
14
+ 0.1.8-next.0 below, plus two fixes from field-testing it.
15
+
16
+ ### Fixed
17
+
18
+ - `retrieve --wait` with no deadline value names `--wait` (#445). Written as a
19
+ bare switch, `--wait --until 'expr'` read `--until` as the deadline and
20
+ shifted the predicate into a second target, so the CLI reported
21
+ `usage/fanout-not-supported`. It now fails `settings/invalid-timeout`
22
+ ("Received the flag: --until"), and every deadline without a unit gets the
23
+ `--wait`-specific message. A real multi-target `--wait` still gets the
24
+ fanout refusal.
25
+ - MCP accepts a registered device by its `identity=`, `mac=` or `ip=` comment
26
+ key, as the CLI does (#444). The allowlist gate now matches with the same
27
+ resolver the call dials with, so the `mcp=` write policy checked is the
28
+ policy of the record the call runs against. A name on more than one record
29
+ fails `identity/ambiguous` with the candidates in `context.matches` and an
30
+ MCP remediation, instead of resolving to the first.
31
+
11
32
  ## 0.1.8-next.0 — 2026-10-12
12
33
 
13
34
  Pre-release (npm tag `next`; `latest` stays on 0.1.7). Install with
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/centrs",
3
- "version": "0.1.8-next.0",
3
+ "version": "0.1.8",
4
4
  "main": "src/index.ts",
5
5
  "module": "src/index.ts",
6
6
  "repository": {
@@ -28,6 +28,7 @@ import {
28
28
  retrieveSample,
29
29
  retrieveWait,
30
30
  type ValidationTrace,
31
+ validateRetrieveRequestShape,
31
32
  } from "../index.ts";
32
33
  import { assertNoQuickchrOverrideConflict } from "../resolver/index.ts";
33
34
  import {
@@ -245,6 +246,12 @@ export async function runRetrieveCli(args: readonly string[]): Promise<number> {
245
246
  ? "--follow"
246
247
  : "--sample";
247
248
  if (isFanoutMode(selectionFlags, targetPositionals.length)) {
249
+ // A value-less `--wait` swallows the next flag and shifts a
250
+ // positional into a second target, so report a flag-value error
251
+ // before the fanout refusal names the wrong mistake (#445). Only
252
+ // here: single-target streams report shape errors as their own
253
+ // stdout envelope.
254
+ validateRetrieveRequestShape(request);
248
255
  throw new CentrsError({
249
256
  code: "usage/fanout-not-supported",
250
257
  summary: `\`retrieve ${mode}\` watches one router and cannot fan out across multiple targets.`,
package/src/mcp/safety.ts CHANGED
@@ -10,7 +10,12 @@
10
10
  * `mcp=rw`, else `cdb/write-not-permitted`.
11
11
  */
12
12
 
13
- import { type LoadedCdb, loadCdb } from "../devices.ts";
13
+ import {
14
+ type DevicesShowItem,
15
+ type LoadedCdb,
16
+ loadCdb,
17
+ showDevice,
18
+ } from "../devices.ts";
14
19
  import { CentrsError } from "../errors.ts";
15
20
  import type { McpWritePolicy } from "../mcp.ts";
16
21
  import { parseCommentKv } from "../resolver/comment-kv.ts";
@@ -52,30 +57,50 @@ export async function loadAllowlistCdb(
52
57
  * `cdb/target-not-registered` when no record matches — the friendly,
53
58
  * MCP-specific framing of "this device is not on the allowlist", pointing at
54
59
  * `centrs_devices add` rather than leaking the CLI's `cdb/not-found-target`.
60
+ *
61
+ * Matches with {@link showDevice}, the matcher the core resolver dials with
62
+ * (target, `identity=`, `mac=`, `ip=`), so the record whose write policy is
63
+ * checked here is the record the call then runs against (#444). A name that
64
+ * matches more than one record fails `identity/ambiguous` rather than taking
65
+ * the first.
55
66
  */
56
67
  export function resolveRegisteredTarget(
57
68
  cdb: LoadedCdb,
58
69
  target: string,
59
70
  ): RegisteredTarget {
60
- for (let index = 0; index < cdb.entries.length; index += 1) {
61
- const entry = cdb.entries[index];
62
- if (entry && entry.target === target) {
63
- return {
64
- target,
65
- user: entry.user,
66
- group: entry.group,
67
- recordIndex: index,
68
- writePolicy: readWritePolicy(entry.comment),
69
- };
71
+ let entry: DevicesShowItem;
72
+ try {
73
+ entry = showDevice({ cdb, target }).data.entry;
74
+ } catch (error) {
75
+ if (error instanceof CentrsError && error.code === "cdb/not-found-target") {
76
+ throw new CentrsError({
77
+ code: "cdb/target-not-registered",
78
+ summary: `Target "${target}" is not registered in the CDB allowlist.`,
79
+ remediation:
80
+ "Register the device first with the `centrs_devices` tool (op `add`) or `centrs devices add`, then retry. The MCP server only acts on CDB-registered targets, by target or by its `identity=`, `mac=` or `ip=` comment key.",
81
+ context: { target, cdbFile: cdb.settings.cdbFile.value },
82
+ });
83
+ }
84
+ // The CLI remediation names `--match`, which no MCP tool takes.
85
+ if (error instanceof CentrsError && error.code === "identity/ambiguous") {
86
+ throw new CentrsError({
87
+ code: error.code,
88
+ summary: error.summary,
89
+ remediation:
90
+ "Retry with one candidate's address from `context.matches[].target` as `target`. If the candidates share that address too, the CDB holds duplicate records for it: remove or edit the extra one with the `centrs_devices` tool (op `remove` or `set`).",
91
+ context: error.context,
92
+ cause: error,
93
+ });
70
94
  }
95
+ throw error;
71
96
  }
72
- throw new CentrsError({
73
- code: "cdb/target-not-registered",
74
- summary: `Target "${target}" is not registered in the CDB allowlist.`,
75
- remediation:
76
- "Register the device first with the `centrs_devices` tool (op `add`) or `centrs devices add`, then retry. The MCP server only acts on CDB-registered targets.",
77
- context: { target, cdbFile: cdb.settings.cdbFile.value },
78
- });
97
+ return {
98
+ target: entry.target,
99
+ user: entry.user,
100
+ group: entry.group,
101
+ recordIndex: entry.cdbRecordIndex,
102
+ writePolicy: readWritePolicy(entry.comment),
103
+ };
79
104
  }
80
105
 
81
106
  /**
package/src/mcp/tools.ts CHANGED
@@ -193,7 +193,7 @@ export const validateInputShape = {
193
193
  target: z
194
194
  .string()
195
195
  .describe(
196
- "CDB-registered target (name / IP / URL / MAC). Must be on the allowlist.",
196
+ "CDB-registered target: its address (IP / URL / MAC) or its identity=, mac= or ip= comment key. Must be on the allowlist.",
197
197
  ),
198
198
  command: z.string().describe("RouterOS CLI command to dry-run."),
199
199
  via: z
@@ -248,7 +248,9 @@ export const retrieveInputShape = {
248
248
  target: z
249
249
  .string()
250
250
  .optional()
251
- .describe("CDB-registered single target. Use either target or group."),
251
+ .describe(
252
+ "CDB-registered single target: its address (IP / URL / MAC) or its identity=, mac= or ip= comment key. Use either target or group.",
253
+ ),
252
254
  group: z
253
255
  .string()
254
256
  .optional()
@@ -340,7 +342,7 @@ export const executeInputShape = {
340
342
  target: z
341
343
  .string()
342
344
  .describe(
343
- "CDB-registered target. Inline host+credentials are not accepted.",
345
+ "CDB-registered target: its address (IP / URL / MAC) or its identity=, mac= or ip= comment key. Inline host+credentials are not accepted.",
344
346
  ),
345
347
  command: z.string().describe("RouterOS CLI command to run."),
346
348
  confirm: z
package/src/retrieve.ts CHANGED
@@ -874,6 +874,9 @@ export function applyMaxResultsBudget(
874
874
  export function validateRetrieveRequestShape(
875
875
  request: RetrieveRequest,
876
876
  ): readonly string[] {
877
+ // Flag values before the path: a value-less flag shifts the positionals,
878
+ // so the path check would blame the wrong token (#445).
879
+ resolveWaitSettings(request);
877
880
  if (!request.path.startsWith("/")) {
878
881
  throw new CentrsError({
879
882
  code: "input/invalid-routeros-path",
@@ -885,7 +888,6 @@ export function validateRetrieveRequestShape(
885
888
  }
886
889
 
887
890
  const query = resolveRetrieveQuery(request);
888
- resolveWaitSettings(request);
889
891
  if (query && request.listAttributes) {
890
892
  throw new CentrsError({
891
893
  code: "usage/conflicting-flags",
@@ -1027,13 +1029,16 @@ function resolveWaitSettings(
1027
1029
  // A unit is required for the same reason as `--sample`: a bare `30` would
1028
1030
  // be a 30 ms deadline, which expires before validation completes.
1029
1031
  const text = String(request.wait).trim();
1030
- const deadlineMs = /^\d+$/.test(text) ? -1 : parseDuration(text);
1032
+ const deadlineMs = /^\d+(ms|s|m)$/i.test(text) ? parseDuration(text) : -1;
1031
1033
  if (deadlineMs <= 0)
1032
1034
  throw new CentrsError({
1033
1035
  code: "settings/invalid-timeout",
1034
- summary: `\`--wait\` needs a positive deadline with a unit. Received: ${text}`,
1036
+ // `--wait --until …` reads `--until` as the deadline (#445).
1037
+ summary: text.startsWith("-")
1038
+ ? `\`--wait\` needs a deadline value before the next flag. Received the flag: ${text}`
1039
+ : `\`--wait\` needs a positive deadline with a unit. Received: ${text}`,
1035
1040
  remediation:
1036
- "Pass an overall duration such as `--wait 30s` or `--wait 5m`.",
1041
+ "Pass an overall duration such as `--wait 30s` or `--wait 5m`, e.g. `--wait 20s --until 'running'`.",
1037
1042
  context: { flag: "--wait", value: text },
1038
1043
  });
1039
1044
  return {