@tikoci/centrs 0.1.4 → 0.1.7
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 +417 -1
- package/GLOSSARY.txt +40 -1
- package/README.md +16 -8
- package/commands/AGENTS.md +7 -4
- package/commands/api/AGENTS.md +13 -1
- package/commands/api/README.md +50 -10
- package/commands/api/examples.md +100 -2
- package/commands/devices/README.md +10 -1
- package/commands/devices/examples.md +33 -2
- package/commands/execute/README.md +68 -25
- package/commands/execute/examples.md +168 -7
- package/commands/explain/README.md +1206 -155
- package/commands/explain/examples.md +99 -3
- package/commands/mcp/README.md +9 -5
- package/commands/mcp/examples.md +2 -2
- package/commands/retrieve/examples.md +20 -0
- package/commands/terminal/examples.md +7 -1
- package/docs/CLI.md +3 -3
- package/docs/CONSTITUTION.md +83 -21
- package/docs/MATRIX.md +89 -52
- package/docs/errors/cdb/create-failed.md +20 -0
- package/docs/errors/cdb/file-exists.md +23 -0
- package/docs/errors/input/invalid-target.md +25 -3
- package/docs/errors/routeros/invalid-value.md +3 -2
- package/docs/errors/transport/cancel-unacknowledged.md +24 -0
- package/docs/errors/validation/syntax.md +29 -4
- package/package.json +13 -2
- package/src/api-fanout.ts +17 -4
- package/src/api.ts +346 -75
- package/src/cli/api.ts +1 -1
- package/src/cli/btest.ts +1 -1
- package/src/cli/devices.ts +16 -3
- package/src/cli/explain.ts +1 -1
- package/src/cli/terminal.ts +1 -1
- package/src/core/envelope.ts +53 -0
- package/src/core/error-catalog.ts +16 -1
- package/src/core/execute-command.ts +455 -0
- package/src/core/inspect.ts +58 -26
- package/src/core/routeros-errors.ts +66 -0
- package/src/core/version.ts +4 -0
- package/src/core/yaml.ts +86 -0
- package/src/data/winbox-cdb-write.ts +22 -8
- package/src/devices.ts +142 -28
- package/src/execute-fanout.ts +19 -5
- package/src/execute.ts +247 -295
- package/src/explain/arg-tokens.ts +62 -25
- package/src/explain/args.ts +398 -52
- package/src/explain/blocks.ts +54 -11
- package/src/explain/brace-tokens.ts +4 -3
- package/src/explain/catalog.ts +42 -44
- package/src/explain/coordinates.ts +159 -0
- package/src/explain/defects.ts +2 -1
- package/src/explain/escape-tokens.ts +77 -0
- package/src/explain/highlight-projection.ts +308 -0
- package/src/explain/menus.ts +9 -5
- package/src/explain/operator-tokens.ts +13 -7
- package/src/explain/operators.ts +27 -20
- package/src/explain/path-tokens.ts +1 -1
- package/src/explain/pathresolve.ts +358 -113
- package/src/explain/quoted-string.ts +110 -20
- package/src/explain/scope-brace.ts +175 -22
- package/src/explain/segment.ts +429 -21
- package/src/explain/separator.ts +187 -0
- package/src/explain/split-owner.ts +194 -0
- package/src/explain/string-tokens.ts +14 -4
- package/src/explain/symbol-values.ts +209 -173
- package/src/explain/symbols.ts +21 -1
- package/src/explain/transport.ts +17 -0
- package/src/explain/value-tokens.ts +15 -12
- package/src/explain/verbsplit.ts +165 -23
- package/src/explain/write.ts +47 -6
- package/src/explain.ts +522 -72
- package/src/index.ts +21 -2
- package/src/mcp/server.ts +2 -1
- package/src/mcp/tools.ts +1 -1
- package/src/offline-gate.ts +148 -0
- package/src/protocols/adapter.ts +12 -1
- package/src/protocols/mac-telnet-console.ts +6 -4
- package/src/protocols/native-api.ts +29 -3
- package/src/resolver/target.ts +32 -12
- package/src/retrieve-fanout.ts +1 -1
- package/src/retrieve.ts +18 -63
- package/src/settings.ts +2 -1
- package/src/transfer-fanout.ts +1 -1
- package/src/transfer.ts +1 -1
package/src/execute.ts
CHANGED
|
@@ -5,20 +5,28 @@ import type {
|
|
|
5
5
|
CommonSettingsMeta,
|
|
6
6
|
SettingSource as CoreSettingSource,
|
|
7
7
|
EnvelopeValidationMeta,
|
|
8
|
+
EnvelopeValidationStage,
|
|
8
9
|
Tip,
|
|
10
|
+
ValidationTrace,
|
|
9
11
|
Warning,
|
|
10
12
|
} from "./core/envelope.ts";
|
|
11
13
|
import { buildTip } from "./core/envelope.ts";
|
|
12
14
|
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
} from "./core/
|
|
19
|
-
import { mapRouterOsError } from "./core/routeros-errors.ts";
|
|
15
|
+
type CanonicalExecuteCommand,
|
|
16
|
+
canonicalizeExecuteCommand,
|
|
17
|
+
isWriteShaped,
|
|
18
|
+
} from "./core/execute-command.ts";
|
|
19
|
+
import { inspectArgumentNames, pathTokens } from "./core/inspect.ts";
|
|
20
|
+
import { mapRouterOsResultError } from "./core/routeros-errors.ts";
|
|
20
21
|
import { routerOsStringLiteral } from "./core/routeros-string.ts";
|
|
22
|
+
import { toYaml } from "./core/yaml.ts";
|
|
21
23
|
import { CentrsError, serializeCentrsError } from "./errors.ts";
|
|
24
|
+
import {
|
|
25
|
+
assertOfflineSyntax,
|
|
26
|
+
isOfflineGateRejection,
|
|
27
|
+
OFFLINE_GATE_SOURCE,
|
|
28
|
+
type OfflineGateResult,
|
|
29
|
+
} from "./offline-gate.ts";
|
|
22
30
|
import {
|
|
23
31
|
createProtocolAdapter,
|
|
24
32
|
getProtocolPlan,
|
|
@@ -54,7 +62,6 @@ import {
|
|
|
54
62
|
resolveTarget,
|
|
55
63
|
toCoreSource,
|
|
56
64
|
} from "./resolver/index.ts";
|
|
57
|
-
import { toYaml } from "./retrieve.ts";
|
|
58
65
|
|
|
59
66
|
export const executeOutputFormats = ["text", "json", "yaml"] as const;
|
|
60
67
|
export type ExecuteOutputFormat = (typeof executeOutputFormats)[number];
|
|
@@ -125,14 +132,17 @@ export type ExecuteSuccessEnvelope = CentrsSuccessEnvelope<
|
|
|
125
132
|
>;
|
|
126
133
|
export type ExecuteErrorEnvelope = CentrsErrorEnvelope<ExecuteOperationMeta>;
|
|
127
134
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
135
|
+
/**
|
|
136
|
+
* The execution gate itself lives in `src/core/execute-command.ts` (pure, no
|
|
137
|
+
* transport reach) and is re-exported here because this module is its
|
|
138
|
+
* documented public home — `src/index.ts`, `src/mcp/tools.ts` and the locked
|
|
139
|
+
* contract table all import it from `execute` (#312).
|
|
140
|
+
*/
|
|
141
|
+
export {
|
|
142
|
+
type CanonicalExecuteCommand,
|
|
143
|
+
canonicalizeExecuteCommand,
|
|
144
|
+
isWriteShaped,
|
|
145
|
+
};
|
|
136
146
|
|
|
137
147
|
export interface ResolvedExecuteRequest {
|
|
138
148
|
command: string;
|
|
@@ -172,13 +182,23 @@ export async function executeEnvelope(
|
|
|
172
182
|
env: Record<string, string | undefined> = Bun.env,
|
|
173
183
|
): Promise<ExecuteEnvelope> {
|
|
174
184
|
let resolved: ResolvedExecuteRequest | undefined;
|
|
185
|
+
// The gate finishes inside `runResolvedExecute`, but the error envelope is
|
|
186
|
+
// built out here after the throw has unwound past it. Without the trace, a
|
|
187
|
+
// RouterOS fault on the *run* — both stages already green — reported a
|
|
188
|
+
// reconstructed `result: "failed"` that erased the real outcome.
|
|
189
|
+
const trace: ValidationTrace = {};
|
|
175
190
|
try {
|
|
176
191
|
resolved = await resolveExecuteRequest(request, env);
|
|
177
192
|
await assertWriteConfirmed(request, resolved);
|
|
178
|
-
return await runResolvedExecute(resolved);
|
|
193
|
+
return await runResolvedExecute(resolved, trace);
|
|
179
194
|
} catch (error) {
|
|
180
195
|
return resolved
|
|
181
|
-
? buildExecuteErrorEnvelopeFromResolved(
|
|
196
|
+
? buildExecuteErrorEnvelopeFromResolved(
|
|
197
|
+
resolved,
|
|
198
|
+
error,
|
|
199
|
+
trace.validation,
|
|
200
|
+
trace,
|
|
201
|
+
)
|
|
182
202
|
: buildExecuteErrorEnvelope(request, error, env);
|
|
183
203
|
}
|
|
184
204
|
}
|
|
@@ -196,6 +216,7 @@ export async function validateExecuteEnvelope(
|
|
|
196
216
|
env: Record<string, string | undefined> = Bun.env,
|
|
197
217
|
): Promise<ExecuteEnvelope> {
|
|
198
218
|
let resolved: ResolvedExecuteRequest | undefined;
|
|
219
|
+
const trace: ValidationTrace = {};
|
|
199
220
|
try {
|
|
200
221
|
resolved = await resolveExecuteRequest({ ...request, validate: true }, env);
|
|
201
222
|
const backend = createProtocolAdapter({
|
|
@@ -212,7 +233,7 @@ export async function validateExecuteEnvelope(
|
|
|
212
233
|
insecure: resolved.insecure.value,
|
|
213
234
|
});
|
|
214
235
|
try {
|
|
215
|
-
const validation = await validateExecuteCommand(resolved, backend);
|
|
236
|
+
const validation = await validateExecuteCommand(resolved, backend, trace);
|
|
216
237
|
return {
|
|
217
238
|
ok: true,
|
|
218
239
|
data: null,
|
|
@@ -225,13 +246,14 @@ export async function validateExecuteEnvelope(
|
|
|
225
246
|
}
|
|
226
247
|
} catch (error) {
|
|
227
248
|
return resolved
|
|
228
|
-
? buildExecuteErrorEnvelopeFromResolved(resolved, error)
|
|
249
|
+
? buildExecuteErrorEnvelopeFromResolved(resolved, error, undefined, trace)
|
|
229
250
|
: buildExecuteErrorEnvelope(request, error, env);
|
|
230
251
|
}
|
|
231
252
|
}
|
|
232
253
|
|
|
233
254
|
export async function runResolvedExecute(
|
|
234
255
|
resolved: ResolvedExecuteRequest,
|
|
256
|
+
trace: ValidationTrace = {},
|
|
235
257
|
): Promise<ExecuteSuccessEnvelope> {
|
|
236
258
|
const backend = createProtocolAdapter({
|
|
237
259
|
protocol: resolved.via.value,
|
|
@@ -253,16 +275,13 @@ export async function runResolvedExecute(
|
|
|
253
275
|
|
|
254
276
|
try {
|
|
255
277
|
if (resolved.validate.value) {
|
|
256
|
-
validation = await validateExecuteCommand(resolved, backend);
|
|
278
|
+
validation = await validateExecuteCommand(resolved, backend, trace);
|
|
257
279
|
} else {
|
|
258
|
-
validation =
|
|
259
|
-
enabled: false,
|
|
260
|
-
source: "disabled",
|
|
261
|
-
result: "skipped",
|
|
262
|
-
syntax: false,
|
|
263
|
-
semantic: false,
|
|
264
|
-
};
|
|
280
|
+
validation = disabledValidationMeta();
|
|
265
281
|
}
|
|
282
|
+
// Recorded BEFORE the run, so a RouterOS fault or transport drop below
|
|
283
|
+
// reports the gate that actually passed rather than a guess.
|
|
284
|
+
trace.validation = validation;
|
|
266
285
|
|
|
267
286
|
const result = await runCommand(resolved, backend);
|
|
268
287
|
const routerOsFailure = routerOsFailureFromResult(
|
|
@@ -560,6 +579,7 @@ export function buildExecuteErrorEnvelopeFromResolved(
|
|
|
560
579
|
resolved: ResolvedExecuteRequest,
|
|
561
580
|
error: unknown,
|
|
562
581
|
validation?: EnvelopeValidationMeta,
|
|
582
|
+
trace?: ValidationTrace,
|
|
563
583
|
): ExecuteErrorEnvelope {
|
|
564
584
|
const centrsError =
|
|
565
585
|
error instanceof CentrsError
|
|
@@ -578,23 +598,133 @@ export function buildExecuteErrorEnvelopeFromResolved(
|
|
|
578
598
|
tips: resolvedExecuteTips(resolved),
|
|
579
599
|
meta: metaFromResolved(
|
|
580
600
|
resolved,
|
|
581
|
-
validation ??
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
? ":put [:parse] + /console/inspect"
|
|
585
|
-
: "disabled",
|
|
586
|
-
result: resolved.validate.value ? "failed" : "skipped",
|
|
587
|
-
syntax: resolved.validate.value,
|
|
588
|
-
semantic: resolved.validate.value
|
|
589
|
-
? resolved.canonical.mode === "structured"
|
|
590
|
-
? false
|
|
591
|
-
: "not-applicable"
|
|
592
|
-
: false,
|
|
593
|
-
},
|
|
601
|
+
validation ??
|
|
602
|
+
trace?.validation ??
|
|
603
|
+
failedValidationMeta(resolved, error, trace),
|
|
594
604
|
),
|
|
595
605
|
};
|
|
596
606
|
}
|
|
597
607
|
|
|
608
|
+
/**
|
|
609
|
+
* The `meta.validation` for an error envelope built without a validation result
|
|
610
|
+
* of its own — i.e. the gate threw, or something downstream did.
|
|
611
|
+
*
|
|
612
|
+
* A stage-1 rejection is the case worth spelling out: the offline analyzer never
|
|
613
|
+
* opens a connection, so reporting the old flat
|
|
614
|
+
* `source: ":put [:parse] + /console/inspect"` / `result: "failed"` would claim
|
|
615
|
+
* a device probe that did not happen. `stages` says which one actually spoke.
|
|
616
|
+
*/
|
|
617
|
+
function failedValidationMeta(
|
|
618
|
+
resolved: ResolvedExecuteRequest,
|
|
619
|
+
error: unknown,
|
|
620
|
+
trace?: ValidationTrace,
|
|
621
|
+
): EnvelopeValidationMeta {
|
|
622
|
+
if (!resolved.validate.value) {
|
|
623
|
+
return disabledValidationMeta();
|
|
624
|
+
}
|
|
625
|
+
const deviceSource = deviceStageSource(resolved);
|
|
626
|
+
// Script-shaped input has no `/console/inspect` half to fail, on either
|
|
627
|
+
// branch below. Reporting `semantic: false` for it would say a semantic gate
|
|
628
|
+
// ran and said no, which is a different claim from "there was none".
|
|
629
|
+
const semantic: boolean | "not-applicable" =
|
|
630
|
+
resolved.canonical.mode === "structured" ? false : "not-applicable";
|
|
631
|
+
if (isOfflineGateRejection(error)) {
|
|
632
|
+
return {
|
|
633
|
+
enabled: true,
|
|
634
|
+
source: OFFLINE_GATE_SOURCE,
|
|
635
|
+
result: "failed",
|
|
636
|
+
syntax: false,
|
|
637
|
+
semantic,
|
|
638
|
+
stages: [
|
|
639
|
+
{ stage: "offline", source: OFFLINE_GATE_SOURCE, result: "failed" },
|
|
640
|
+
{
|
|
641
|
+
stage: "device",
|
|
642
|
+
source: deviceSource,
|
|
643
|
+
result: "skipped",
|
|
644
|
+
reason: "offline analysis rejected the command; no connection opened",
|
|
645
|
+
},
|
|
646
|
+
],
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
// Only a `validation/*` error tells us the DEVICE stage rejected it — and
|
|
650
|
+
// only then do we know stage 1 passed, because stage 1 runs first and would
|
|
651
|
+
// have thrown its own (offline-tagged) error otherwise. This builder also
|
|
652
|
+
// catches everything downstream — a RouterOS fault on the run, a transport
|
|
653
|
+
// drop — which passed both stages and is served by `trace.validation`. When
|
|
654
|
+
// neither applies, `stages` is omitted rather than asserting a verdict this
|
|
655
|
+
// function cannot know.
|
|
656
|
+
const deviceRejected =
|
|
657
|
+
error instanceof CentrsError && error.code.startsWith("validation/");
|
|
658
|
+
return {
|
|
659
|
+
enabled: true,
|
|
660
|
+
source: deviceSource,
|
|
661
|
+
result: "failed",
|
|
662
|
+
syntax: true,
|
|
663
|
+
semantic,
|
|
664
|
+
...(deviceRejected
|
|
665
|
+
? {
|
|
666
|
+
stages: [
|
|
667
|
+
offlineStagePassed(trace?.offlineVerdict),
|
|
668
|
+
{
|
|
669
|
+
stage: "device" as const,
|
|
670
|
+
source: deviceSource,
|
|
671
|
+
result: "failed" as const,
|
|
672
|
+
},
|
|
673
|
+
],
|
|
674
|
+
}
|
|
675
|
+
: {}),
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** The device-stage validator identity for a resolved request's transport. */
|
|
680
|
+
function deviceStageSource(resolved: ResolvedExecuteRequest): string {
|
|
681
|
+
return resolved.via.value === "mac-telnet" || resolved.via.value === "ssh"
|
|
682
|
+
? `:put [:parse ...] over ${resolved.via.value}`
|
|
683
|
+
: ":put [:parse] + /console/inspect";
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* The offline stage entry for a gate stage 1 cleared.
|
|
688
|
+
*
|
|
689
|
+
* `verdict` comes from the {@link ValidationTrace}, not from a re-run: a `warn`
|
|
690
|
+
* is an ABSTENTION, and an error envelope that flattened it to a bare `passed`
|
|
691
|
+
* would tell a consumer the analyzer judged bytes it explicitly declined to
|
|
692
|
+
* judge. An absent verdict means the caller did not thread a trace, so say
|
|
693
|
+
* nothing extra rather than invent the reason.
|
|
694
|
+
*/
|
|
695
|
+
function offlineStagePassed(
|
|
696
|
+
verdict: ValidationTrace["offlineVerdict"],
|
|
697
|
+
): EnvelopeValidationStage {
|
|
698
|
+
return {
|
|
699
|
+
stage: "offline",
|
|
700
|
+
source: OFFLINE_GATE_SOURCE,
|
|
701
|
+
result: "passed",
|
|
702
|
+
...(verdict === "warn"
|
|
703
|
+
? { reason: "analyzer abstained on part of the input" }
|
|
704
|
+
: {}),
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* `validate=false`. Both stages are listed as `skipped` rather than omitted:
|
|
710
|
+
* the constitution's rule is that a gated surface always reports each stage, so
|
|
711
|
+
* a consumer reading `stages` never has to special-case the disabled shape.
|
|
712
|
+
* `reason` is left off — `enabled: false` already says why.
|
|
713
|
+
*/
|
|
714
|
+
function disabledValidationMeta(): EnvelopeValidationMeta {
|
|
715
|
+
return {
|
|
716
|
+
enabled: false,
|
|
717
|
+
source: "disabled",
|
|
718
|
+
result: "skipped",
|
|
719
|
+
syntax: false,
|
|
720
|
+
semantic: false,
|
|
721
|
+
stages: [
|
|
722
|
+
{ stage: "offline", source: OFFLINE_GATE_SOURCE, result: "skipped" },
|
|
723
|
+
{ stage: "device", source: "device preflight", result: "skipped" },
|
|
724
|
+
],
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
|
|
598
728
|
export function renderExecuteEnvelope(
|
|
599
729
|
envelope: ExecuteEnvelope,
|
|
600
730
|
format: ExecuteOutputFormat,
|
|
@@ -614,86 +744,6 @@ export function renderExecuteEnvelope(
|
|
|
614
744
|
}
|
|
615
745
|
}
|
|
616
746
|
|
|
617
|
-
/**
|
|
618
|
-
* The **script-vs-structured execution gate** — centrs's load-bearing
|
|
619
|
-
* discriminator (with {@link isWriteShaped}) for which validation runs and
|
|
620
|
-
* whether the write-confirmation prompt fires. centrs owns this gate; the shared,
|
|
621
|
-
* prose-tolerant, multi-command canonicalizer that `rosetta` / `lsp-routeros-ts`
|
|
622
|
-
* publish is for *canonicalization*, never the structured-mode predicate —
|
|
623
|
-
* widening what counts as `structured` is a product regression. Behavior is
|
|
624
|
-
* pinned by `test/unit/execute-canonicalize-contract.test.ts`.
|
|
625
|
-
*
|
|
626
|
-
* centrs deliberately does **not** vendor the shared parser yet. Preconditions
|
|
627
|
-
* for adopting it: (1) that contract stays green (no gate widening); (2) the
|
|
628
|
-
* vendored file is clean under centrs's strict `tsconfig` (or explicitly
|
|
629
|
-
* quarantined with justification); (3) `lsp-routeros-ts` also vendors/consumes
|
|
630
|
-
* the same parser shape, so it is genuinely shared, not a premature first copy.
|
|
631
|
-
*/
|
|
632
|
-
export function canonicalizeExecuteCommand(
|
|
633
|
-
input: string,
|
|
634
|
-
): CanonicalExecuteCommand {
|
|
635
|
-
const asScript = (): CanonicalExecuteCommand => ({
|
|
636
|
-
mode: "script",
|
|
637
|
-
input,
|
|
638
|
-
path: "",
|
|
639
|
-
verb: "",
|
|
640
|
-
attributes: {},
|
|
641
|
-
queries: [],
|
|
642
|
-
});
|
|
643
|
-
|
|
644
|
-
const trimmed = input.trim();
|
|
645
|
-
if (!trimmed.startsWith("/")) {
|
|
646
|
-
return asScript();
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
const tokens = tokenizeRouterOsCli(trimmed);
|
|
650
|
-
const pathToken = tokens[0] ?? "";
|
|
651
|
-
const pathParts = pathToken.split("/").filter(Boolean);
|
|
652
|
-
if (pathParts.length < 2) {
|
|
653
|
-
return asScript();
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
const verb = pathParts.at(-1) ?? "";
|
|
657
|
-
const path = `/${pathParts.slice(0, -1).join("/")}`;
|
|
658
|
-
const attributes: Record<string, string> = {};
|
|
659
|
-
const queries: string[] = [];
|
|
660
|
-
for (const token of tokens.slice(1)) {
|
|
661
|
-
// `[...]` subshell selectors (e.g. `numbers=[find ...]`) cannot be
|
|
662
|
-
// represented as a structured attribute map: the inner command contains
|
|
663
|
-
// spaces this tokenizer splits on, which would mangle a write-shaped
|
|
664
|
-
// command into corrupt key=value pairs. Fall back to the raw-script path
|
|
665
|
-
// so RouterOS evaluates the subshell with its real semantics.
|
|
666
|
-
if (token.includes("[") || token.includes("]")) {
|
|
667
|
-
return asScript();
|
|
668
|
-
}
|
|
669
|
-
if (token.startsWith("?")) {
|
|
670
|
-
queries.push(token);
|
|
671
|
-
continue;
|
|
672
|
-
}
|
|
673
|
-
const separator = token.indexOf("=");
|
|
674
|
-
if (separator <= 0) {
|
|
675
|
-
return asScript();
|
|
676
|
-
}
|
|
677
|
-
attributes[token.slice(0, separator)] = token.slice(separator + 1);
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
return {
|
|
681
|
-
mode: "structured",
|
|
682
|
-
input,
|
|
683
|
-
path,
|
|
684
|
-
verb,
|
|
685
|
-
attributes,
|
|
686
|
-
queries,
|
|
687
|
-
};
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
export function isWriteShaped(command: CanonicalExecuteCommand): boolean {
|
|
691
|
-
return (
|
|
692
|
-
command.mode === "structured" &&
|
|
693
|
-
["add", "set", "remove"].includes(command.verb)
|
|
694
|
-
);
|
|
695
|
-
}
|
|
696
|
-
|
|
697
747
|
export function validateExecuteRequestShape(request: ExecuteRequest): void {
|
|
698
748
|
if (!request.command || request.command.trim().length === 0) {
|
|
699
749
|
throw new CentrsError({
|
|
@@ -705,12 +755,31 @@ export function validateExecuteRequestShape(request: ExecuteRequest): void {
|
|
|
705
755
|
}
|
|
706
756
|
}
|
|
707
757
|
|
|
758
|
+
/**
|
|
759
|
+
* The two-stage gate (GH#354): offline analysis first, then the device probe.
|
|
760
|
+
*
|
|
761
|
+
* Stage 1 runs before anything is sent, on every transport, so a syntax fault is
|
|
762
|
+
* a byte span and no round trip. It is not a substitute for stage 2 — `explain`
|
|
763
|
+
* has no per-menu schema, so it cannot see an unknown attribute — and it never
|
|
764
|
+
* reports success on the device's behalf. Stage 2 is unchanged: `:parse` (which
|
|
765
|
+
* on a console transport also covers the name-level attribute gate) plus
|
|
766
|
+
* `/console/inspect request=child` for structured input over REST/native.
|
|
767
|
+
*/
|
|
708
768
|
async function validateExecuteCommand(
|
|
709
769
|
resolved: ResolvedExecuteRequest,
|
|
710
770
|
backend: ProtocolAdapter,
|
|
771
|
+
trace: ValidationTrace = {},
|
|
711
772
|
): Promise<EnvelopeValidationMeta> {
|
|
773
|
+
const offline = assertOfflineSyntax(resolved.command, {
|
|
774
|
+
surface: "execute",
|
|
775
|
+
via: resolved.via.value,
|
|
776
|
+
});
|
|
777
|
+
// Recorded before stage 2 can throw, so a device rejection still reports
|
|
778
|
+
// whether stage 1 read the whole input or abstained on part of it.
|
|
779
|
+
trace.offlineVerdict = offline.verdict;
|
|
780
|
+
|
|
712
781
|
if (resolved.via.value === "mac-telnet" || resolved.via.value === "ssh") {
|
|
713
|
-
return validateConsoleParseCommand(resolved, backend);
|
|
782
|
+
return validateConsoleParseCommand(resolved, backend, offline);
|
|
714
783
|
}
|
|
715
784
|
|
|
716
785
|
await runSyntaxGate(resolved, backend);
|
|
@@ -722,10 +791,14 @@ async function validateExecuteCommand(
|
|
|
722
791
|
result: "passed",
|
|
723
792
|
syntax: true,
|
|
724
793
|
semantic: "not-applicable",
|
|
794
|
+
stages: passedStages(offline, ":put [:parse]"),
|
|
725
795
|
};
|
|
726
796
|
}
|
|
727
797
|
|
|
728
|
-
const availableAttributes = await
|
|
798
|
+
const availableAttributes = await inspectArgumentNames(backend, [
|
|
799
|
+
...pathTokens(resolved.canonical.path),
|
|
800
|
+
resolved.canonical.verb,
|
|
801
|
+
]);
|
|
729
802
|
const requested = Object.keys(resolved.canonical.attributes);
|
|
730
803
|
const missing = requested.filter(
|
|
731
804
|
(attribute) => !availableAttributes.includes(attribute),
|
|
@@ -742,7 +815,7 @@ async function validateExecuteCommand(
|
|
|
742
815
|
parameter: missing[0],
|
|
743
816
|
requestedAttributes: requested,
|
|
744
817
|
availableAttributes,
|
|
745
|
-
validationSource: "/console/inspect request=child
|
|
818
|
+
validationSource: "/console/inspect request=child",
|
|
746
819
|
},
|
|
747
820
|
});
|
|
748
821
|
}
|
|
@@ -754,6 +827,7 @@ async function validateExecuteCommand(
|
|
|
754
827
|
syntax: true,
|
|
755
828
|
semantic: true,
|
|
756
829
|
availableAttributes,
|
|
830
|
+
stages: passedStages(offline, ":put [:parse] + /console/inspect"),
|
|
757
831
|
};
|
|
758
832
|
}
|
|
759
833
|
|
|
@@ -768,22 +842,8 @@ async function validateExecuteCommand(
|
|
|
768
842
|
async function validateConsoleParseCommand(
|
|
769
843
|
resolved: ResolvedExecuteRequest,
|
|
770
844
|
backend: ProtocolAdapter,
|
|
845
|
+
offline: OfflineGateResult,
|
|
771
846
|
): Promise<EnvelopeValidationMeta> {
|
|
772
|
-
if (hasUnbalancedQuotes(resolved.command)) {
|
|
773
|
-
throw new CentrsError({
|
|
774
|
-
code: "validation/syntax",
|
|
775
|
-
summary:
|
|
776
|
-
"RouterOS rejected the command syntax during local quote preflight.",
|
|
777
|
-
remediation:
|
|
778
|
-
"Close the RouterOS string quote, then retry. The command was not executed.",
|
|
779
|
-
context: {
|
|
780
|
-
command: resolved.command,
|
|
781
|
-
validationSource: "local quote preflight before :put [:parse ...]",
|
|
782
|
-
via: resolved.via.value,
|
|
783
|
-
},
|
|
784
|
-
causeData: "unterminated string literal",
|
|
785
|
-
});
|
|
786
|
-
}
|
|
787
847
|
const result = await backend.execute({
|
|
788
848
|
path: "",
|
|
789
849
|
command: "",
|
|
@@ -798,29 +858,50 @@ async function validateConsoleParseCommand(
|
|
|
798
858
|
syntax: true,
|
|
799
859
|
semantic:
|
|
800
860
|
resolved.canonical.mode === "structured" ? true : "not-applicable",
|
|
861
|
+
stages: passedStages(
|
|
862
|
+
offline,
|
|
863
|
+
`:put [:parse ...] over ${resolved.via.value}`,
|
|
864
|
+
),
|
|
801
865
|
};
|
|
802
866
|
}
|
|
803
867
|
|
|
868
|
+
/**
|
|
869
|
+
* The stage breakdown for a gate both stages passed. `offline` is carried rather
|
|
870
|
+
* than re-derived so the meta reports the verdict the run actually saw — a
|
|
871
|
+
* `warn` is an abstention, and saying so is the difference between "the analyzer
|
|
872
|
+
* read all of this" and "it declined to judge part of it".
|
|
873
|
+
*/
|
|
874
|
+
function passedStages(
|
|
875
|
+
offline: OfflineGateResult,
|
|
876
|
+
deviceSource: string,
|
|
877
|
+
): EnvelopeValidationStage[] {
|
|
878
|
+
return [
|
|
879
|
+
{
|
|
880
|
+
stage: "offline",
|
|
881
|
+
source: OFFLINE_GATE_SOURCE,
|
|
882
|
+
result: "passed",
|
|
883
|
+
...(offline.verdict === "warn"
|
|
884
|
+
? { reason: "analyzer abstained on part of the input" }
|
|
885
|
+
: {}),
|
|
886
|
+
},
|
|
887
|
+
{ stage: "device", source: deviceSource, result: "passed" },
|
|
888
|
+
];
|
|
889
|
+
}
|
|
890
|
+
|
|
804
891
|
async function runSyntaxGate(
|
|
805
892
|
resolved: ResolvedExecuteRequest,
|
|
806
893
|
backend: ProtocolAdapter,
|
|
807
894
|
): Promise<void> {
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
via: resolved.via.value,
|
|
819
|
-
},
|
|
820
|
-
causeData: "unterminated string literal",
|
|
821
|
-
});
|
|
822
|
-
}
|
|
823
|
-
const script = `:put [:parse ${routerOsStringLiteral(resolved.command)}]`;
|
|
895
|
+
await validateRouterOsScript(resolved.command, backend, resolved.via.value);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/** Shared live `:parse` stage for every surface that carries a CLI string. */
|
|
899
|
+
export async function validateRouterOsScript(
|
|
900
|
+
command: string,
|
|
901
|
+
backend: ProtocolAdapter,
|
|
902
|
+
via: RouterOsProtocol,
|
|
903
|
+
): Promise<void> {
|
|
904
|
+
const script = `:put [:parse ${routerOsStringLiteral(command)}]`;
|
|
824
905
|
try {
|
|
825
906
|
// GH#230: `:parse` never throws (CHR 7.23.3). The diagnostic rides the
|
|
826
907
|
// success value's text — over REST/native the `ret` field (HTTP 200 /
|
|
@@ -829,7 +910,7 @@ async function runSyntaxGate(
|
|
|
829
910
|
// Read the return and classify it: one `:parse` covers syntax and the
|
|
830
911
|
// unknown-attribute (name-level) gate.
|
|
831
912
|
const result = await backend.execute({ path: "", command: "", script });
|
|
832
|
-
classifyParseResult(result.ret ?? "",
|
|
913
|
+
classifyParseResult(result.ret ?? "", command, via);
|
|
833
914
|
} catch (error) {
|
|
834
915
|
// The preflight is a syntax gate, but `backend.execute` also surfaces
|
|
835
916
|
// connection and authentication failures (login happens lazily here).
|
|
@@ -853,9 +934,9 @@ async function runSyntaxGate(
|
|
|
853
934
|
remediation:
|
|
854
935
|
"Fix the RouterOS CLI syntax, especially quotes and bracketed expressions, then retry.",
|
|
855
936
|
context: {
|
|
856
|
-
command
|
|
937
|
+
command,
|
|
857
938
|
validationSource: ":put [:parse ...]",
|
|
858
|
-
via
|
|
939
|
+
via,
|
|
859
940
|
},
|
|
860
941
|
...(position ? { position } : {}),
|
|
861
942
|
cause: error,
|
|
@@ -873,27 +954,6 @@ function isPreflightTransportError(error: CentrsError): boolean {
|
|
|
873
954
|
return error.code.startsWith("transport/") || error.code.startsWith("auth/");
|
|
874
955
|
}
|
|
875
956
|
|
|
876
|
-
async function inspectExecuteAttributes(
|
|
877
|
-
resolved: ResolvedExecuteRequest,
|
|
878
|
-
backend: ProtocolAdapter,
|
|
879
|
-
): Promise<string[]> {
|
|
880
|
-
const commandTokens = [
|
|
881
|
-
...pathTokens(resolved.canonical.path),
|
|
882
|
-
resolved.canonical.verb,
|
|
883
|
-
];
|
|
884
|
-
const children = await inspectChildren(backend, commandTokens);
|
|
885
|
-
const childAttributes = children
|
|
886
|
-
.filter(isArgumentNode)
|
|
887
|
-
.map((child) => child.name)
|
|
888
|
-
.filter(
|
|
889
|
-
(name): name is string => typeof name === "string" && name.length > 0,
|
|
890
|
-
);
|
|
891
|
-
const completionRows = await inspectCompletions(backend, commandTokens);
|
|
892
|
-
return [
|
|
893
|
-
...new Set([...childAttributes, ...extractCompletionNames(completionRows)]),
|
|
894
|
-
].sort();
|
|
895
|
-
}
|
|
896
|
-
|
|
897
957
|
async function runCommand(
|
|
898
958
|
resolved: ResolvedExecuteRequest,
|
|
899
959
|
backend: ProtocolAdapter,
|
|
@@ -1247,52 +1307,14 @@ function routerOsFailureFromResult(
|
|
|
1247
1307
|
result: ProtocolExecuteResult,
|
|
1248
1308
|
via: RouterOsProtocol,
|
|
1249
1309
|
): CentrsError | undefined {
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
]),
|
|
1259
|
-
].filter((value): value is string => typeof value === "string");
|
|
1260
|
-
const failure = candidates.find((candidate) =>
|
|
1261
|
-
isRouterOsFailureString(candidate),
|
|
1262
|
-
);
|
|
1263
|
-
return failure
|
|
1264
|
-
? mapRouterOsError(failure, {
|
|
1265
|
-
transport:
|
|
1266
|
-
via === "rest-api" || via === "mac-telnet" || via === "ssh"
|
|
1267
|
-
? via
|
|
1268
|
-
: "native-api",
|
|
1269
|
-
context: { via },
|
|
1270
|
-
})
|
|
1271
|
-
: undefined;
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
|
-
function isRouterOsFailureString(value: string): boolean {
|
|
1275
|
-
const trimmed = value.trim();
|
|
1276
|
-
// REST/native fault strings (unchanged).
|
|
1277
|
-
if (
|
|
1278
|
-
/^(failure:|error:)|unknown parameter|invalid value|session closed|\(:error; line \d+\)/i.test(
|
|
1279
|
-
trimmed,
|
|
1280
|
-
)
|
|
1281
|
-
) {
|
|
1282
|
-
return true;
|
|
1283
|
-
}
|
|
1284
|
-
// Console (mac-telnet) error forms. `ret` here is ordinary stdout (every
|
|
1285
|
-
// mac-telnet command and REST `/execute`), so anchor to the grounded RouterOS
|
|
1286
|
-
// error *shapes* — the `(line N column M)` source location for parse rejections
|
|
1287
|
-
// — rather than bare phrases, so `:put "syntax error"` is not misread as a
|
|
1288
|
-
// failure.
|
|
1289
|
-
return (
|
|
1290
|
-
/(?:^|\n)\s*(?:bad parameter \S+|syntax error)\b[^\n]*\(line \d+ column \d+\)/i.test(
|
|
1291
|
-
trimmed,
|
|
1292
|
-
) ||
|
|
1293
|
-
/(?:^|\n)\s*bad command name\b/i.test(trimmed) ||
|
|
1294
|
-
/(?:^|\n)\s*expected end of command\b/i.test(trimmed)
|
|
1295
|
-
);
|
|
1310
|
+
if (typeof result.ret !== "string") return undefined;
|
|
1311
|
+
return mapRouterOsResultError(result.ret, {
|
|
1312
|
+
transport:
|
|
1313
|
+
via === "rest-api" || via === "mac-telnet" || via === "ssh"
|
|
1314
|
+
? via
|
|
1315
|
+
: "native-api",
|
|
1316
|
+
context: { via },
|
|
1317
|
+
});
|
|
1296
1318
|
}
|
|
1297
1319
|
|
|
1298
1320
|
function applyMaxResultsBudget(
|
|
@@ -1359,76 +1381,6 @@ function renderExecuteErrorText(
|
|
|
1359
1381
|
return lines.join("\n");
|
|
1360
1382
|
}
|
|
1361
1383
|
|
|
1362
|
-
function hasUnbalancedQuotes(input: string): boolean {
|
|
1363
|
-
let quote: '"' | "'" | undefined;
|
|
1364
|
-
let escaped = false;
|
|
1365
|
-
for (const char of input) {
|
|
1366
|
-
if (escaped) {
|
|
1367
|
-
escaped = false;
|
|
1368
|
-
continue;
|
|
1369
|
-
}
|
|
1370
|
-
if (char === "\\") {
|
|
1371
|
-
escaped = true;
|
|
1372
|
-
continue;
|
|
1373
|
-
}
|
|
1374
|
-
if (quote) {
|
|
1375
|
-
if (char === quote) {
|
|
1376
|
-
quote = undefined;
|
|
1377
|
-
}
|
|
1378
|
-
continue;
|
|
1379
|
-
}
|
|
1380
|
-
if (char === '"' || char === "'") {
|
|
1381
|
-
quote = char;
|
|
1382
|
-
}
|
|
1383
|
-
}
|
|
1384
|
-
return quote !== undefined;
|
|
1385
|
-
}
|
|
1386
|
-
|
|
1387
|
-
function tokenizeRouterOsCli(input: string): string[] {
|
|
1388
|
-
const tokens: string[] = [];
|
|
1389
|
-
let current = "";
|
|
1390
|
-
let quote: '"' | "'" | undefined;
|
|
1391
|
-
let escaped = false;
|
|
1392
|
-
for (const char of input) {
|
|
1393
|
-
if (escaped) {
|
|
1394
|
-
current += char;
|
|
1395
|
-
escaped = false;
|
|
1396
|
-
continue;
|
|
1397
|
-
}
|
|
1398
|
-
if (char === "\\") {
|
|
1399
|
-
escaped = true;
|
|
1400
|
-
continue;
|
|
1401
|
-
}
|
|
1402
|
-
if (quote) {
|
|
1403
|
-
if (char === quote) {
|
|
1404
|
-
quote = undefined;
|
|
1405
|
-
} else {
|
|
1406
|
-
current += char;
|
|
1407
|
-
}
|
|
1408
|
-
continue;
|
|
1409
|
-
}
|
|
1410
|
-
if (char === '"' || char === "'") {
|
|
1411
|
-
quote = char;
|
|
1412
|
-
continue;
|
|
1413
|
-
}
|
|
1414
|
-
if (/\s/.test(char)) {
|
|
1415
|
-
if (current.length > 0) {
|
|
1416
|
-
tokens.push(current);
|
|
1417
|
-
current = "";
|
|
1418
|
-
}
|
|
1419
|
-
continue;
|
|
1420
|
-
}
|
|
1421
|
-
current += char;
|
|
1422
|
-
}
|
|
1423
|
-
if (escaped) {
|
|
1424
|
-
current += "\\";
|
|
1425
|
-
}
|
|
1426
|
-
if (current.length > 0) {
|
|
1427
|
-
tokens.push(current);
|
|
1428
|
-
}
|
|
1429
|
-
return tokens;
|
|
1430
|
-
}
|
|
1431
|
-
|
|
1432
1384
|
function syntaxCause(error: unknown): unknown {
|
|
1433
1385
|
if (error instanceof CentrsError) {
|
|
1434
1386
|
return error.causeData ?? error.context ?? error.toJSON();
|