@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.
- package/CHANGELOG.md +106 -0
- package/GLOSSARY.txt +38 -0
- package/README.md +2 -2
- package/commands/api/AGENTS.md +18 -1
- package/commands/api/README.md +10 -6
- package/commands/api/examples.md +1 -1
- package/commands/execute/README.md +26 -6
- package/commands/execute/examples.md +43 -5
- package/commands/explain/README.md +873 -182
- package/commands/explain/examples.md +447 -38
- package/commands/retrieve/README.md +13 -2
- package/commands/retrieve/examples.md +30 -1
- package/commands/transfer/README.md +8 -4
- package/docs/CLI.md +19 -5
- package/docs/CONSTITUTION.md +18 -9
- package/docs/MATRIX.md +51 -26
- package/docs/errors/cmr/caller-policy-not-enforced.md +5 -3
- package/docs/errors/routeros/permission-denied.md +24 -0
- package/docs/errors/transport/edge-auth-failed.md +22 -0
- package/docs/errors/transport/write-outcome-unknown.md +3 -0
- package/docs/errors/usage/invalid-cursor.md +20 -0
- package/docs/errors/validation/unknown-path.md +9 -0
- package/package.json +5 -2
- package/src/api.ts +34 -39
- package/src/cli/explain.ts +206 -47
- package/src/core/envelope.ts +10 -3
- package/src/core/error-catalog.ts +15 -0
- package/src/core/execute-command.ts +53 -0
- package/src/core/fanout.ts +12 -2
- package/src/core/inspect.ts +4 -5
- package/src/core/routeros-errors.ts +25 -0
- package/src/execute-cmr.ts +2 -8
- package/src/execute.ts +48 -20
- package/src/explain/args.ts +181 -15
- package/src/explain/blocks.ts +3 -4
- package/src/explain/brace-slots.ts +5 -0
- package/src/explain/catalog.ts +13 -6
- package/src/explain/defects.ts +1 -0
- package/src/explain/group-faults.ts +1060 -0
- package/src/explain/highlight-projection.ts +10 -8
- package/src/explain/operators.ts +22 -20
- package/src/explain/pathresolve.ts +102 -20
- package/src/explain/quoted-string.ts +41 -6
- package/src/explain/run-tokens.ts +223 -0
- package/src/explain/scope-brace.ts +319 -7
- package/src/explain/segment.ts +177 -4
- package/src/explain/spaced-args.ts +116 -0
- package/src/explain/symbols.ts +163 -7
- package/src/explain/transport.ts +13 -0
- package/src/explain/values.ts +55 -4
- package/src/explain/verbsplit.ts +14 -188
- package/src/explain/write.ts +8 -1
- package/src/explain-live-facets.ts +739 -0
- package/src/explain-live-types.ts +490 -0
- package/src/explain-live.ts +593 -0
- package/src/explain.ts +772 -60
- package/src/index.ts +6 -0
- package/src/offline-gate.ts +30 -9
- package/src/protocols/adapter.ts +88 -10
- package/src/protocols/btest-session.ts +30 -10
- package/src/protocols/mac-telnet-console.ts +16 -0
- package/src/protocols/mac-telnet.ts +38 -10
- package/src/protocols/native-api.ts +13 -12
- package/src/retrieve-follow.ts +2 -1
- package/src/retrieve-sample.ts +2 -1
- package/src/retrieve-wait.ts +10 -6
- package/src/retrieve.ts +77 -27
- package/src/transfer.ts +38 -13
package/src/cli/explain.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `centrs explain` CLI surface: command metadata, arg parsing, dispatch, and
|
|
3
|
-
* envelope rendering for
|
|
3
|
+
* envelope rendering for offline analysis and read-only live evidence (#491).
|
|
4
4
|
*
|
|
5
5
|
* ## The positional grammar is conditional, and that is deliberate
|
|
6
6
|
*
|
|
@@ -20,9 +20,8 @@
|
|
|
20
20
|
*
|
|
21
21
|
* ## What this surface refuses to do
|
|
22
22
|
*
|
|
23
|
-
* - **
|
|
24
|
-
*
|
|
25
|
-
* report `mode: "offline"` about an invocation that asked for more.
|
|
23
|
+
* - **Live probes never execute the input.** The shared live module attaches
|
|
24
|
+
* device evidence, `--schema` and `--complete` to the offline result.
|
|
26
25
|
* - **Fan-out is rejected** (`usage/fanout-not-supported`, the `terminal`
|
|
27
26
|
* pattern): `explain` takes at most one router.
|
|
28
27
|
* - **`--complete` / `--schema` enumerate nothing offline.** They are accepted,
|
|
@@ -45,9 +44,17 @@ import {
|
|
|
45
44
|
renderExplainEnvelope,
|
|
46
45
|
resolveExplainFormat,
|
|
47
46
|
} from "../explain.ts";
|
|
47
|
+
import {
|
|
48
|
+
assertExplainTarget,
|
|
49
|
+
type ExplainLiveRequest,
|
|
50
|
+
explainLiveEnvelope,
|
|
51
|
+
} from "../explain-live.ts";
|
|
52
|
+
import { resolveExplainCursor } from "../explain-live-facets.ts";
|
|
48
53
|
import { describeCentrs } from "../index.ts";
|
|
49
54
|
import { loadEnvFileDefaults } from "../resolver/config-file.ts";
|
|
55
|
+
import { redactTargetInput } from "../resolver/index.ts";
|
|
50
56
|
import type { ResolvedSetting } from "../resolver/settings.ts";
|
|
57
|
+
import { apiCommand } from "./api.ts";
|
|
51
58
|
import {
|
|
52
59
|
type CliCommandMetadata,
|
|
53
60
|
expectValue,
|
|
@@ -59,10 +66,30 @@ import { selectionFlagTokens } from "./selection.ts";
|
|
|
59
66
|
|
|
60
67
|
export const explainCliCommand: CliCommandMetadata = {
|
|
61
68
|
name: "explain",
|
|
62
|
-
usage: "centrs explain '<input>' [flags]",
|
|
69
|
+
usage: "centrs explain [<router>] '<input>' [flags]",
|
|
63
70
|
summary:
|
|
64
|
-
"Analyze
|
|
71
|
+
"Analyze RouterOS input offline or add read-only device evidence from one router.",
|
|
65
72
|
options: [
|
|
73
|
+
...apiCommand.options.filter((o) =>
|
|
74
|
+
[
|
|
75
|
+
"--via",
|
|
76
|
+
"--host",
|
|
77
|
+
"--port",
|
|
78
|
+
"--username / --user / -u",
|
|
79
|
+
"--password",
|
|
80
|
+
"--timeout",
|
|
81
|
+
"--insecure",
|
|
82
|
+
"--cdb-file",
|
|
83
|
+
"--cdb-password",
|
|
84
|
+
"--resolve",
|
|
85
|
+
].includes(o.flag),
|
|
86
|
+
),
|
|
87
|
+
{ flag: "-p", valueName: "<secret>", description: "Alias for --password." },
|
|
88
|
+
{
|
|
89
|
+
flag: "--quickchr",
|
|
90
|
+
valueName: "<name>",
|
|
91
|
+
description: "Resolve one running quickchr VM by name.",
|
|
92
|
+
},
|
|
66
93
|
{
|
|
67
94
|
flag: "--file",
|
|
68
95
|
valueName: "<path>",
|
|
@@ -78,12 +105,23 @@ export const explainCliCommand: CliCommandMetadata = {
|
|
|
78
105
|
{
|
|
79
106
|
flag: "--complete",
|
|
80
107
|
description:
|
|
81
|
-
"
|
|
108
|
+
"Candidates at the cursor: menus and verbs, argument names, or values. Live evidence — offline emits a tip and enumerates nothing.",
|
|
82
109
|
},
|
|
83
110
|
{
|
|
84
111
|
flag: "--schema",
|
|
85
112
|
description:
|
|
86
|
-
"
|
|
113
|
+
"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.",
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
flag: "--cursor",
|
|
117
|
+
valueName: "<byte>",
|
|
118
|
+
description:
|
|
119
|
+
"UTF-8 byte offset for --complete/--schema. Default: the end of the input.",
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
flag: "--full",
|
|
123
|
+
description:
|
|
124
|
+
"Lift --complete/--schema sizing to its safety ceiling (1024 names, 128 expansion probes).",
|
|
87
125
|
},
|
|
88
126
|
{
|
|
89
127
|
flag: "--curl",
|
|
@@ -106,16 +144,20 @@ export const explainCliCommand: CliCommandMetadata = {
|
|
|
106
144
|
],
|
|
107
145
|
};
|
|
108
146
|
|
|
109
|
-
export interface ExplainCliArgs {
|
|
147
|
+
export interface ExplainCliArgs extends Omit<ExplainLiveRequest, "input"> {
|
|
110
148
|
help?: boolean;
|
|
111
149
|
positionals: string[];
|
|
112
150
|
filePath?: string;
|
|
113
151
|
failOn: ExplainFailOn;
|
|
114
152
|
facets: string[];
|
|
153
|
+
cursor?: number;
|
|
154
|
+
full?: boolean;
|
|
115
155
|
curl?: boolean;
|
|
116
156
|
tokens?: boolean;
|
|
117
157
|
format?: ExplainOutputFormat;
|
|
118
158
|
verbose?: boolean;
|
|
159
|
+
/** First explicitly supplied connection flag, for the missing-target refusal. */
|
|
160
|
+
connectionFlag?: string;
|
|
119
161
|
}
|
|
120
162
|
|
|
121
163
|
export function parseExplainCliArgs(args: readonly string[]): ExplainCliArgs {
|
|
@@ -133,6 +175,7 @@ export function parseExplainCliArgs(args: readonly string[]): ExplainCliArgs {
|
|
|
133
175
|
parsed.positionals.push(arg);
|
|
134
176
|
continue;
|
|
135
177
|
}
|
|
178
|
+
if (LIVE_CONNECTION_FLAGS.has(arg)) parsed.connectionFlag ??= arg;
|
|
136
179
|
switch (arg) {
|
|
137
180
|
case "--":
|
|
138
181
|
// Everything after `--` is input, however command-shaped it looks:
|
|
@@ -144,6 +187,54 @@ export function parseExplainCliArgs(args: readonly string[]): ExplainCliArgs {
|
|
|
144
187
|
case "-h":
|
|
145
188
|
parsed.help = true;
|
|
146
189
|
break;
|
|
190
|
+
case "--via":
|
|
191
|
+
parsed.via = expectValue(args, ++index, arg);
|
|
192
|
+
break;
|
|
193
|
+
case "--host":
|
|
194
|
+
parsed.host = expectValue(args, ++index, arg);
|
|
195
|
+
break;
|
|
196
|
+
case "--quickchr":
|
|
197
|
+
if (parsed.quickchr !== undefined)
|
|
198
|
+
throw fanoutNotSupportedError({ flag: arg });
|
|
199
|
+
parsed.quickchr = expectValue(args, ++index, arg);
|
|
200
|
+
break;
|
|
201
|
+
case "--timeout":
|
|
202
|
+
parsed.timeout = expectValue(args, ++index, arg);
|
|
203
|
+
break;
|
|
204
|
+
case "--cdb-file":
|
|
205
|
+
parsed.cdbFile = expectValue(args, ++index, arg);
|
|
206
|
+
break;
|
|
207
|
+
case "--cdb-password":
|
|
208
|
+
parsed.cdbPassword = expectValue(args, ++index, arg);
|
|
209
|
+
break;
|
|
210
|
+
case "--resolve":
|
|
211
|
+
parsed.resolve = expectValue(args, ++index, arg);
|
|
212
|
+
break;
|
|
213
|
+
case "--password":
|
|
214
|
+
parsed.password = expectValue(args, ++index, arg);
|
|
215
|
+
break;
|
|
216
|
+
case "-p":
|
|
217
|
+
parsed.password = expectValue(args, ++index, arg);
|
|
218
|
+
break;
|
|
219
|
+
case "-u":
|
|
220
|
+
case "--user":
|
|
221
|
+
case "--username":
|
|
222
|
+
parsed.username = expectValue(args, ++index, arg);
|
|
223
|
+
break;
|
|
224
|
+
case "--insecure":
|
|
225
|
+
parsed.insecure = true;
|
|
226
|
+
break;
|
|
227
|
+
case "--port": {
|
|
228
|
+
const value = expectValue(args, ++index, arg);
|
|
229
|
+
if (!/^\d+$/.test(value))
|
|
230
|
+
throw new CentrsError({
|
|
231
|
+
code: "input/invalid-command",
|
|
232
|
+
summary: "Port must be an integer.",
|
|
233
|
+
remediation: "Pass a TCP port between 1 and 65535.",
|
|
234
|
+
});
|
|
235
|
+
parsed.port = Number(value);
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
147
238
|
case "--file":
|
|
148
239
|
parsed.filePath = expectValue(args, ++index, arg);
|
|
149
240
|
break;
|
|
@@ -167,6 +258,22 @@ export function parseExplainCliArgs(args: readonly string[]): ExplainCliArgs {
|
|
|
167
258
|
case "--schema":
|
|
168
259
|
parsed.facets.push("--schema");
|
|
169
260
|
break;
|
|
261
|
+
case "--cursor": {
|
|
262
|
+
const value = expectValue(args, ++index, arg);
|
|
263
|
+
if (!/^\d+$/.test(value))
|
|
264
|
+
throw new CentrsError({
|
|
265
|
+
code: "usage/invalid-cursor",
|
|
266
|
+
summary: `--cursor must be a non-negative byte offset; got ${value}.`,
|
|
267
|
+
remediation:
|
|
268
|
+
"Pass a UTF-8 byte offset into the input, or omit --cursor to complete at the end.",
|
|
269
|
+
context: { cursor: value },
|
|
270
|
+
});
|
|
271
|
+
parsed.cursor = Number(value);
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
case "--full":
|
|
275
|
+
parsed.full = true;
|
|
276
|
+
break;
|
|
170
277
|
case "--curl":
|
|
171
278
|
parsed.curl = true;
|
|
172
279
|
break;
|
|
@@ -236,35 +343,11 @@ function fanoutNotSupportedError(context: {
|
|
|
236
343
|
...(context.flag === undefined ? {} : { flag: context.flag }),
|
|
237
344
|
...(context.targets === undefined
|
|
238
345
|
? {}
|
|
239
|
-
: { targets:
|
|
346
|
+
: { targets: context.targets.map(redactTargetInput) }),
|
|
240
347
|
},
|
|
241
348
|
});
|
|
242
349
|
}
|
|
243
350
|
|
|
244
|
-
/**
|
|
245
|
-
* The one live-form error.
|
|
246
|
-
*
|
|
247
|
-
* Named separately because it is a PHASE boundary, not a usage mistake: the
|
|
248
|
-
* invocation is the ratified grammar and will work unchanged when phase 2
|
|
249
|
-
* lands, so the remediation points at the offline form rather than at `--help`.
|
|
250
|
-
*/
|
|
251
|
-
function liveNotImplementedError(router: string): CentrsError {
|
|
252
|
-
// A "router" that starts `/` or `:` is almost always an unquoted input the
|
|
253
|
-
// shell split, so name that reading first rather than reporting a router
|
|
254
|
-
// nobody meant to give.
|
|
255
|
-
const looksLikeInput = /^[/:]/.test(router);
|
|
256
|
-
return new CentrsError({
|
|
257
|
-
code: "usage/not-implemented",
|
|
258
|
-
summary: looksLikeInput
|
|
259
|
-
? `Read \`${router}\` as a router, because two positionals mean \`<router> '<input>'\` — the input was probably not quoted.`
|
|
260
|
-
: `\`centrs explain <router> '<input>'\` needs live \`/console/inspect\` evidence, which is phase 2; the router \`${router}\` was not contacted.`,
|
|
261
|
-
remediation: looksLikeInput
|
|
262
|
-
? "Quote the whole input as one argument: `centrs explain '/ip/route print'`."
|
|
263
|
-
: "Drop the router for the offline analysis (`centrs explain '<input>'`), or use `rosetta` for documented RouterOS schema facts.",
|
|
264
|
-
context: { router, phase: "2" },
|
|
265
|
-
});
|
|
266
|
-
}
|
|
267
|
-
|
|
268
351
|
/**
|
|
269
352
|
* Is fd 0 attached to something that could be carrying input?
|
|
270
353
|
*
|
|
@@ -290,11 +373,8 @@ function stdinMayCarryInput(): boolean {
|
|
|
290
373
|
/**
|
|
291
374
|
* Split the positionals into targets and the one that may be the input.
|
|
292
375
|
*
|
|
293
|
-
* Pure and cheap
|
|
294
|
-
*
|
|
295
|
-
* `centrs explain router --file -` blocked on EOF before returning
|
|
296
|
-
* `usage/not-implemented`, and `… --file missing` reported a file error instead
|
|
297
|
-
* of the live refusal — the wrong answer, arrived at slowly.
|
|
376
|
+
* Pure and cheap: fan-out and unsupported facets are refused before any file
|
|
377
|
+
* or stdin is read. Supported live calls read the input before connecting.
|
|
298
378
|
*
|
|
299
379
|
* `--file` replaces the input positional, so with it every positional is a
|
|
300
380
|
* target. Otherwise the LAST positional is the input and everything before it is
|
|
@@ -320,7 +400,7 @@ function splitPositionals(parsed: ExplainCliArgs): {
|
|
|
320
400
|
}
|
|
321
401
|
|
|
322
402
|
/**
|
|
323
|
-
* Read the input text
|
|
403
|
+
* Read the input text after the invocation shape has been checked.
|
|
324
404
|
*
|
|
325
405
|
* ## Ambient stdin is read ONLY when nothing else could be the input
|
|
326
406
|
*
|
|
@@ -396,7 +476,40 @@ async function readFileInput(path: string): Promise<string> {
|
|
|
396
476
|
}
|
|
397
477
|
|
|
398
478
|
/** Flags whose NEXT token is a value, so a scanner must skip it. */
|
|
399
|
-
const VALUE_FLAGS = new Set([
|
|
479
|
+
const VALUE_FLAGS = new Set([
|
|
480
|
+
"--file",
|
|
481
|
+
"--fail-on",
|
|
482
|
+
"--format",
|
|
483
|
+
"--via",
|
|
484
|
+
"--host",
|
|
485
|
+
"--port",
|
|
486
|
+
"--username",
|
|
487
|
+
"--user",
|
|
488
|
+
"-u",
|
|
489
|
+
"--password",
|
|
490
|
+
"-p",
|
|
491
|
+
"--timeout",
|
|
492
|
+
"--cdb-file",
|
|
493
|
+
"--cdb-password",
|
|
494
|
+
"--resolve",
|
|
495
|
+
"--quickchr",
|
|
496
|
+
"--cursor",
|
|
497
|
+
]);
|
|
498
|
+
|
|
499
|
+
const LIVE_CONNECTION_FLAGS = new Set([
|
|
500
|
+
"--via",
|
|
501
|
+
"--port",
|
|
502
|
+
"--username",
|
|
503
|
+
"--user",
|
|
504
|
+
"-u",
|
|
505
|
+
"--password",
|
|
506
|
+
"-p",
|
|
507
|
+
"--timeout",
|
|
508
|
+
"--insecure",
|
|
509
|
+
"--cdb-file",
|
|
510
|
+
"--cdb-password",
|
|
511
|
+
"--resolve",
|
|
512
|
+
]);
|
|
400
513
|
|
|
401
514
|
/**
|
|
402
515
|
* The render options, recovered when the parse loop THREW.
|
|
@@ -472,22 +585,68 @@ export async function runExplainCli(args: readonly string[]): Promise<number> {
|
|
|
472
585
|
const { targets, inputPositional } = splitPositionals(parsed);
|
|
473
586
|
if (targets.length > 1) throw fanoutNotSupportedError({ targets });
|
|
474
587
|
const router = targets[0];
|
|
475
|
-
|
|
588
|
+
assertExplainTarget({ targetInput: router, quickchr: parsed.quickchr });
|
|
589
|
+
if (router !== undefined && (/^\//.test(router) || /^:[a-z]/i.test(router)))
|
|
590
|
+
throw new CentrsError({
|
|
591
|
+
code: "input/invalid-command",
|
|
592
|
+
summary:
|
|
593
|
+
"The input was probably not quoted; two positionals mean router then input.",
|
|
594
|
+
remediation:
|
|
595
|
+
"Quote the whole input as one argument: `centrs explain '/ip/route print'`.",
|
|
596
|
+
});
|
|
597
|
+
const live =
|
|
598
|
+
router !== undefined ||
|
|
599
|
+
parsed.quickchr !== undefined ||
|
|
600
|
+
parsed.host !== undefined;
|
|
601
|
+
if (!live && parsed.connectionFlag !== undefined)
|
|
602
|
+
throw new CentrsError({
|
|
603
|
+
code: "input/invalid-command",
|
|
604
|
+
summary: `${parsed.connectionFlag} requires a router target for live explain.`,
|
|
605
|
+
remediation:
|
|
606
|
+
"Add <router>, --host <host>, or --quickchr <name>; remove the connection flag for offline analysis.",
|
|
607
|
+
context: { flag: parsed.connectionFlag },
|
|
608
|
+
});
|
|
609
|
+
if (
|
|
610
|
+
parsed.facets.length === 0 &&
|
|
611
|
+
(parsed.cursor !== undefined || parsed.full)
|
|
612
|
+
)
|
|
613
|
+
throw new CentrsError({
|
|
614
|
+
code: "usage/conflicting-flags",
|
|
615
|
+
summary: `${parsed.cursor !== undefined ? "--cursor" : "--full"} applies only with --complete or --schema.`,
|
|
616
|
+
remediation: "Add --complete or --schema, or remove the flag.",
|
|
617
|
+
});
|
|
476
618
|
|
|
477
619
|
const { input, warnings } = await readInput(parsed, inputPositional);
|
|
620
|
+
resolveExplainCursor(input, parsed.cursor);
|
|
478
621
|
|
|
479
|
-
const
|
|
622
|
+
const options = {
|
|
480
623
|
format,
|
|
481
624
|
curl: parsed.curl,
|
|
482
625
|
tokens: parsed.tokens,
|
|
483
626
|
warnings,
|
|
484
627
|
tips:
|
|
485
|
-
parsed.facets.length > 0
|
|
628
|
+
!live && parsed.facets.length > 0
|
|
486
629
|
? [buildExplainFacetTip(parsed.facets)]
|
|
487
630
|
: undefined,
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
|
|
631
|
+
};
|
|
632
|
+
const envelope = live
|
|
633
|
+
? await explainLiveEnvelope(
|
|
634
|
+
{
|
|
635
|
+
...parsed,
|
|
636
|
+
targetInput: router,
|
|
637
|
+
input,
|
|
638
|
+
schema: parsed.facets.includes("--schema"),
|
|
639
|
+
complete: parsed.facets.includes("--complete"),
|
|
640
|
+
},
|
|
641
|
+
options,
|
|
642
|
+
env,
|
|
643
|
+
)
|
|
644
|
+
: explainEnvelope(input, options);
|
|
645
|
+
if (envelope.ok) console.log(renderExplainEnvelope(envelope, format.value));
|
|
646
|
+
else console.error(renderExplainEnvelope(envelope, format.value));
|
|
647
|
+
return envelope.ok
|
|
648
|
+
? explainExitCode(envelope.data.verdict, parsed.failOn)
|
|
649
|
+
: 1;
|
|
491
650
|
} catch (error) {
|
|
492
651
|
// Only here is the parser's answer unavailable — it threw. `config` is
|
|
493
652
|
// whatever loaded before the throw, which is `{}` when the loader itself is
|
package/src/core/envelope.ts
CHANGED
|
@@ -185,10 +185,17 @@ export interface EnvelopeValidationMeta {
|
|
|
185
185
|
* `stages[]` then says which stage was not reached and why (GH#435).
|
|
186
186
|
*/
|
|
187
187
|
result?: "passed" | "failed" | "skipped";
|
|
188
|
-
/**
|
|
188
|
+
/**
|
|
189
|
+
* The syntax verdict (`:parse` or the offline analyzer). Present only when a
|
|
190
|
+
* stage gave one; absent means no stage was asked (#535).
|
|
191
|
+
*/
|
|
189
192
|
syntax?: boolean;
|
|
190
|
-
/**
|
|
191
|
-
|
|
193
|
+
/**
|
|
194
|
+
* The name-level verdict (`/console/inspect` or a console `:parse` `bad
|
|
195
|
+
* parameter`). Present only when a stage gave one; absent means no stage was
|
|
196
|
+
* asked, including for script input, which has no names to check (#535).
|
|
197
|
+
*/
|
|
198
|
+
semantic?: boolean;
|
|
192
199
|
availableAttributes?: readonly string[];
|
|
193
200
|
/**
|
|
194
201
|
* Per-stage detail for the gate described in
|
|
@@ -431,6 +431,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
431
431
|
code: "routeros/mac-telnet-unsupported-auth",
|
|
432
432
|
summary: "The device requested an unsupported MAC-Telnet auth mode.",
|
|
433
433
|
},
|
|
434
|
+
{
|
|
435
|
+
code: "routeros/permission-denied",
|
|
436
|
+
summary:
|
|
437
|
+
"RouterOS refused the command because the user's group lacks a policy it needs.",
|
|
438
|
+
},
|
|
434
439
|
{
|
|
435
440
|
code: "routeros/protocol-not-implemented",
|
|
436
441
|
summary: "The requested protocol is planned but not implemented yet.",
|
|
@@ -563,6 +568,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
563
568
|
code: "transport/dns",
|
|
564
569
|
summary: "The device host name could not be resolved.",
|
|
565
570
|
},
|
|
571
|
+
{
|
|
572
|
+
code: "transport/edge-auth-failed",
|
|
573
|
+
summary:
|
|
574
|
+
"An HTTP layer in front of RouterOS (auth proxy, tunnel edge, WAF) refused the request before RouterOS answered it.",
|
|
575
|
+
},
|
|
566
576
|
{
|
|
567
577
|
code: "transport/host-key-mismatch",
|
|
568
578
|
summary: "The device's SSH host key did not match the known key.",
|
|
@@ -652,6 +662,11 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
|
|
|
652
662
|
summary:
|
|
653
663
|
"The command or mode cannot fan out across multiple targets (e.g. terminal, --listen/--stream).",
|
|
654
664
|
},
|
|
665
|
+
{
|
|
666
|
+
code: "usage/invalid-cursor",
|
|
667
|
+
summary:
|
|
668
|
+
"An explain --cursor is not a byte offset inside the input being analyzed.",
|
|
669
|
+
},
|
|
655
670
|
{
|
|
656
671
|
code: "usage/invalid-method",
|
|
657
672
|
summary: "The -X/--method value is not a supported HTTP method.",
|
|
@@ -104,11 +104,64 @@ export function isWriteShaped(command: CanonicalExecuteCommand): boolean {
|
|
|
104
104
|
if (Object.keys(command.attributes).some(isFileOutputAttribute)) {
|
|
105
105
|
return true;
|
|
106
106
|
}
|
|
107
|
+
if (isBareMenuNavigation(command)) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
107
110
|
return !READ_ONLY_EXECUTE_VERBS.has(command.verb.toLowerCase());
|
|
108
111
|
}
|
|
109
112
|
return scriptContainsWriteShapedCommand(command.input);
|
|
110
113
|
}
|
|
111
114
|
|
|
115
|
+
/**
|
|
116
|
+
* A truly bare known-menu path (`/system/identity`, no attributes or queries)
|
|
117
|
+
* is console navigation — a no-op read, not a command — so it needs no write
|
|
118
|
+
* confirmation. The APIs refuse it (`no such command`) and the consoles
|
|
119
|
+
* navigate silently; neither mutates.
|
|
120
|
+
*
|
|
121
|
+
* This is narrower than the script branch on purpose. There, `/ip firewall
|
|
122
|
+
* filter` stays write-shaped (#387): a later line may carry a relative verb,
|
|
123
|
+
* so navigation fails closed. Here structured mode proves the input is a
|
|
124
|
+
* single path token (any second word without `=` — including a second line —
|
|
125
|
+
* falls back to script), so no follow-up is possible. The carve-out still
|
|
126
|
+
* fails closed three ways: anything the menu table does not carry stays gated,
|
|
127
|
+
* a verb in the known-mutator list stays gated even if the table ever
|
|
128
|
+
* mis-tags its path, and any attribute or query (including `;`-smuggled text,
|
|
129
|
+
* which lands in the verb or an attribute, never in a clean menu reading)
|
|
130
|
+
* keeps the gate shut. The gate is shared, so bare menus also stop tripping
|
|
131
|
+
* `cdb/write-not-permitted` on `mcp=ro` targets (and stop needing
|
|
132
|
+
* `confirm:true` on `mcp=rw` ones), and `explain`'s `writeShaped` flips to
|
|
133
|
+
* false for these inputs — harmless in all three, since the input is a
|
|
134
|
+
* no-op read either way.
|
|
135
|
+
*/
|
|
136
|
+
function isBareMenuNavigation(command: CanonicalExecuteCommand): boolean {
|
|
137
|
+
if (
|
|
138
|
+
Object.keys(command.attributes).length > 0 ||
|
|
139
|
+
command.queries.length > 0
|
|
140
|
+
) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
if (KNOWN_WRITE_EXECUTE_VERBS.has(command.verb.toLowerCase())) {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
const segments = `${command.path}/${command.verb}`.split("/").filter(Boolean);
|
|
147
|
+
if (segments.length < 2) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
return isKnownMenuPath(segments);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Whether `execute` was asked to run a bare menu path (see
|
|
155
|
+
* {@link isBareMenuNavigation}). The envelope carries a steering tip for it:
|
|
156
|
+
* navigation runs — the gate no longer stops it — but it answers nothing, so
|
|
157
|
+
* the tip names the read that does.
|
|
158
|
+
*/
|
|
159
|
+
export function isBareMenuPathCommand(
|
|
160
|
+
command: CanonicalExecuteCommand,
|
|
161
|
+
): boolean {
|
|
162
|
+
return command.mode === "structured" && isBareMenuNavigation(command);
|
|
163
|
+
}
|
|
164
|
+
|
|
112
165
|
/**
|
|
113
166
|
* The attributes that turn an otherwise read-only command into a device write.
|
|
114
167
|
*
|
package/src/core/fanout.ts
CHANGED
|
@@ -94,6 +94,7 @@ export const NOT_SENT_TRANSPORT_CODES: readonly string[] = [
|
|
|
94
94
|
"transport/auth-failed",
|
|
95
95
|
"transport/connection-refused",
|
|
96
96
|
"transport/dns",
|
|
97
|
+
"transport/edge-auth-failed",
|
|
97
98
|
"transport/host-key-mismatch",
|
|
98
99
|
"transport/insecure-trust",
|
|
99
100
|
"transport/local-tool-missing",
|
|
@@ -106,15 +107,24 @@ export const NOT_SENT_TRANSPORT_CODES: readonly string[] = [
|
|
|
106
107
|
* #448: a write whose request was already sent when a transport failure ended
|
|
107
108
|
* it may have run on the router. It is never retried, and its error envelope
|
|
108
109
|
* says the outcome is unknown rather than implying nothing happened.
|
|
110
|
+
*
|
|
111
|
+
* #549: the same warning covers a dispatched multi-statement write refused
|
|
112
|
+
* with `routeros/permission-denied`, which can be partly applied — statements
|
|
113
|
+
* before the refused one already ran. A single refused statement did not run
|
|
114
|
+
* (#541), so it stays silent; the caller passes whether the dispatch carried
|
|
115
|
+
* more than one statement.
|
|
109
116
|
*/
|
|
110
117
|
export function sentWriteWarnings(
|
|
111
118
|
write: boolean,
|
|
112
119
|
error: unknown,
|
|
113
120
|
trace: ValidationTrace | undefined,
|
|
121
|
+
multiStatement = false,
|
|
114
122
|
): Warning[] {
|
|
115
123
|
if (!write || !trace?.dispatched) return [];
|
|
116
|
-
if (
|
|
117
|
-
|
|
124
|
+
if (!(error instanceof CentrsError)) return [];
|
|
125
|
+
if (error.code === "routeros/permission-denied") {
|
|
126
|
+
if (!multiStatement) return [];
|
|
127
|
+
} else if (
|
|
118
128
|
!error.code.startsWith("transport/") ||
|
|
119
129
|
NOT_SENT_TRANSPORT_CODES.includes(error.code)
|
|
120
130
|
) {
|
package/src/core/inspect.ts
CHANGED
|
@@ -39,12 +39,11 @@ export interface InspectBackend {
|
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
41
|
* The `/console/inspect` request modes (lsp's `InspectRequest`). Only `child`
|
|
42
|
-
* and `completion` are wired
|
|
42
|
+
* and `completion` are wired here (the two the {@link InspectBackend} seam
|
|
43
43
|
* exposes), plus `input` for completion over CLI text
|
|
44
|
-
* ({@link inspectWhereBooleans})
|
|
45
|
-
* `syntax`
|
|
46
|
-
*
|
|
47
|
-
* seam when adding them.
|
|
44
|
+
* ({@link inspectWhereBooleans}). The protocol adapter also supports
|
|
45
|
+
* `highlight` and `syntax` for live explain, which sends `syntax` for argument
|
|
46
|
+
* paths only (`src/explain-live-facets.ts`).
|
|
48
47
|
*/
|
|
49
48
|
export type InspectRequestKind =
|
|
50
49
|
| "child"
|
|
@@ -107,6 +107,31 @@ function cleanToken(token: string): string {
|
|
|
107
107
|
* rules here; keep the most specific patterns above broader ones.
|
|
108
108
|
*/
|
|
109
109
|
export const routerOsErrorRules: readonly RouterOsErrorRule[] = [
|
|
110
|
+
{
|
|
111
|
+
code: "routeros/permission-denied",
|
|
112
|
+
description:
|
|
113
|
+
"RouterOS refused the command because the user's group lacks a policy it needs.",
|
|
114
|
+
// Grounded on CHR 7.25. REST answers HTTP 500 with this as `detail`:
|
|
115
|
+
// `not enough permissions (9)` for a write without `write`, and
|
|
116
|
+
// `std failure: not allowed (9)` for any request (even a GET) when the
|
|
117
|
+
// group has `rest-api` but not `api` (fixed in 7.26beta3). The native API traps with the
|
|
118
|
+
// first string; `/execute as-string` returns it as text with a
|
|
119
|
+
// `(<path>; line N)` suffix. In a multi-statement script the refusal is
|
|
120
|
+
// the last line, after earlier statements' stdout (#549: `:put
|
|
121
|
+
// "p5-first"; /system/identity/set …` as a read-only user returns HTTP
|
|
122
|
+
// 200 with ret `p5-first\r\nnot enough permissions (9)
|
|
123
|
+
// (/system/identity/set; line 1)`, identical on native-api), so the
|
|
124
|
+
// trailed form matches too — with the location suffix required, so a
|
|
125
|
+
// script that merely prints the bare phrase stays ordinary output.
|
|
126
|
+
test: /not enough permissions|not allowed \(9\)/i,
|
|
127
|
+
resultTest:
|
|
128
|
+
/^(?:not enough permissions \(9\)(?: \(\/[^\s();]+; line \d+\))?|[\s\S]*\r?\nnot enough permissions \(9\) \(\/[^\s();]+; line \d+\))$/i,
|
|
129
|
+
build: (_match, raw) => ({
|
|
130
|
+
summary: `RouterOS refused the command for lack of permission: ${raw.trim()}`,
|
|
131
|
+
remediation:
|
|
132
|
+
"Grant the missing policy to the RouterOS user's group (`/user/group`): `read` for reads, `write` for changes. If every REST request fails with `not allowed (9)`, the group has `rest-api` without `api`; before RouterOS 7.26beta3, REST needs both. Retrying will not help.",
|
|
133
|
+
}),
|
|
134
|
+
},
|
|
110
135
|
{
|
|
111
136
|
code: "routeros/unknown-path",
|
|
112
137
|
description:
|
package/src/execute-cmr.ts
CHANGED
|
@@ -1002,8 +1002,7 @@ function devicePassed(
|
|
|
1002
1002
|
source: CMR_VALIDATION_SOURCE,
|
|
1003
1003
|
result: "passed",
|
|
1004
1004
|
syntax: true,
|
|
1005
|
-
semantic:
|
|
1006
|
-
resolved.canonical.mode === "structured" ? true : "not-applicable",
|
|
1005
|
+
...(resolved.canonical.mode === "structured" ? { semantic: true } : {}),
|
|
1007
1006
|
stages: passedStages(
|
|
1008
1007
|
offline ?? { verdict: "pass", warnings: [] },
|
|
1009
1008
|
CMR_VALIDATION_SOURCE,
|
|
@@ -1023,12 +1022,7 @@ function deviceRejected(
|
|
|
1023
1022
|
enabled: true,
|
|
1024
1023
|
source: CMR_VALIDATION_SOURCE,
|
|
1025
1024
|
result: "failed",
|
|
1026
|
-
...(nameRejected
|
|
1027
|
-
? { syntax: true, semantic: false }
|
|
1028
|
-
: {
|
|
1029
|
-
syntax: false,
|
|
1030
|
-
...(structured ? {} : { semantic: "not-applicable" as const }),
|
|
1031
|
-
}),
|
|
1025
|
+
...(nameRejected ? { syntax: true, semantic: false } : { syntax: false }),
|
|
1032
1026
|
stages: [
|
|
1033
1027
|
offlineStagePassed(offline?.verdict),
|
|
1034
1028
|
{ stage: "device", source: CMR_VALIDATION_SOURCE, result: "failed" },
|