@tikoci/centrs 0.1.9-next.24 → 0.1.9

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 (71) hide show
  1. package/CHANGELOG.md +121 -6
  2. package/GLOSSARY.txt +9 -1
  3. package/README.md +43 -29
  4. package/commands/api/AGENTS.md +3 -2
  5. package/commands/api/README.md +8 -1
  6. package/commands/btest/README.md +9 -0
  7. package/commands/btest/examples.md +7 -0
  8. package/commands/devices/README.md +160 -30
  9. package/commands/devices/examples.md +168 -2
  10. package/commands/execute/README.md +13 -2
  11. package/commands/explain/README.md +223 -3
  12. package/commands/explain/examples.md +8 -1
  13. package/commands/retrieve/README.md +24 -3
  14. package/commands/retrieve/examples.md +84 -0
  15. package/commands/settings/README.md +6 -0
  16. package/commands/settings/examples.md +17 -0
  17. package/commands/transfer/README.md +25 -0
  18. package/commands/transfer/examples.md +46 -0
  19. package/docs/CLI.md +53 -11
  20. package/docs/MATRIX.md +5 -3
  21. package/docs/errors/cdb/encrypt-failed.md +10 -0
  22. package/docs/errors/cdb/password-required.md +5 -4
  23. package/docs/errors/cdb/plaintext-backups.md +11 -0
  24. package/docs/errors/input/empty-stdin.md +27 -0
  25. package/docs/errors/input/invalid-routeros-path.md +25 -3
  26. package/docs/errors/input/invalid-stdin.md +28 -0
  27. package/docs/errors/usage/stdin-is-tty.md +21 -0
  28. package/package.json +11 -7
  29. package/src/api-fanout.ts +2 -0
  30. package/src/api.ts +12 -0
  31. package/src/btest.ts +7 -0
  32. package/src/cli/api.ts +19 -2
  33. package/src/cli/btest.ts +9 -0
  34. package/src/cli/common.ts +1 -0
  35. package/src/cli/devices.ts +248 -15
  36. package/src/cli/execute.ts +12 -3
  37. package/src/cli/retrieve.ts +9 -0
  38. package/src/cli/settings.ts +28 -6
  39. package/src/cli/terminal.ts +9 -0
  40. package/src/cli/transfer.ts +10 -1
  41. package/src/core/error-catalog.ts +28 -2
  42. package/src/core/menu-path.ts +89 -0
  43. package/src/devices.ts +378 -33
  44. package/src/execute-fanout.ts +2 -0
  45. package/src/execute.ts +7 -0
  46. package/src/explain/blocks.ts +2 -2
  47. package/src/explain/catalog.ts +22 -12
  48. package/src/explain/pasted-prompt.ts +198 -0
  49. package/src/explain/pathresolve.ts +2 -2
  50. package/src/explain/segment.ts +2 -2
  51. package/src/explain/symbols.ts +2 -2
  52. package/src/explain/verbsplit.ts +2 -2
  53. package/src/explain/write.ts +2 -2
  54. package/src/explain.ts +2 -0
  55. package/src/index.ts +5 -0
  56. package/src/mcp/safety.ts +7 -6
  57. package/src/mcp/tools.ts +30 -68
  58. package/src/protocols/adapter.ts +2 -0
  59. package/src/protocols/btest-session.ts +10 -30
  60. package/src/protocols/ec-srp5.ts +19 -10
  61. package/src/protocols/mac-telnet-console.ts +2 -2
  62. package/src/resolver/cdb.ts +66 -9
  63. package/src/resolver/index.ts +1 -0
  64. package/src/resolver/quickchr-provider.ts +5 -0
  65. package/src/resolver/selection.ts +15 -4
  66. package/src/retrieve-fanout.ts +2 -0
  67. package/src/retrieve.ts +11 -0
  68. package/src/settings.ts +24 -8
  69. package/src/terminal.ts +7 -0
  70. package/src/transfer-fanout.ts +2 -0
  71. package/src/transfer.ts +79 -14
