@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/api.ts
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The structured middle of the verb trichotomy (see `commands/api/README.md`):
|
|
5
5
|
* one command per operation (no code blocks), structured input **and** output,
|
|
6
|
-
* validated through `/console/inspect
|
|
6
|
+
* validated through `/console/inspect` (or live `:parse` for `/execute` scripts),
|
|
7
|
+
* runnable over REST or the native API. The
|
|
7
8
|
* orchestrator is transport-agnostic — it normalizes the endpoint, resolves the
|
|
8
|
-
* method→verb map, runs the
|
|
9
|
+
* method→verb map, runs the validation and write-confirmation gates, then
|
|
9
10
|
* hands a normalized {@link ProtocolApiRequest} to the adapter. REST vs native
|
|
10
11
|
* mechanics (id-in-URL vs `=.id=`, `.query` vs `?`-words) live in the adapter.
|
|
11
12
|
*
|
|
@@ -21,26 +22,36 @@ import type {
|
|
|
21
22
|
CommonSettingsMeta,
|
|
22
23
|
SettingSource as CoreSettingSource,
|
|
23
24
|
EnvelopeValidationMeta,
|
|
25
|
+
EnvelopeValidationStage,
|
|
24
26
|
Tip,
|
|
27
|
+
ValidationTrace,
|
|
25
28
|
Warning,
|
|
26
29
|
} from "./core/envelope.ts";
|
|
27
30
|
import { buildTip } from "./core/envelope.ts";
|
|
28
31
|
import {
|
|
29
|
-
|
|
30
|
-
inspectChildren,
|
|
32
|
+
inspectArgumentNames,
|
|
31
33
|
inspectChildrenOrEmpty,
|
|
32
|
-
inspectCompletions,
|
|
33
|
-
isArgumentNode,
|
|
34
34
|
isCommandNode,
|
|
35
35
|
pathTokens,
|
|
36
36
|
} from "./core/inspect.ts";
|
|
37
|
+
import { mapRouterOsResultError } from "./core/routeros-errors.ts";
|
|
38
|
+
import { toYaml } from "./core/yaml.ts";
|
|
37
39
|
import { CentrsError, serializeCentrsError } from "./errors.ts";
|
|
38
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
promptForWriteConfirmation,
|
|
42
|
+
validateRouterOsScript,
|
|
43
|
+
} from "./execute.ts";
|
|
44
|
+
import {
|
|
45
|
+
assertOfflineSyntax,
|
|
46
|
+
isOfflineGateRejection,
|
|
47
|
+
OFFLINE_GATE_SOURCE,
|
|
48
|
+
} from "./offline-gate.ts";
|
|
39
49
|
import {
|
|
40
50
|
type ApiVerb,
|
|
41
51
|
createProtocolAdapter,
|
|
42
52
|
type ProtocolAdapter,
|
|
43
53
|
type ProtocolApiRequest,
|
|
54
|
+
type ProtocolApiResult,
|
|
44
55
|
plannedProtocols,
|
|
45
56
|
type RouterOsProtocol,
|
|
46
57
|
} from "./protocols/index.ts";
|
|
@@ -64,7 +75,6 @@ import {
|
|
|
64
75
|
resolveTarget,
|
|
65
76
|
toCoreSource,
|
|
66
77
|
} from "./resolver/index.ts";
|
|
67
|
-
import { toYaml } from "./retrieve.ts";
|
|
68
78
|
|
|
69
79
|
export const apiOutputFormats = ["json", "yaml", "text"] as const;
|
|
70
80
|
export type ApiOutputFormat = (typeof apiOutputFormats)[number];
|
|
@@ -97,7 +107,11 @@ export interface ApiRequest {
|
|
|
97
107
|
rawQuery?: readonly string[];
|
|
98
108
|
/** `--proplist` / `--attribute` property projection. */
|
|
99
109
|
proplist?: readonly string[];
|
|
100
|
-
/**
|
|
110
|
+
/**
|
|
111
|
+
* `--raw`: bare RouterOS body passthrough. DEFAULTS `--validate` to false; an
|
|
112
|
+
* explicit `validate` still wins, and its failures render through the `--raw`
|
|
113
|
+
* error contract (#154).
|
|
114
|
+
*/
|
|
101
115
|
raw?: boolean;
|
|
102
116
|
/** `--listen`: native-api-only open-ended follow (later phase; rejected here). */
|
|
103
117
|
listen?: boolean;
|
|
@@ -124,6 +138,23 @@ export interface ApiRequest {
|
|
|
124
138
|
confirm?: (prompt: string) => Promise<boolean>;
|
|
125
139
|
}
|
|
126
140
|
|
|
141
|
+
/**
|
|
142
|
+
* The `--raw` / `--validate` precedence for a summary built BEFORE (or instead
|
|
143
|
+
* of) full resolution — a usage error, or a fan-out member that never reached
|
|
144
|
+
* `resolveApiRequest`.
|
|
145
|
+
*
|
|
146
|
+
* Ambient sources are deliberately out of reach here: this runs where the CDB
|
|
147
|
+
* and config tiers have not been loaded, so it reports the two layers it CAN
|
|
148
|
+
* see. That is why it is a shared helper rather than an inlined expression —
|
|
149
|
+
* both call sites used to spell `request.raw ? false : …`, which contradicted
|
|
150
|
+
* the resolved request by reporting `validate: false` for an explicit
|
|
151
|
+
* `--raw --validate=true` (#154).
|
|
152
|
+
*/
|
|
153
|
+
function summarizedValidate(request: ApiRequest): boolean {
|
|
154
|
+
if (request.validate !== undefined) return request.validate;
|
|
155
|
+
return !(request.raw ?? false);
|
|
156
|
+
}
|
|
157
|
+
|
|
127
158
|
export interface NormalizedApiEndpoint {
|
|
128
159
|
/** Canonical slash path with the leading `/rest` and any trailing id/`listen` removed. */
|
|
129
160
|
path: string;
|
|
@@ -247,21 +278,32 @@ export async function apiEnvelope(
|
|
|
247
278
|
env: Record<string, string | undefined> = Bun.env,
|
|
248
279
|
): Promise<ApiEnvelope> {
|
|
249
280
|
let resolved: ResolvedApiRequest | undefined;
|
|
281
|
+
// The gate finishes inside `runResolvedApi`, but the error envelope is built
|
|
282
|
+
// out here after the throw has unwound past it. Without the trace, a
|
|
283
|
+
// `transport/*` or `routeros/*` fault on the request — both stages already
|
|
284
|
+
// settled — reported `/console/inspect` as the validator that failed.
|
|
285
|
+
const trace: ValidationTrace = {};
|
|
250
286
|
try {
|
|
251
287
|
resolved = await resolveApiRequest(request, env);
|
|
252
288
|
await assertApiWriteConfirmed(request, resolved);
|
|
253
|
-
return await runResolvedApi(resolved);
|
|
289
|
+
return await runResolvedApi(resolved, trace);
|
|
254
290
|
} catch (error) {
|
|
255
291
|
return resolved
|
|
256
|
-
? buildApiErrorEnvelopeFromResolved(resolved, error)
|
|
292
|
+
? buildApiErrorEnvelopeFromResolved(resolved, error, trace)
|
|
257
293
|
: buildApiErrorEnvelope(request, error, env);
|
|
258
294
|
}
|
|
259
295
|
}
|
|
260
296
|
|
|
261
297
|
export async function runResolvedApi(
|
|
262
298
|
resolved: ResolvedApiRequest,
|
|
299
|
+
trace: ValidationTrace = {},
|
|
263
300
|
): Promise<ApiSuccessEnvelope> {
|
|
264
301
|
assertListenCapability(resolved);
|
|
302
|
+
// Validate the complete request shape before constructing a backend or
|
|
303
|
+
// running either validation stage. In particular, `/execute` must reject
|
|
304
|
+
// extra body fields deterministically instead of letting live `:parse`
|
|
305
|
+
// surface an unrelated transport or authentication failure first.
|
|
306
|
+
const protocolRequest = buildProtocolApiRequest(resolved);
|
|
265
307
|
const backend = createProtocolAdapter({
|
|
266
308
|
protocol: resolved.via.value,
|
|
267
309
|
host: resolved.target.host,
|
|
@@ -279,20 +321,21 @@ export async function runResolvedApi(
|
|
|
279
321
|
let tips: Tip[] = [];
|
|
280
322
|
try {
|
|
281
323
|
if (resolved.validate.value) {
|
|
282
|
-
const result = await validateApiRequest(resolved, backend);
|
|
324
|
+
const result = await validateApiRequest(resolved, backend, trace);
|
|
283
325
|
validation = result.validation;
|
|
284
326
|
tips = result.tips;
|
|
285
327
|
} else {
|
|
286
|
-
validation =
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
328
|
+
validation = disabledApiValidationMeta(resolved);
|
|
329
|
+
}
|
|
330
|
+
// Recorded BEFORE the request, so a transport drop or RouterOS fault below
|
|
331
|
+
// reports the gate that actually settled rather than a guess.
|
|
332
|
+
trace.validation = validation;
|
|
333
|
+
|
|
334
|
+
const result = await backend.apiRequest(protocolRequest);
|
|
335
|
+
const routerOsFailure = apiRouterOsFailureFromResult(resolved, result);
|
|
336
|
+
if (routerOsFailure) {
|
|
337
|
+
throw routerOsFailure;
|
|
293
338
|
}
|
|
294
|
-
|
|
295
|
-
const result = await backend.apiRequest(buildProtocolApiRequest(resolved));
|
|
296
339
|
return {
|
|
297
340
|
ok: true,
|
|
298
341
|
data: result.data,
|
|
@@ -305,6 +348,35 @@ export async function runResolvedApi(
|
|
|
305
348
|
}
|
|
306
349
|
}
|
|
307
350
|
|
|
351
|
+
/**
|
|
352
|
+
* A `/execute` run that RouterOS *transported* successfully but *rejected* at
|
|
353
|
+
* runtime.
|
|
354
|
+
*
|
|
355
|
+
* `as-string` makes the console's own text the reply body, so a rejected
|
|
356
|
+
* script comes back as an ordinary 200/`!done` carrying `no such item`. Both
|
|
357
|
+
* adapters surface that text as `data`, so without this the caller-addressed
|
|
358
|
+
* `/execute` would report a failure as `ok: true` — exactly the shape
|
|
359
|
+
* `execute` normalizes in `routerOsFailureFromResult`. Structured path
|
|
360
|
+
* requests are untouched: their faults already arrive as HTTP >=400 or a
|
|
361
|
+
* `!trap`, and their `data` is a record, not console text.
|
|
362
|
+
*/
|
|
363
|
+
function apiRouterOsFailureFromResult(
|
|
364
|
+
resolved: ResolvedApiRequest,
|
|
365
|
+
result: ProtocolApiResult,
|
|
366
|
+
): CentrsError | undefined {
|
|
367
|
+
if (!resolved.scriptMode || typeof result.data !== "string") {
|
|
368
|
+
return undefined;
|
|
369
|
+
}
|
|
370
|
+
const via = resolved.via.value;
|
|
371
|
+
return mapRouterOsResultError(result.data, {
|
|
372
|
+
transport:
|
|
373
|
+
via === "rest-api" || via === "mac-telnet" || via === "ssh"
|
|
374
|
+
? via
|
|
375
|
+
: "native-api",
|
|
376
|
+
context: { via },
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
308
380
|
/**
|
|
309
381
|
* The open-ended `--stream` follow path: an async generator yielding one
|
|
310
382
|
* {@link ApiEnvelope} per `/listen` change frame, then a terminating summary
|
|
@@ -371,14 +443,18 @@ async function* streamResolvedApi(
|
|
|
371
443
|
const startedAt = Date.now();
|
|
372
444
|
let frames = 0;
|
|
373
445
|
let stopReason: ApiStreamStopReason | undefined;
|
|
446
|
+
let cancelUnacknowledged = false;
|
|
374
447
|
let durationTimer: ReturnType<typeof setTimeout> | undefined;
|
|
448
|
+
// The first stop cause wins: the cancel grace window keeps the loop alive
|
|
449
|
+
// after a stop, so a later timer or frame must not relabel it.
|
|
375
450
|
const onExternalAbort = (): void => {
|
|
376
451
|
stopReason ??= "interrupted";
|
|
452
|
+
clearTimeout(durationTimer);
|
|
377
453
|
controller.abort();
|
|
378
454
|
};
|
|
379
455
|
if (resolved.durationMs !== undefined) {
|
|
380
456
|
durationTimer = setTimeout(() => {
|
|
381
|
-
stopReason
|
|
457
|
+
stopReason ??= "duration-elapsed";
|
|
382
458
|
controller.abort();
|
|
383
459
|
}, resolved.durationMs);
|
|
384
460
|
}
|
|
@@ -395,11 +471,14 @@ async function* streamResolvedApi(
|
|
|
395
471
|
for await (const record of backend.listen(protocolRequest, {
|
|
396
472
|
signal: controller.signal,
|
|
397
473
|
onListening,
|
|
474
|
+
onCancelUnacknowledged: () => {
|
|
475
|
+
cancelUnacknowledged = true;
|
|
476
|
+
},
|
|
398
477
|
})) {
|
|
399
478
|
frames += 1;
|
|
400
479
|
yield streamFrameEnvelope(resolved, validation, record, frames);
|
|
401
480
|
if (resolved.count !== undefined && frames >= resolved.count) {
|
|
402
|
-
stopReason
|
|
481
|
+
stopReason ??= "count-reached";
|
|
403
482
|
break;
|
|
404
483
|
}
|
|
405
484
|
}
|
|
@@ -409,6 +488,15 @@ async function* streamResolvedApi(
|
|
|
409
488
|
stopReason ?? "interrupted",
|
|
410
489
|
frames,
|
|
411
490
|
Date.now() - startedAt,
|
|
491
|
+
cancelUnacknowledged
|
|
492
|
+
? [
|
|
493
|
+
{
|
|
494
|
+
code: "transport/cancel-unacknowledged",
|
|
495
|
+
message: `RouterOS did not acknowledge /cancel within ${resolved.timeoutMs.value}ms; centrs closed the session locally.`,
|
|
496
|
+
context: { timeoutMs: resolved.timeoutMs.value },
|
|
497
|
+
},
|
|
498
|
+
]
|
|
499
|
+
: [],
|
|
412
500
|
);
|
|
413
501
|
} catch (error) {
|
|
414
502
|
// A mid-stream failure is itself a frame, tagged `stream.kind="frame"` so
|
|
@@ -468,6 +556,7 @@ function streamSummaryEnvelope(
|
|
|
468
556
|
stopReason: ApiStreamStopReason,
|
|
469
557
|
frames: number,
|
|
470
558
|
durationMs: number,
|
|
559
|
+
streamWarnings: readonly Warning[] = [],
|
|
471
560
|
): ApiSuccessEnvelope {
|
|
472
561
|
const summary: ApiStreamSummary = { stopReason, frames, durationMs };
|
|
473
562
|
const meta = metaFromResolved(resolved, validation, summary);
|
|
@@ -476,7 +565,7 @@ function streamSummaryEnvelope(
|
|
|
476
565
|
return {
|
|
477
566
|
ok: true,
|
|
478
567
|
data: summary,
|
|
479
|
-
warnings: [...resolved.warnings],
|
|
568
|
+
warnings: [...resolved.warnings, ...streamWarnings],
|
|
480
569
|
tips: [],
|
|
481
570
|
meta,
|
|
482
571
|
};
|
|
@@ -574,19 +663,40 @@ export async function resolveApiRequest(
|
|
|
574
663
|
)));
|
|
575
664
|
const via = resolveApiProtocol(request, env, listen, cdbResolution, config);
|
|
576
665
|
const format = resolveApiFormat(request, env, config);
|
|
577
|
-
// `--raw`
|
|
578
|
-
//
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
666
|
+
// `--raw` is a PRECEDENCE LAYER, not an override (#154). It ranks below an
|
|
667
|
+
// explicit `--validate` and above every ambient source, so:
|
|
668
|
+
//
|
|
669
|
+
// --validate=true|false explicit CLI flag ← wins
|
|
670
|
+
// --raw → validate=false
|
|
671
|
+
// CENTRS_VALIDATE / CDB comment-kv / config
|
|
672
|
+
// default → validate=true
|
|
673
|
+
//
|
|
674
|
+
// It used to short-circuit the whole ladder, which silently discarded an
|
|
675
|
+
// explicit `--validate=true` — and env, CDB and config with it. `--raw
|
|
676
|
+
// --validate=true` is the intended way to debug `api` when centrs itself is
|
|
677
|
+
// suspect: the gate runs and a failure renders through `rawErrorPayload` as
|
|
678
|
+
// `{code,message}` on stderr with a nonzero exit, which is the `--raw` error
|
|
679
|
+
// contract for any other preflight failure. `--raw` still outranks the
|
|
680
|
+
// ambient sources rather than merely lowering the default, so it behaves the
|
|
681
|
+
// same on every machine regardless of a `CENTRS_VALIDATE` in the environment.
|
|
682
|
+
//
|
|
683
|
+
// The `--raw` arm must SHORT-CIRCUIT, not merely win a comparison: resolving
|
|
684
|
+
// the ambient layer first would parse `CENTRS_VALIDATE` even when `--raw`
|
|
685
|
+
// decides the value, so a malformed ambient value threw `settings/invalid-
|
|
686
|
+
// boolean` out of a call `--raw` had already settled — the same
|
|
687
|
+
// machine-dependence this layer exists to remove.
|
|
688
|
+
const validate: ResolvedSetting<boolean> =
|
|
689
|
+
raw && request.validate === undefined
|
|
690
|
+
? { value: false, source: { kind: "cli", key: "--raw" } }
|
|
691
|
+
: resolveBooleanSetting(
|
|
692
|
+
request.validate,
|
|
693
|
+
env,
|
|
694
|
+
"CENTRS_VALIDATE",
|
|
695
|
+
true,
|
|
696
|
+
"validate",
|
|
697
|
+
cdbResolution?.overrides.validate,
|
|
698
|
+
config,
|
|
699
|
+
);
|
|
590
700
|
const timeoutMs = resolveApiTimeout(
|
|
591
701
|
request.timeout,
|
|
592
702
|
env,
|
|
@@ -906,6 +1016,10 @@ export function buildProtocolApiRequest(
|
|
|
906
1016
|
return request;
|
|
907
1017
|
}
|
|
908
1018
|
|
|
1019
|
+
/** The api device-stage validator for structured path input. */
|
|
1020
|
+
const API_DEVICE_STAGE_SOURCE = "/console/inspect request=child";
|
|
1021
|
+
const API_SCRIPT_DEVICE_STAGE_SOURCE = ":put [:parse]";
|
|
1022
|
+
|
|
909
1023
|
interface ApiValidationResult {
|
|
910
1024
|
validation: EnvelopeValidationMeta;
|
|
911
1025
|
tips: Tip[];
|
|
@@ -913,22 +1027,63 @@ interface ApiValidationResult {
|
|
|
913
1027
|
|
|
914
1028
|
/**
|
|
915
1029
|
* The structured-input gate. Because the input is a path (not a CLI string) the
|
|
916
|
-
* validator is `/console/inspect`, never `:put [:parse]`. Path existence
|
|
917
|
-
*
|
|
1030
|
+
* validator is `/console/inspect`, never `:put [:parse]`. Path existence and
|
|
1031
|
+
* add/set attribute validity both come from `request=child` — command-level
|
|
1032
|
+
* `request=completion` is unsafe on RouterOS 7.12.2 (`core/inspect.ts`). A
|
|
918
1033
|
* `/execute` script is a CLI string → `semantic: not-applicable`.
|
|
919
1034
|
*/
|
|
920
1035
|
async function validateApiRequest(
|
|
921
1036
|
resolved: ResolvedApiRequest,
|
|
922
1037
|
backend: ProtocolAdapter,
|
|
1038
|
+
trace: ValidationTrace = {},
|
|
923
1039
|
): Promise<ApiValidationResult> {
|
|
924
1040
|
if (resolved.scriptMode) {
|
|
1041
|
+
// `/execute` carries a CLI string, so use the same offline -> live `:parse`
|
|
1042
|
+
// pipeline as `execute`. `/console/inspect` remains inapplicable because
|
|
1043
|
+
// there is no structured menu path/body to inspect.
|
|
1044
|
+
const script = scriptOf(resolved);
|
|
1045
|
+
const offline = script
|
|
1046
|
+
? assertOfflineSyntax(script, {
|
|
1047
|
+
surface: "api /execute",
|
|
1048
|
+
via: resolved.via.value,
|
|
1049
|
+
})
|
|
1050
|
+
: undefined;
|
|
1051
|
+
trace.offlineVerdict = offline?.verdict;
|
|
1052
|
+
if (script !== undefined) {
|
|
1053
|
+
await validateRouterOsScript(script, backend, resolved.via.value);
|
|
1054
|
+
}
|
|
1055
|
+
const offlineReason =
|
|
1056
|
+
offline === undefined
|
|
1057
|
+
? "no `script` field to analyze"
|
|
1058
|
+
: offline.verdict === "warn"
|
|
1059
|
+
? "analyzer abstained on part of the input"
|
|
1060
|
+
: undefined;
|
|
925
1061
|
return {
|
|
926
1062
|
validation: {
|
|
927
1063
|
enabled: true,
|
|
928
|
-
source:
|
|
1064
|
+
source:
|
|
1065
|
+
offline === undefined
|
|
1066
|
+
? "not-applicable"
|
|
1067
|
+
: API_SCRIPT_DEVICE_STAGE_SOURCE,
|
|
929
1068
|
result: "passed",
|
|
930
|
-
syntax:
|
|
1069
|
+
syntax: script !== undefined,
|
|
931
1070
|
semantic: "not-applicable",
|
|
1071
|
+
stages: [
|
|
1072
|
+
{
|
|
1073
|
+
stage: "offline",
|
|
1074
|
+
source: OFFLINE_GATE_SOURCE,
|
|
1075
|
+
result: offline === undefined ? "skipped" : "passed",
|
|
1076
|
+
...(offlineReason ? { reason: offlineReason } : {}),
|
|
1077
|
+
},
|
|
1078
|
+
{
|
|
1079
|
+
stage: "device",
|
|
1080
|
+
source: API_SCRIPT_DEVICE_STAGE_SOURCE,
|
|
1081
|
+
result: script === undefined ? "skipped" : "passed",
|
|
1082
|
+
...(script === undefined
|
|
1083
|
+
? { reason: "no `script` field to parse" }
|
|
1084
|
+
: {}),
|
|
1085
|
+
},
|
|
1086
|
+
],
|
|
932
1087
|
},
|
|
933
1088
|
tips: [],
|
|
934
1089
|
};
|
|
@@ -970,7 +1125,7 @@ async function validateApiRequest(
|
|
|
970
1125
|
// Only reject when inspect actually surfaced the command's arguments; if it
|
|
971
1126
|
// returns none, skip rather than false-reject a valid command.
|
|
972
1127
|
if (isCommand && Object.keys(resolved.body).length > 0) {
|
|
973
|
-
const available = await
|
|
1128
|
+
const available = await inspectArgumentNames(backend, tokens);
|
|
974
1129
|
if (available.length > 0) {
|
|
975
1130
|
const requested = Object.keys(resolved.body);
|
|
976
1131
|
const missing = requested.filter(
|
|
@@ -988,7 +1143,7 @@ async function validateApiRequest(
|
|
|
988
1143
|
parameter: missing[0],
|
|
989
1144
|
requestedAttributes: requested,
|
|
990
1145
|
availableAttributes: available,
|
|
991
|
-
validationSource: "/console/inspect request=child
|
|
1146
|
+
validationSource: "/console/inspect request=child",
|
|
992
1147
|
},
|
|
993
1148
|
});
|
|
994
1149
|
}
|
|
@@ -1013,7 +1168,7 @@ async function validateApiRequest(
|
|
|
1013
1168
|
}
|
|
1014
1169
|
|
|
1015
1170
|
if (resolved.verb === "add" || resolved.verb === "set") {
|
|
1016
|
-
const available = await
|
|
1171
|
+
const available = await inspectArgumentNames(backend, [
|
|
1017
1172
|
...tokens,
|
|
1018
1173
|
resolved.verb,
|
|
1019
1174
|
]);
|
|
@@ -1033,14 +1188,14 @@ async function validateApiRequest(
|
|
|
1033
1188
|
parameter: missing[0],
|
|
1034
1189
|
requestedAttributes: requested,
|
|
1035
1190
|
availableAttributes: available,
|
|
1036
|
-
validationSource: "/console/inspect request=child
|
|
1191
|
+
validationSource: "/console/inspect request=child",
|
|
1037
1192
|
},
|
|
1038
1193
|
});
|
|
1039
1194
|
}
|
|
1040
1195
|
return {
|
|
1041
1196
|
validation: {
|
|
1042
1197
|
enabled: true,
|
|
1043
|
-
source: "/console/inspect request=child
|
|
1198
|
+
source: "/console/inspect request=child",
|
|
1044
1199
|
result: "passed",
|
|
1045
1200
|
syntax: false,
|
|
1046
1201
|
semantic: true,
|
|
@@ -1062,6 +1217,21 @@ async function validateApiRequest(
|
|
|
1062
1217
|
};
|
|
1063
1218
|
}
|
|
1064
1219
|
|
|
1220
|
+
/**
|
|
1221
|
+
* The `/execute` script body, when there is one to analyze.
|
|
1222
|
+
*
|
|
1223
|
+
* A missing or blank `script` is not this gate's error to raise:
|
|
1224
|
+
* {@link buildProtocolApiRequest} already rejects it with an actionable
|
|
1225
|
+
* `input/invalid-command` naming the three ways to pass one. Reporting a syntax
|
|
1226
|
+
* fault for an absent field would be the wrong diagnosis.
|
|
1227
|
+
*/
|
|
1228
|
+
function scriptOf(resolved: ResolvedApiRequest): string | undefined {
|
|
1229
|
+
const script = resolved.body["script"];
|
|
1230
|
+
return typeof script === "string" && script.trim().length > 0
|
|
1231
|
+
? script
|
|
1232
|
+
: undefined;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1065
1235
|
function unknownPathError(resolved: ResolvedApiRequest): CentrsError {
|
|
1066
1236
|
return new CentrsError({
|
|
1067
1237
|
code: "validation/unknown-path",
|
|
@@ -1076,23 +1246,6 @@ function unknownPathError(resolved: ResolvedApiRequest): CentrsError {
|
|
|
1076
1246
|
});
|
|
1077
1247
|
}
|
|
1078
1248
|
|
|
1079
|
-
async function inspectApiAttributes(
|
|
1080
|
-
backend: ProtocolAdapter,
|
|
1081
|
-
commandTokens: readonly string[],
|
|
1082
|
-
): Promise<string[]> {
|
|
1083
|
-
const children = await inspectChildren(backend, commandTokens);
|
|
1084
|
-
const childAttributes = children
|
|
1085
|
-
.filter(isArgumentNode)
|
|
1086
|
-
.map((child) => child.name)
|
|
1087
|
-
.filter(
|
|
1088
|
-
(name): name is string => typeof name === "string" && name.length > 0,
|
|
1089
|
-
);
|
|
1090
|
-
const completionRows = await inspectCompletions(backend, commandTokens);
|
|
1091
|
-
return [
|
|
1092
|
-
...new Set([...childAttributes, ...extractCompletionNames(completionRows)]),
|
|
1093
|
-
].sort();
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
1249
|
async function assertApiWriteConfirmed(
|
|
1097
1250
|
request: ApiRequest,
|
|
1098
1251
|
resolved: ResolvedApiRequest,
|
|
@@ -1299,7 +1452,7 @@ export function buildApiErrorEnvelope(
|
|
|
1299
1452
|
: false,
|
|
1300
1453
|
listen: request.listen ?? false,
|
|
1301
1454
|
yes: request.yes ?? false,
|
|
1302
|
-
validate:
|
|
1455
|
+
validate: summarizedValidate(request),
|
|
1303
1456
|
raw: request.raw ?? false,
|
|
1304
1457
|
format: resolveErrorFormat(request, env),
|
|
1305
1458
|
},
|
|
@@ -1315,6 +1468,7 @@ export function buildApiErrorEnvelope(
|
|
|
1315
1468
|
export function buildApiErrorEnvelopeFromResolved(
|
|
1316
1469
|
resolved: ResolvedApiRequest,
|
|
1317
1470
|
error: unknown,
|
|
1471
|
+
trace?: ValidationTrace,
|
|
1318
1472
|
): ApiErrorEnvelope {
|
|
1319
1473
|
const centrsError = asApiError(error);
|
|
1320
1474
|
return {
|
|
@@ -1322,17 +1476,134 @@ export function buildApiErrorEnvelopeFromResolved(
|
|
|
1322
1476
|
error: serializeCentrsError(centrsError),
|
|
1323
1477
|
warnings: [...resolved.warnings],
|
|
1324
1478
|
tips: [],
|
|
1325
|
-
meta: metaFromResolved(
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1479
|
+
meta: metaFromResolved(
|
|
1480
|
+
resolved,
|
|
1481
|
+
trace?.validation ??
|
|
1482
|
+
failedApiValidationMeta(resolved, centrsError, trace),
|
|
1483
|
+
),
|
|
1484
|
+
};
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
/**
|
|
1488
|
+
* `meta.validation` for an api error envelope. A stage-1 rejection opened no
|
|
1489
|
+
* connection, so the meta must not report `/console/inspect` as the validator
|
|
1490
|
+
* that failed (GH#354).
|
|
1491
|
+
*/
|
|
1492
|
+
function failedApiValidationMeta(
|
|
1493
|
+
resolved: ResolvedApiRequest,
|
|
1494
|
+
error: CentrsError,
|
|
1495
|
+
trace?: ValidationTrace,
|
|
1496
|
+
): EnvelopeValidationMeta {
|
|
1497
|
+
if (!resolved.validate.value) {
|
|
1498
|
+
return disabledApiValidationMeta(resolved);
|
|
1499
|
+
}
|
|
1500
|
+
if (isOfflineGateRejection(error)) {
|
|
1501
|
+
return {
|
|
1502
|
+
enabled: true,
|
|
1503
|
+
source: OFFLINE_GATE_SOURCE,
|
|
1504
|
+
result: "failed",
|
|
1329
1505
|
syntax: false,
|
|
1330
|
-
semantic:
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1506
|
+
semantic: "not-applicable",
|
|
1507
|
+
stages: [
|
|
1508
|
+
{ stage: "offline", source: OFFLINE_GATE_SOURCE, result: "failed" },
|
|
1509
|
+
{
|
|
1510
|
+
stage: "device",
|
|
1511
|
+
source: apiDeviceStageSource(resolved),
|
|
1512
|
+
result: "skipped",
|
|
1513
|
+
reason: "offline analysis rejected the script; no connection opened",
|
|
1514
|
+
},
|
|
1515
|
+
],
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1518
|
+
// `stages` is only asserted when a `validation/*` error identifies the device
|
|
1519
|
+
// stage as the rejecter. Everything downstream — a RouterOS fault on the
|
|
1520
|
+
// request, a transport drop — passed the gate and is served by
|
|
1521
|
+
// `trace.validation` before this function is ever reached.
|
|
1522
|
+
const deviceRejected = error.code.startsWith("validation/");
|
|
1523
|
+
return {
|
|
1524
|
+
enabled: true,
|
|
1525
|
+
source: apiDeviceStageSource(resolved),
|
|
1526
|
+
result: "failed",
|
|
1527
|
+
syntax: false,
|
|
1528
|
+
semantic: resolved.scriptMode ? "not-applicable" : false,
|
|
1529
|
+
...(deviceRejected
|
|
1530
|
+
? {
|
|
1531
|
+
stages: [
|
|
1532
|
+
offlineApiStage(resolved, trace),
|
|
1533
|
+
deviceStageFailed(resolved),
|
|
1534
|
+
],
|
|
1535
|
+
}
|
|
1536
|
+
: {}),
|
|
1537
|
+
};
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
/**
|
|
1541
|
+
* The offline-stage entry for an api gate that reached the device stage.
|
|
1542
|
+
*
|
|
1543
|
+
* Script mode is the only api shape stage 1 analyzes; a structured path request
|
|
1544
|
+
* is a path plus a body, not a CLI string, so its offline stage is `skipped`
|
|
1545
|
+
* with the reason spelled out rather than silently reported as passed.
|
|
1546
|
+
*/
|
|
1547
|
+
function offlineApiStage(
|
|
1548
|
+
resolved: ResolvedApiRequest,
|
|
1549
|
+
trace?: ValidationTrace,
|
|
1550
|
+
): EnvelopeValidationStage {
|
|
1551
|
+
if (!resolved.scriptMode) {
|
|
1552
|
+
return {
|
|
1553
|
+
stage: "offline",
|
|
1554
|
+
source: OFFLINE_GATE_SOURCE,
|
|
1555
|
+
result: "skipped",
|
|
1556
|
+
reason: "structured path request, not a CLI string (GH#354)",
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
return {
|
|
1560
|
+
stage: "offline",
|
|
1561
|
+
source: OFFLINE_GATE_SOURCE,
|
|
1562
|
+
result: "passed",
|
|
1563
|
+
...(trace?.offlineVerdict === "warn"
|
|
1564
|
+
? { reason: "analyzer abstained on part of the input" }
|
|
1565
|
+
: {}),
|
|
1566
|
+
};
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
function apiDeviceStageSource(resolved: ResolvedApiRequest): string {
|
|
1570
|
+
return resolved.scriptMode
|
|
1571
|
+
? API_SCRIPT_DEVICE_STAGE_SOURCE
|
|
1572
|
+
: API_DEVICE_STAGE_SOURCE;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
function deviceStageFailed(
|
|
1576
|
+
resolved: ResolvedApiRequest,
|
|
1577
|
+
): EnvelopeValidationStage {
|
|
1578
|
+
return {
|
|
1579
|
+
stage: "device",
|
|
1580
|
+
source: apiDeviceStageSource(resolved),
|
|
1581
|
+
result: "failed",
|
|
1582
|
+
};
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
/**
|
|
1586
|
+
* `--validate=false` (including a bare `--raw`). Both stages are listed as
|
|
1587
|
+
* `skipped` rather than omitted, so a consumer reading `stages` never has to
|
|
1588
|
+
* special-case the disabled shape; `enabled: false` already carries the why.
|
|
1589
|
+
*/
|
|
1590
|
+
function disabledApiValidationMeta(
|
|
1591
|
+
resolved: ResolvedApiRequest,
|
|
1592
|
+
): EnvelopeValidationMeta {
|
|
1593
|
+
return {
|
|
1594
|
+
enabled: false,
|
|
1595
|
+
source: "disabled",
|
|
1596
|
+
result: "skipped",
|
|
1597
|
+
syntax: false,
|
|
1598
|
+
semantic: resolved.scriptMode ? "not-applicable" : false,
|
|
1599
|
+
stages: [
|
|
1600
|
+
{ stage: "offline", source: OFFLINE_GATE_SOURCE, result: "skipped" },
|
|
1601
|
+
{
|
|
1602
|
+
stage: "device",
|
|
1603
|
+
source: apiDeviceStageSource(resolved),
|
|
1604
|
+
result: "skipped",
|
|
1605
|
+
},
|
|
1606
|
+
],
|
|
1336
1607
|
};
|
|
1337
1608
|
}
|
|
1338
1609
|
|
|
@@ -1525,7 +1796,7 @@ export function apiRequestSummaryFromRequest(
|
|
|
1525
1796
|
write: parsed ? isApiMutating(parsed, normalized.path) : false,
|
|
1526
1797
|
listen,
|
|
1527
1798
|
yes: request.yes ?? false,
|
|
1528
|
-
validate:
|
|
1799
|
+
validate: summarizedValidate(request),
|
|
1529
1800
|
raw: request.raw ?? false,
|
|
1530
1801
|
format: resolveErrorFormat(request, env),
|
|
1531
1802
|
query: buildApiQuery(request),
|
package/src/cli/api.ts
CHANGED
|
@@ -94,7 +94,7 @@ export const apiCommand: CliCommandMetadata = {
|
|
|
94
94
|
{
|
|
95
95
|
flag: "--raw",
|
|
96
96
|
description:
|
|
97
|
-
"Strip the envelope; emit bare RouterOS JSON.
|
|
97
|
+
"Strip the envelope; emit bare RouterOS JSON. Defaults `--validate` to false (an explicit `--validate=true` still runs the gate); does not imply `--yes`.",
|
|
98
98
|
},
|
|
99
99
|
{
|
|
100
100
|
flag: "--yes",
|
package/src/cli/btest.ts
CHANGED
|
@@ -23,10 +23,10 @@ import {
|
|
|
23
23
|
renderBtestClientEnvelope,
|
|
24
24
|
renderBtestServerEnvelope,
|
|
25
25
|
} from "../btest.ts";
|
|
26
|
+
import { toYaml } from "../core/yaml.ts";
|
|
26
27
|
import { asCentrsError, formatCentrsErrorText } from "../errors.ts";
|
|
27
28
|
import { describeCentrs, parseDuration } from "../index.ts";
|
|
28
29
|
import type { BtestDirection, BtestProtocol } from "../protocols/btest.ts";
|
|
29
|
-
import { toYaml } from "../retrieve.ts";
|
|
30
30
|
import {
|
|
31
31
|
type CliCommandMetadata,
|
|
32
32
|
type CliCommandOption,
|