@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.
Files changed (85) hide show
  1. package/CHANGELOG.md +417 -1
  2. package/GLOSSARY.txt +40 -1
  3. package/README.md +16 -8
  4. package/commands/AGENTS.md +7 -4
  5. package/commands/api/AGENTS.md +13 -1
  6. package/commands/api/README.md +50 -10
  7. package/commands/api/examples.md +100 -2
  8. package/commands/devices/README.md +10 -1
  9. package/commands/devices/examples.md +33 -2
  10. package/commands/execute/README.md +68 -25
  11. package/commands/execute/examples.md +168 -7
  12. package/commands/explain/README.md +1206 -155
  13. package/commands/explain/examples.md +99 -3
  14. package/commands/mcp/README.md +9 -5
  15. package/commands/mcp/examples.md +2 -2
  16. package/commands/retrieve/examples.md +20 -0
  17. package/commands/terminal/examples.md +7 -1
  18. package/docs/CLI.md +3 -3
  19. package/docs/CONSTITUTION.md +83 -21
  20. package/docs/MATRIX.md +89 -52
  21. package/docs/errors/cdb/create-failed.md +20 -0
  22. package/docs/errors/cdb/file-exists.md +23 -0
  23. package/docs/errors/input/invalid-target.md +25 -3
  24. package/docs/errors/routeros/invalid-value.md +3 -2
  25. package/docs/errors/transport/cancel-unacknowledged.md +24 -0
  26. package/docs/errors/validation/syntax.md +29 -4
  27. package/package.json +13 -2
  28. package/src/api-fanout.ts +17 -4
  29. package/src/api.ts +346 -75
  30. package/src/cli/api.ts +1 -1
  31. package/src/cli/btest.ts +1 -1
  32. package/src/cli/devices.ts +16 -3
  33. package/src/cli/explain.ts +1 -1
  34. package/src/cli/terminal.ts +1 -1
  35. package/src/core/envelope.ts +53 -0
  36. package/src/core/error-catalog.ts +16 -1
  37. package/src/core/execute-command.ts +455 -0
  38. package/src/core/inspect.ts +58 -26
  39. package/src/core/routeros-errors.ts +66 -0
  40. package/src/core/version.ts +4 -0
  41. package/src/core/yaml.ts +86 -0
  42. package/src/data/winbox-cdb-write.ts +22 -8
  43. package/src/devices.ts +142 -28
  44. package/src/execute-fanout.ts +19 -5
  45. package/src/execute.ts +247 -295
  46. package/src/explain/arg-tokens.ts +62 -25
  47. package/src/explain/args.ts +398 -52
  48. package/src/explain/blocks.ts +54 -11
  49. package/src/explain/brace-tokens.ts +4 -3
  50. package/src/explain/catalog.ts +42 -44
  51. package/src/explain/coordinates.ts +159 -0
  52. package/src/explain/defects.ts +2 -1
  53. package/src/explain/escape-tokens.ts +77 -0
  54. package/src/explain/highlight-projection.ts +308 -0
  55. package/src/explain/menus.ts +9 -5
  56. package/src/explain/operator-tokens.ts +13 -7
  57. package/src/explain/operators.ts +27 -20
  58. package/src/explain/path-tokens.ts +1 -1
  59. package/src/explain/pathresolve.ts +358 -113
  60. package/src/explain/quoted-string.ts +110 -20
  61. package/src/explain/scope-brace.ts +175 -22
  62. package/src/explain/segment.ts +429 -21
  63. package/src/explain/separator.ts +187 -0
  64. package/src/explain/split-owner.ts +194 -0
  65. package/src/explain/string-tokens.ts +14 -4
  66. package/src/explain/symbol-values.ts +209 -173
  67. package/src/explain/symbols.ts +21 -1
  68. package/src/explain/transport.ts +17 -0
  69. package/src/explain/value-tokens.ts +15 -12
  70. package/src/explain/verbsplit.ts +165 -23
  71. package/src/explain/write.ts +47 -6
  72. package/src/explain.ts +522 -72
  73. package/src/index.ts +21 -2
  74. package/src/mcp/server.ts +2 -1
  75. package/src/mcp/tools.ts +1 -1
  76. package/src/offline-gate.ts +148 -0
  77. package/src/protocols/adapter.ts +12 -1
  78. package/src/protocols/mac-telnet-console.ts +6 -4
  79. package/src/protocols/native-api.ts +29 -3
  80. package/src/resolver/target.ts +32 -12
  81. package/src/retrieve-fanout.ts +1 -1
  82. package/src/retrieve.ts +18 -63
  83. package/src/settings.ts +2 -1
  84. package/src/transfer-fanout.ts +1 -1
  85. 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`, runnable over REST or the native API. The
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 inspect gate and the write-confirmation gate, then
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
- extractCompletionNames,
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 { promptForWriteConfirmation } from "./execute.ts";
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
- /** `--raw`: bare RouterOS body passthrough; implies `--validate=false`. */
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
- enabled: false,
288
- source: "disabled",
289
- result: "skipped",
290
- syntax: false,
291
- semantic: false,
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 = "duration-elapsed";
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 = "count-reached";
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` forces validation off (constitution: --raw waiver); otherwise the
578
- // inspect gate is on by default.
579
- const validate: ResolvedSetting<boolean> = raw
580
- ? { value: false, source: { kind: "cli", key: "--raw" } }
581
- : resolveBooleanSetting(
582
- request.validate,
583
- env,
584
- "CENTRS_VALIDATE",
585
- true,
586
- "validate",
587
- cdbResolution?.overrides.validate,
588
- config,
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 via
917
- * `request=child`; add/set attribute validity via `request=child`+`completion`. A
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: "/console/inspect (script, not-applicable)",
1064
+ source:
1065
+ offline === undefined
1066
+ ? "not-applicable"
1067
+ : API_SCRIPT_DEVICE_STAGE_SOURCE,
929
1068
  result: "passed",
930
- syntax: false,
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 inspectApiAttributes(backend, tokens);
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+completion",
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 inspectApiAttributes(backend, [
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+completion",
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+completion",
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: request.raw ? false : (request.validate ?? true),
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(resolved, {
1326
- enabled: resolved.validate.value,
1327
- source: resolved.validate.value ? "/console/inspect" : "disabled",
1328
- result: resolved.validate.value ? "failed" : "skipped",
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: resolved.validate.value
1331
- ? resolved.scriptMode
1332
- ? "not-applicable"
1333
- : false
1334
- : false,
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: request.raw ? false : (request.validate ?? true),
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. Implies `--validate=false`; does not imply `--yes`.",
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,