@@ -9,7 +9,7 @@
9
9
  * Union of two first-order sources (#228):
10
10
  *
11
11
  * 1. MikroTik's published CLI Reference (`https://manual.mikrotik.com/docs/cli-reference/`),
12
- * 1,099 pages, 1,106 entries — first-order about the definition
12
+ * 1,109 pages, 1,116 entries — first-order about the definition
13
13
  * structs and their build-time gates. Since #285 every page is a leaf whose
14
14
  * slug is the CLI path, so no spelling has to be rewritten to be looked up.
15
15
  * 2. 8 pinned restraml `/console/inspect` trees
@@ -28,9 +28,9 @@
28
28
  *
29
29
  * | Kind | Total | `both` | `inspect` | `published` |
30
30
  * | ---- | ----- | ------ | --------- | ----------- |
31
- * | `menu` | 568 | 457 | 67 | 44 |
32
- * | `command` | 477 | 436 | 0 | 41 |
33
- * | `settings` | 121 | 112 | 0 | 9 |
31
+ * | `menu` | 571 | 457 | 67 | 47 |
32
+ * | `command` | 483 | 436 | 0 | 47 |
33
+ * | `settings` | 122 | 112 | 0 | 10 |
34
34
  *
35
35
  * Zero navigation-vs-command contradictions between the two sources. Generation
36
36
  * aborts if that ever stops holding, rather than picking a winner. Where a path
@@ -49,9 +49,9 @@
49
49
  *
50
50
  * **Gates conjoin down a path, so read them with {@link effectiveGates}, not
51
51
  * row by row.** A row states only what the publication stated at that entry.
52
- * Read row-wise, 1 published-only path looks ungated; read with
52
+ * Read row-wise, 7 published-only paths look ungated; read with
53
53
  * ancestry, the residue carrying no published explanation for its absence at
54
- * all is 1.
54
+ * all is 7.
55
55
  *
56
56
  * Tree COMMANDS are not enumerated — those are the generic CRUD leaves
57
57
  * `verbs.ts` already owns. The command rows here are the published,
@@ -91,7 +91,7 @@ export interface CatalogEntry {
91
91
  * `path|kind|provenance|package|conditions|syscap` — with trailing empty
92
92
  * columns trimmed. Lower-cased, slash-led and sorted by path.
93
93
  *
94
- * Text rather than ~1,166 object literals so that one path is one line in a
94
+ * Text rather than ~1,176 object literals so that one path is one line in a
95
95
  * review diff, and so the formatter has nothing to re-wrap.
96
96
  */
97
97
  const ROWS = `
@@ -101,6 +101,7 @@ const ROWS = `
101
101
  /app/remove|command|both|container||app
102
102
  /app/restart|command|both|container||app
103
103
  /app/settings|settings|both|container||app
104
+ /app/settings/remove-certificate|command|published|container||app
104
105
  /app/setup|command|both|container||app
105
106
  /app/update|command|both|container||app
106
107
  /beep|command|both
@@ -160,9 +161,9 @@ const ROWS = `
160
161
  /cmr/alert|menu|both|cmr
161
162
  /cmr/alert/show-devices|command|both|cmr
162
163
  /cmr/alert/test|command|both|cmr
163
- /cmr/client|settings|both||!smips, !powerpc
164
- /cmr/client/forget|command|both||!smips, !powerpc
165
- /cmr/client/pair|command|both||!smips, !powerpc
164
+ /cmr/client|settings|both||!smips
165
+ /cmr/client/forget|command|both||!smips
166
+ /cmr/client/pair|command|both||!smips
166
167
  /cmr/client/push-button|command|both
167
168
  /cmr/device|menu|both|cmr
168
169
  /cmr/device/apptraffic|command|both|cmr
@@ -485,6 +486,12 @@ const ROWS = `
485
486
  /interface/wifi/dashboard/show-logs|command|both
486
487
  /interface/wifi/datapath|menu|both
487
488
  /interface/wifi/devel|command|both
489
+ /interface/wifi/easymesh|settings|published
490
+ /interface/wifi/easymesh/bss|menu|published
491
+ /interface/wifi/easymesh/dpp-trigger|command|published
492
+ /interface/wifi/easymesh/sta|menu|published
493
+ /interface/wifi/easymesh/topology|menu|published
494
+ /interface/wifi/easymesh/wps-push-button|command|published
488
495
  /interface/wifi/flat-snoop|command|both
489
496
  /interface/wifi/frequency-scan|command|both
490
497
  /interface/wifi/interworking|menu|both
@@ -1095,6 +1102,9 @@ const ROWS = `
1095
1102
  /system/resource/cpu|menu|both
1096
1103
  /system/resource/hardware|menu|both||!powerpc, !smips
1097
1104
  /system/resource/hardware/authorize|command|both||!powerpc, !smips
1105
+ /system/resource/hardware/power-cycle|command|published||!powerpc, !smips|usb-hub
1106
+ /system/resource/hardware/power-reset|command|published||!powerpc, !smips, !i386
1107
+ /system/resource/hardware/reset-overcurrent|command|published||!powerpc, !smips|usb-hub
1098
1108
  /system/resource/hardware/usb-power-reset|command|both||!powerpc, !smips, i386
1099
1109
  /system/resource/hardware/usb-settings|settings|both||!powerpc, !smips
1100
1110
  /system/resource/irq|menu|both
@@ -1373,8 +1383,8 @@ export interface CatalogGate {
1373
1383
  * parent's `syscap` applies even though the child entry states none: the gates
1374
1384
  * up a path CONJOIN, they do not override.
1375
1385
  *
1376
- * Read row-wise, 1 published-only path looks ungated; read with
1377
- * ancestry the residue is 1. The gap between the two is the
1386
+ * Read row-wise, 7 published-only paths look ungated; read with
1387
+ * ancestry the residue is 7. The gap between the two is the
1378
1388
  * conjunction, and it is what a caller has to reproduce: a child's silence about
1379
1389
  * a gate is not the absence of one. #228's finding — that a published-only path
1380
1390
  * almost always explains its own absence — is about the ancestry-aware number.
@@ -0,0 +1,198 @@
1
+ import { buildTip, type Tip } from "../core/envelope.ts";
2
+ import { type Segment, segmentStatements } from "./segment.ts";
3
+
4
+ /**
5
+ * Pasted console-prompt repair (#370).
6
+ *
7
+ * Recognizes two grounded prompt shapes at the start of a *statement* (not a
8
+ * raw line, so prompt-looking text inside strings and comments stays legal):
9
+ *
10
+ * - `[user@host] <menu>> <command>` — e.g. `[admin@MikroTik] > /ip/address print`
11
+ * at the root, or `[admin@MikroTik] /ip/address> add …` inside a menu, the
12
+ * shape the manual's Console page shows; a HotLock prompt doubles the `>`
13
+ * - `/menu/path> <command>` — e.g. `/interface/lte> monitor lte1`
14
+ *
15
+ * Segmentation already split the document on real statement boundaries with
16
+ * string/comment awareness, so testing each segment's start is a lexical
17
+ * position, not a line-start regex. A prompt-looking line inside a multiline
18
+ * string is interior to its segment and never at a segment start; comment
19
+ * lines never become segments at all.
20
+ *
21
+ * Severity / envelope behavior (chosen, documented here per #370 scope):
22
+ * the tip is ADVISORY only and never changes `data.verdict`. The bracket
23
+ * shape already fails via `invalid-at-sign` (the `@` is a hard fault); the
24
+ * menu shape stays `warn` (abstention — offline cannot prove the menu
25
+ * exists on any given device). Output banners (`Flags:`, `Columns:`) and
26
+ * bare `>` forms are deliberately NOT detected here; they are separately
27
+ * grounded cases, not automatic extensions.
28
+ *
29
+ * The stripped candidate is offered as repair TEXT only and is never adopted
30
+ * or executed: tips carry no execution semantics. For the menu shape the
31
+ * candidate is the literal text after `>` (it runs in that menu's context
32
+ * on the device, not from the root).
33
+ *
34
+ * The candidate is the prompt-stripped tail of the ORIGINAL input — separators,
35
+ * blocks and newlines preserved verbatim — bounded by the next pasted prompt
36
+ * or the end of input, so each prompt in a transcript gets its own repair.
37
+ * Reconstructing from segment texts would flatten `{ ... }` blocks and drop
38
+ * whatever follows them. The slice needs analyzed-byte offsets as JS indices,
39
+ * which hold only when the segmentation reports `ascii`; otherwise the
40
+ * per-segment candidate stands (narrow, never wrong).
41
+ *
42
+ * No two adjacent quantifiers in BRACKET_PROMPT_RE can match the same bytes
43
+ * (the user part stops at the first `@`; the menu's leading space lives inside
44
+ * its optional group), so a near-miss fails in linear time.
45
+ */
46
+ const BRACKET_PROMPT_RE =
47
+ /^\[[^[\]\r\n@]*@[^[\]\r\n]*\](?:\s*(\/[A-Za-z0-9_\-/]*))?\s*>>?\s*([\s\S]*)$/;
48
+ const MENU_PROMPT_RE = /^(\/[A-Za-z0-9_\-/]+)\s*>>?\s*([\s\S]*)$/;
49
+
50
+ export interface PastedPromptHit {
51
+ shape: "bracket" | "menu";
52
+ menu?: string;
53
+ candidate: string;
54
+ }
55
+
56
+ interface StrippedPrompt {
57
+ shape: "bracket" | "menu";
58
+ menu?: string;
59
+ /** Length of the prompt prefix within the segment text. */
60
+ promptEnd: number;
61
+ candidate: string;
62
+ }
63
+
64
+ /** True when re-segmenting `text` reports an unbalanced delimiter. */
65
+ function hasUnbalancedDelimiters(text: string): boolean {
66
+ try {
67
+ return segmentStatements(text).defects.some(
68
+ (d) => d.code === "unbalanced-close" || d.code === "unclosed",
69
+ );
70
+ } catch {
71
+ return true;
72
+ }
73
+ }
74
+
75
+ export function detectPastedPrompts(input: string): PastedPromptHit[] {
76
+ const hits: PastedPromptHit[] = [];
77
+ let segments: Segment[];
78
+ let ascii: boolean;
79
+ try {
80
+ const result = segmentStatements(input);
81
+ segments = result.segments;
82
+ ascii = result.ascii;
83
+ } catch {
84
+ return hits;
85
+ }
86
+ // Per-segment prompt match. A bare prompt with no command ("prompt-empty")
87
+ // earns no hit but still bounds the previous hit's region.
88
+ const stripped: (StrippedPrompt | "prompt-empty" | null)[] = segments.map(
89
+ (segment) => {
90
+ const text = segment.text;
91
+ if (text.length === 0) return null;
92
+ const bracket = BRACKET_PROMPT_RE.exec(text);
93
+ if (bracket !== null) {
94
+ const menu = bracket[1];
95
+ const group = bracket[2] ?? "";
96
+ return group.trim().length > 0
97
+ ? {
98
+ shape: "bracket",
99
+ ...(menu && menu !== "/" ? { menu } : {}),
100
+ promptEnd: text.length - group.length,
101
+ candidate: group.trim(),
102
+ }
103
+ : "prompt-empty";
104
+ }
105
+ const menu = MENU_PROMPT_RE.exec(text);
106
+ if (menu !== null) {
107
+ const group = menu[2] ?? "";
108
+ return group.trim().length > 0
109
+ ? {
110
+ shape: "menu",
111
+ menu: menu[1],
112
+ promptEnd: text.length - group.length,
113
+ candidate: group.trim(),
114
+ }
115
+ : "prompt-empty";
116
+ }
117
+ return null;
118
+ },
119
+ );
120
+ for (let i = 0; i < segments.length; i++) {
121
+ const hit = stripped[i];
122
+ if (hit === null || hit === undefined || hit === "prompt-empty") continue;
123
+ let candidate = hit.candidate;
124
+ if (ascii) {
125
+ const segment = segments[i];
126
+ let end = input.length;
127
+ for (let j = i + 1; j < segments.length; j++) {
128
+ if (stripped[j] !== null && stripped[j] !== undefined) {
129
+ end = segments[j]?.start ?? end;
130
+ break;
131
+ }
132
+ }
133
+ const sliced = input
134
+ .slice((segment?.start ?? 0) + hit.promptEnd, end)
135
+ .trim();
136
+ // The slice must not cross the prompt's enclosing container: it is
137
+ // kept only when it re-segments balanced, else the narrow
138
+ // per-segment candidate stands (`{ /menu> cmd }` would otherwise
139
+ // offer `cmd }`).
140
+ if (sliced.length > 0 && !hasUnbalancedDelimiters(sliced))
141
+ candidate = sliced;
142
+ }
143
+ hits.push({
144
+ shape: hit.shape,
145
+ ...(hit.menu ? { menu: hit.menu } : {}),
146
+ candidate,
147
+ });
148
+ }
149
+ return hits;
150
+ }
151
+
152
+ /**
153
+ * A markdown code span that survives backticks in `text`: the fence is one
154
+ * longer than the longest backtick run inside, padded when `text` starts or
155
+ * ends with one (CommonMark code spans).
156
+ */
157
+ function codeSpan(text: string): string {
158
+ const fence = "`".repeat(longestBacktickRun(text) + 1);
159
+ const pad = text.startsWith("`") || text.endsWith("`") ? " " : "";
160
+ return `${fence}${pad}${text}${pad}${fence}`;
161
+ }
162
+
163
+ /**
164
+ * A fenced code block for multi-line text: a code span would render its
165
+ * newlines as spaces, and `:put 1` NEWLINE `:put 2` is not `:put 1 :put 2`.
166
+ */
167
+ function codeBlock(text: string): string {
168
+ const fence = "`".repeat(Math.max(3, longestBacktickRun(text) + 1));
169
+ return `${fence}\n${text}\n${fence}`;
170
+ }
171
+
172
+ function longestBacktickRun(text: string): number {
173
+ let longest = 0;
174
+ for (const run of text.match(/`+/g) ?? [])
175
+ longest = Math.max(longest, run.length);
176
+ return longest;
177
+ }
178
+
179
+ export function pastedPromptTips(input: string): Tip[] {
180
+ return detectPastedPrompts(input).map((hit) => {
181
+ const where = hit.menu ? ` (typed at ${codeSpan(hit.menu)})` : "";
182
+ const fix = /[\r\n]/.test(hit.candidate)
183
+ ? `Drop the pasted prompt and try this${where} instead — this input was not executed:\n\n${codeBlock(hit.candidate)}`
184
+ : `Drop the pasted prompt and try ${codeSpan(hit.candidate)}${where} instead — this input was not executed.`;
185
+ if (hit.shape === "menu")
186
+ return buildTip(
187
+ "tip/explain-pasted-prompt",
188
+ `This looks like a pasted ${codeSpan(hit.menu ?? "")} menu prompt, not a command to run from the root.`,
189
+ fix,
190
+ );
191
+ const prompt = hit.menu ? `[user@host] ${hit.menu}>` : "[user@host] >";
192
+ return buildTip(
193
+ "tip/explain-pasted-prompt",
194
+ `This looks like a pasted console transcript (${codeSpan(prompt)}), not a command to run.`,
195
+ fix,
196
+ );
197
+ });
198
+ }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Offline path resolution for `explain` (centrs canonicalizer).
3
3
  *
4
- * Ratified by the phase-0 lab, questions Q3 and Q4 (#185), promoted from the
5
- * throwaway probe `.scratch/explain-lab-pathresolve.ts`. Two surfaces:
4
+ * Ratified by the phase-0 lab, questions Q3 and Q4 (#185); the lab probe
5
+ * it was promoted from is history (see #185), not a runnable method. Two surfaces:
6
6
  *
7
7
  * resolveStatements (Q4) — the fully-qualified canonical path of every
8
8
  * statement in source order, tracking the persistent menu
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Statement segmenter for `explain` (centrs canonicalizer).
3
3
  *
4
- * Ratified by the phase-0 lab, question Q1 (#185) and promoted from the
5
- * throwaway probe `.scratch/explain-lab-segmenter.ts`. This is the first walker
4
+ * Ratified by the phase-0 lab, question Q1 (#185); the lab probe it was
5
+ * promoted from is history (see #185), not a runnable method. This is the first walker
6
6
  * stage: a schema-free, device-free splitter of multi-statement RouterOS input
7
7
  * into top-level statements, matching `(evl …)` sibling adjacency in `:parse`
8
8
  * IL. It never throws; structural surprises are reported as located
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Symbol scope resolution for `explain` (centrs canonicalizer).
3
3
  *
4
- * Ratified by the phase-0 lab, question Q13 (#185) and promoted from the
5
- * throwaway probe `.scratch/explain-lab-symbols.ts`. Q13 asks whether an
4
+ * Ratified by the phase-0 lab, question Q13 (#185); the lab probe it was
5
+ * promoted from is history (see #185), not a runnable method. Q13 asks whether an
6
6
  * offline, schema-free reader can reproduce the class the console assigns to
7
7
  * every variable occurrence — `variable-local` / `-global` / `-auto` /
8
8
  * `-parameter` / `-undefined` in `/console/inspect request=highlight`.
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Verb / menu boundary resolution for `explain` (centrs canonicalizer).
3
3
  *
4
- * Ratified by the phase-0 lab, question Q6 (#185) and promoted from the
5
- * throwaway probe `.scratch/explain-lab-verbsplit.ts`. Q6 asks the residual the
4
+ * Ratified by the phase-0 lab, question Q6 (#185); the lab probe it was
5
+ * promoted from is history (see #185), not a runnable method. Q6 asks the residual the
6
6
  * path resolver (Q3/Q4) deliberately left open: within a statement's leading
7
7
  * path run, where does the MENU end and the VERB begin — and when must offline
8
8
  * refuse to guess? `pathresolve.ts` reports a greedy full-run `path` plus the
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Offline write-shape inference for `explain` — `structure.containsWrite`.
3
3
  *
4
- * Ratified by the phase-0 lab, question Q16 (#185) and promoted from the
5
- * throwaway probe `.scratch/explain-lab-q16-write.ts`. Q16 asked what shape the
4
+ * Ratified by the phase-0 lab, question Q16 (#185); the lab probe it was
5
+ * promoted from is history (see #185), not a runnable method. Q16 asked what shape the
6
6
  * field should have and answered that the spec's sketched **boolean is unsafe**:
7
7
  * a fail-closed `true | false | unknown` reaches 100% precision on what it
8
8
  * decides (226/226 holdout, 469/469 dev) with zero false negatives, abstaining
package/src/explain.ts CHANGED
@@ -145,6 +145,7 @@ import { escapeSpans } from "./explain/escape-tokens.ts";
145
145
  import { findGroupFaults, type GroupFault } from "./explain/group-faults.ts";
146
146
  import { isKnownMenuPath } from "./explain/is-known-menu.ts";
147
147
  import { operatorSpans } from "./explain/operator-tokens.ts";
148
+ import { pastedPromptTips } from "./explain/pasted-prompt.ts";
148
149
  import { type PathTokenCandidate, pathSpans } from "./explain/path-tokens.ts";
149
150
  import {
150
151
  type Resolution,
@@ -2871,6 +2872,7 @@ export function explainEnvelope(
2871
2872
  "No router was given, so this is the canonicalizer's reading alone.",
2872
2873
  "Add a router (`centrs explain <router> '<input>'`) for read-only parse, highlight and argument-name evidence; `--schema` and `--complete` then describe paths and complete at the cursor.",
2873
2874
  ),
2875
+ ...pastedPromptTips(input),
2874
2876
  ...(options.tips ?? []),
2875
2877
  ];
2876
2878
  const warnings: Warning[] = [...(options.warnings ?? [])];
package/src/index.ts CHANGED
@@ -209,6 +209,7 @@ export {
209
209
  addDevice,
210
210
  buildDevicesErrorEnvelope,
211
211
  type DevicesCommand,
212
+ type DevicesEncryptData,
212
213
  type DevicesEnvelope,
213
214
  type DevicesGroupSummary,
214
215
  type DevicesInitData,
@@ -219,11 +220,15 @@ export {
219
220
  type DevicesOutputFormat,
220
221
  type DevicesShowEnvelopeData,
221
222
  type DevicesShowItem,
223
+ type DevicesShowItemWithPassword,
222
224
  type DevicesWarning,
223
225
  type DevicesWhereClause,
224
226
  defaultCdbPath,
225
227
  devicesOutputFormats,
228
+ type EncryptCdbOptions,
226
229
  editInteractiveOnlyError,
230
+ encryptCdb,
231
+ type InitCdbOptions,
227
232
  initCdb,
228
233
  type LoadCdbOptions,
229
234
  type LoadedCdb,
package/src/mcp/safety.ts CHANGED
@@ -11,10 +11,10 @@
11
11
  */
12
12
 
13
13
  import {
14
- type DevicesShowItem,
14
+ type DeviceRecordLookup,
15
15
  type LoadedCdb,
16
16
  loadCdb,
17
- showDevice,
17
+ lookupDeviceRecord,
18
18
  } from "../devices.ts";
19
19
  import { CentrsError } from "../errors.ts";
20
20
  import type { McpWritePolicy } from "../mcp.ts";
@@ -63,7 +63,7 @@ export async function loadAllowlistCdb(
63
63
  * MCP-specific framing of "this device is not on the allowlist", pointing at
64
64
  * `centrs_devices add` rather than leaking the CLI's `cdb/not-found-target`.
65
65
  *
66
- * Matches with {@link showDevice}, the matcher the core resolver dials with
66
+ * Matches with {@link lookupDeviceRecord}, the matcher the core resolver dials with
67
67
  * (target, `identity=`, `mac=`, `ip=`), so the record whose write policy is
68
68
  * checked here is the record the call then runs against (#444). A name that
69
69
  * matches more than one record fails `identity/ambiguous` rather than taking
@@ -75,9 +75,9 @@ export function resolveRegisteredTarget(
75
75
  ): RegisteredTarget {
76
76
  // Before the lookup, whose refusal echoes the target (#478).
77
77
  assertNoTargetPassword(target);
78
- let entry: DevicesShowItem;
78
+ let found: DeviceRecordLookup;
79
79
  try {
80
- entry = showDevice({ cdb, target }).data.entry;
80
+ found = lookupDeviceRecord({ cdb, target });
81
81
  } catch (error) {
82
82
  // Preserve registered identities; mask credential-shaped lookup failures.
83
83
  if (redactTargetInput(target) !== target) {
@@ -105,11 +105,12 @@ export function resolveRegisteredTarget(
105
105
  }
106
106
  throw error;
107
107
  }
108
+ const { entry } = found;
108
109
  return {
109
110
  target: entry.target,
110
111
  user: entry.user,
111
112
  group: entry.group,
112
- recordIndex: entry.cdbRecordIndex,
113
+ recordIndex: found.cdbRecordIndex,
113
114
  writePolicy: readWritePolicy(entry.comment),
114
115
  };
115
116
  }
package/src/mcp/tools.ts CHANGED
@@ -63,8 +63,6 @@ export interface McpOperationMeta {
63
63
  */
64
64
  type McpEnvelope = CentrsEnvelope;
65
65
 
66
- type EnvelopeWithData = CentrsEnvelope<unknown> & { data?: unknown };
67
-
68
66
  /**
69
67
  * Build a minimal centrs error envelope for failures that happen in the MCP
70
68
  * adapter itself (allowlist rejections, policy denials) — before or instead of
@@ -111,39 +109,6 @@ function requireConfirm(tool: string, confirmed: boolean | undefined): void {
111
109
  }
112
110
  }
113
111
 
114
- function redactEntry(entry: unknown): unknown {
115
- if (typeof entry !== "object" || entry === null) {
116
- return entry;
117
- }
118
- const { password, ...rest } = entry as Record<string, unknown>;
119
- return {
120
- ...rest,
121
- passwordSet: typeof password === "string" ? password.length > 0 : undefined,
122
- };
123
- }
124
-
125
- function redactDeviceSecrets(envelope: McpEnvelope): McpEnvelope {
126
- if (!envelope.ok) {
127
- return envelope;
128
- }
129
- const withData = envelope as EnvelopeWithData;
130
- const data = withData.data;
131
- if (typeof data !== "object" || data === null) {
132
- return envelope;
133
- }
134
- const deviceData = data as { entry?: unknown };
135
- if (deviceData.entry === undefined) {
136
- return envelope;
137
- }
138
- return {
139
- ...envelope,
140
- data: {
141
- ...data,
142
- entry: redactEntry(deviceData.entry),
143
- },
144
- };
145
- }
146
-
147
112
  // --- centrs_explain ---------------------------------------------------------
148
113
 
149
114
  export const explainInputShape = {
@@ -630,6 +595,9 @@ export async function handleDevices(
630
595
  context: { op: args.op },
631
596
  });
632
597
  }
598
+ // `show`/`add`/`set` entries come back with the password withheld and
599
+ // `passwordSet` in its place: core redacts by default and MCP never
600
+ // passes `showPassword` (#551).
633
601
  switch (args.op) {
634
602
  case "list":
635
603
  return listDevices({
@@ -643,29 +611,25 @@ export async function handleDevices(
643
611
  return listGroups({ cdb, withMembers: true });
644
612
  case "show": {
645
613
  const target = requireDevicesTarget(args, "show");
646
- return redactDeviceSecrets(
647
- showDevice({ cdb, target, env: config.env }),
648
- );
614
+ return showDevice({ cdb, target, env: config.env });
649
615
  }
650
616
  case "add": {
651
617
  requireConfirm("centrs_devices op=add", args.confirm);
652
618
  const target = requireDevicesTarget(args, "add");
653
- return redactDeviceSecrets(
654
- await addDevice({
655
- cdb,
656
- target,
657
- recordType: resolveRecordType(args.recordType),
658
- user: args.user,
659
- password: args.password,
660
- group: args.group,
661
- profile: args.profile,
662
- session: args.session,
663
- comment: args.comment,
664
- commentKvUpdates: buildGeoUpdates(args),
665
- savedPassword: args.savedPassword,
666
- force: args.force,
667
- }),
668
- );
619
+ return await addDevice({
620
+ cdb,
621
+ target,
622
+ recordType: resolveRecordType(args.recordType),
623
+ user: args.user,
624
+ password: args.password,
625
+ group: args.group,
626
+ profile: args.profile,
627
+ session: args.session,
628
+ comment: args.comment,
629
+ commentKvUpdates: buildGeoUpdates(args),
630
+ savedPassword: args.savedPassword,
631
+ force: args.force,
632
+ });
669
633
  }
670
634
  case "edit":
671
635
  // The interactive editor has no MCP analogue; modify via op=set.
@@ -673,20 +637,18 @@ export async function handleDevices(
673
637
  case "set": {
674
638
  requireConfirm("centrs_devices op=set", args.confirm);
675
639
  const target = requireDevicesTarget(args, "set");
676
- return redactDeviceSecrets(
677
- await setDevice({
678
- cdb,
679
- target,
680
- updates: [...buildGeoUpdates(args), ...(args.updates ?? [])],
681
- user: args.user,
682
- password: args.password,
683
- group: args.group,
684
- profile: args.profile,
685
- session: args.session,
686
- savedPassword: args.savedPassword,
687
- strict: args.strict,
688
- }),
689
- );
640
+ return await setDevice({
641
+ cdb,
642
+ target,
643
+ updates: [...buildGeoUpdates(args), ...(args.updates ?? [])],
644
+ user: args.user,
645
+ password: args.password,
646
+ group: args.group,
647
+ profile: args.profile,
648
+ session: args.session,
649
+ savedPassword: args.savedPassword,
650
+ strict: args.strict,
651
+ });
690
652
  }
691
653
  case "remove": {
692
654
  requireConfirm("centrs_devices op=remove", args.confirm);
@@ -14,6 +14,7 @@
14
14
  * protocol data.
15
15
  */
16
16
 
17
+ import { assertRestRequestPath } from "../core/menu-path.ts";
17
18
  import { mapRouterOsError } from "../core/routeros-errors.ts";
18
19
  import { CentrsError } from "../errors.ts";
19
20
  import { formatEndpoint } from "./endpoint.ts";
@@ -1336,6 +1337,7 @@ function repliesToRecords(
1336
1337
  }
1337
1338
 
1338
1339
  function joinRestUrl(baseUrl: string, path: string): string {
1340
+ assertRestRequestPath(path);
1339
1341
  const normalizedPath = path.startsWith("/") ? path : `/${path}`;
1340
1342
  return `${baseUrl}${normalizedPath}`;
1341
1343
  }
@@ -72,7 +72,6 @@ import {
72
72
  } from "./btest.ts";
73
73
  import {
74
74
  bytesToBigIntBE,
75
- type EcSrp5Shared,
76
75
  ecSrp5ClientConfirm,
77
76
  ecSrp5ClientShared,
78
77
  ecSrp5Id,
@@ -677,13 +676,11 @@ async function runClientEcSrp5(
677
676
 
678
677
  const challenge = parseServerChallenge(await readAuthFrame(channel));
679
678
  const validator = ecSrp5Id(username, password, challenge.salt);
680
- const shared = btestShared(
681
- ecSrp5ClientShared(
682
- keypair.privateKey,
683
- challenge.serverPublicKey,
684
- keypair.publicKey,
685
- validator,
686
- ),
679
+ const shared = ecSrp5ClientShared(
680
+ keypair.privateKey,
681
+ challenge.serverPublicKey,
682
+ keypair.publicKey,
683
+ validator,
687
684
  );
688
685
  const clientCc = ecSrp5ClientConfirm(shared);
689
686
  await channel.write(encodeConfirmation(clientCc));
@@ -785,13 +782,11 @@ async function runServerEcSrp5(
785
782
  );
786
783
  await channel.write(encodeServerChallenge(serverPublicKey, salt));
787
784
 
788
- const shared = btestShared(
789
- ecSrp5ServerShared(
790
- keypair.privateKey,
791
- validatorScalar,
792
- hello.publicKey,
793
- serverPublicKey,
794
- ),
785
+ const shared = ecSrp5ServerShared(
786
+ keypair.privateKey,
787
+ validatorScalar,
788
+ hello.publicKey,
789
+ serverPublicKey,
795
790
  );
796
791
  const clientCc = await readAuthFrame(channel);
797
792
  const expectedCc = ecSrp5ClientConfirm(shared);
@@ -804,21 +799,6 @@ async function runServerEcSrp5(
804
799
  return hello.username;
805
800
  }
806
801
 
807
- /**
808
- * The shared secret as btest hashes it: `z` without its leading zero bytes
809
- * (OpenSSL `BN_bn2bin`), where MAC-Telnet's `mtwei.c` pads it to 32
810
- * (`BN_bn2binpad`), so the shared math in `ec-srp5.ts` keeps 32 bytes.
811
- *
812
- * Grounded on CHR 7.24.5 `/tool/bandwidth-server`: over 800 client handshakes,
813
- * every one whose `z` began with 0x00 (9) failed with the padded form and no
814
- * other did. Stripped, 600 of 600 verified, 4 of them with a leading zero.
815
- */
816
- export function btestShared(shared: EcSrp5Shared): EcSrp5Shared {
817
- let start = 0;
818
- while (start < shared.z.length - 1 && shared.z[start] === 0) start++;
819
- return start === 0 ? shared : { j: shared.j, z: shared.z.subarray(start) };
820
- }
821
-
822
802
  function ecSrp5RandomSalt(): Uint8Array {
823
803
  return crypto.getRandomValues(new Uint8Array(16));
824
804
  }