@tikoci/centrs 0.1.3 → 0.1.6

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 (107) hide show
  1. package/CHANGELOG.md +348 -0
  2. package/GLOSSARY.txt +252 -0
  3. package/LICENSE +21 -0
  4. package/README.md +44 -12
  5. package/commands/AGENTS.md +14 -5
  6. package/commands/api/AGENTS.md +13 -1
  7. package/commands/api/README.md +56 -26
  8. package/commands/api/examples.md +46 -1
  9. package/commands/btest/README.md +21 -32
  10. package/commands/check/README.md +6 -1
  11. package/commands/discover/README.md +5 -11
  12. package/commands/execute/README.md +77 -36
  13. package/commands/execute/examples.md +120 -10
  14. package/commands/explain/README.md +2756 -0
  15. package/commands/explain/examples.md +702 -0
  16. package/commands/mcp/README.md +7 -4
  17. package/commands/mcp/examples.md +2 -2
  18. package/commands/retrieve/README.md +25 -27
  19. package/commands/retrieve/examples.md +16 -0
  20. package/commands/terminal/examples.md +7 -1
  21. package/commands/transfer/README.md +28 -20
  22. package/commands/transfer/examples.md +29 -0
  23. package/docs/CLI.md +353 -0
  24. package/docs/CONSTITUTION.md +138 -30
  25. package/docs/MATRIX.md +92 -18
  26. package/docs/errors/README.md +3 -0
  27. package/docs/errors/quickchr/machine-not-found.md +22 -0
  28. package/docs/errors/quickchr/machine-stopped.md +20 -0
  29. package/docs/errors/quickchr/package-unavailable.md +27 -0
  30. package/docs/errors/quickchr/unsupported-via.md +35 -0
  31. package/docs/errors/quickchr/unsupported.md +35 -0
  32. package/docs/errors/routeros/version-unsupported.md +26 -0
  33. package/docs/errors/transport/provider-trust.md +29 -0
  34. package/docs/errors/usage/stdin-ignored.md +28 -0
  35. package/docs/errors/validation/syntax.md +29 -4
  36. package/docs/errors/validation/unknown-attribute.md +37 -4
  37. package/package.json +56 -10
  38. package/src/api-fanout.ts +46 -18
  39. package/src/api.ts +351 -121
  40. package/src/cli/api.ts +26 -7
  41. package/src/cli/btest.ts +19 -3
  42. package/src/cli/execute.ts +25 -6
  43. package/src/cli/explain.ts +503 -0
  44. package/src/cli/retrieve.ts +33 -1
  45. package/src/cli/selection.ts +71 -11
  46. package/src/cli/settings.ts +2 -2
  47. package/src/cli/terminal.ts +1 -1
  48. package/src/cli/transfer.ts +23 -6
  49. package/src/cli.ts +15 -2
  50. package/src/core/envelope.ts +58 -2
  51. package/src/core/error-catalog.ts +42 -1
  52. package/src/core/execute-command.ts +148 -0
  53. package/src/core/inspect.ts +36 -4
  54. package/src/core/preflight.ts +306 -0
  55. package/src/core/routeros-string.ts +24 -0
  56. package/src/core/version.ts +4 -0
  57. package/src/core/yaml.ts +86 -0
  58. package/src/errors.ts +1 -0
  59. package/src/execute-fanout.ts +47 -18
  60. package/src/execute.ts +339 -307
  61. package/src/explain/arg-tokens.ts +151 -0
  62. package/src/explain/args.ts +1749 -0
  63. package/src/explain/blocks.ts +175 -0
  64. package/src/explain/brace-slots.ts +143 -0
  65. package/src/explain/brace-tokens.ts +39 -0
  66. package/src/explain/catalog.ts +1343 -0
  67. package/src/explain/coordinates.ts +488 -0
  68. package/src/explain/defects.ts +218 -0
  69. package/src/explain/escape-tokens.ts +77 -0
  70. package/src/explain/highlight-projection.ts +308 -0
  71. package/src/explain/is-known-menu.ts +17 -0
  72. package/src/explain/menus.ts +668 -0
  73. package/src/explain/operator-tokens.ts +316 -0
  74. package/src/explain/operators.ts +465 -0
  75. package/src/explain/path-tokens.ts +150 -0
  76. package/src/explain/pathresolve.ts +1569 -0
  77. package/src/explain/quoted-string.ts +331 -0
  78. package/src/explain/scope-brace.ts +461 -0
  79. package/src/explain/segment.ts +1165 -0
  80. package/src/explain/separator.ts +187 -0
  81. package/src/explain/split-owner.ts +194 -0
  82. package/src/explain/string-tokens.ts +77 -0
  83. package/src/explain/symbol-values.ts +556 -0
  84. package/src/explain/symbols.ts +1617 -0
  85. package/src/explain/token-ranges.ts +72 -0
  86. package/src/explain/transport.ts +514 -0
  87. package/src/explain/value-tokens.ts +83 -0
  88. package/src/explain/values.ts +357 -0
  89. package/src/explain/verbs.ts +64 -0
  90. package/src/explain/verbsplit.ts +955 -0
  91. package/src/explain/write.ts +856 -0
  92. package/src/explain.ts +2602 -0
  93. package/src/index.ts +225 -6
  94. package/src/mcp/server.ts +2 -1
  95. package/src/offline-gate.ts +148 -0
  96. package/src/protocols/mac-telnet-console.ts +61 -17
  97. package/src/resolver/facts.ts +1 -1
  98. package/src/resolver/index.ts +23 -4
  99. package/src/resolver/quickchr-provider.ts +597 -0
  100. package/src/resolver/selection.ts +99 -12
  101. package/src/resolver/service-endpoint.ts +155 -0
  102. package/src/resolver/target.ts +1 -1
  103. package/src/retrieve-fanout.ts +27 -13
  104. package/src/retrieve.ts +82 -77
  105. package/src/settings.ts +2 -1
  106. package/src/transfer-fanout.ts +43 -26
  107. package/src/transfer.ts +88 -45
package/src/api.ts CHANGED
@@ -21,21 +21,26 @@ import type {
21
21
  CommonSettingsMeta,
22
22
  SettingSource as CoreSettingSource,
23
23
  EnvelopeValidationMeta,
24
+ EnvelopeValidationStage,
24
25
  Tip,
26
+ ValidationTrace,
25
27
  Warning,
26
28
  } from "./core/envelope.ts";
27
29
  import { buildTip } from "./core/envelope.ts";
28
30
  import {
29
- extractCompletionNames,
30
- inspectChildren,
31
+ inspectArgumentNames,
31
32
  inspectChildrenOrEmpty,
32
- inspectCompletions,
33
- isArgumentNode,
34
33
  isCommandNode,
35
34
  pathTokens,
36
35
  } from "./core/inspect.ts";
36
+ import { toYaml } from "./core/yaml.ts";
37
37
  import { CentrsError, serializeCentrsError } from "./errors.ts";
38
38
  import { promptForWriteConfirmation } from "./execute.ts";
39
+ import {
40
+ assertOfflineSyntax,
41
+ isOfflineGateRejection,
42
+ OFFLINE_GATE_SOURCE,
43
+ } from "./offline-gate.ts";
39
44
  import {
40
45
  type ApiVerb,
41
46
  createProtocolAdapter,
@@ -45,11 +50,13 @@ import {
45
50
  type RouterOsProtocol,
46
51
  } from "./protocols/index.ts";
47
52
  import {
53
+ assertNoQuickchrOverrideConflict,
48
54
  type CdbResolution,
49
55
  isIpTransport,
50
56
  loadEnvFileDefaults,
51
57
  parseDuration,
52
58
  parseResolvePolicy,
59
+ quickchrConnection,
53
60
  type ResolvedAuth,
54
61
  type ResolvedSetting,
55
62
  type ResolvedTarget,
@@ -57,11 +64,11 @@ import {
57
64
  resolveBooleanSetting,
58
65
  resolveCdb,
59
66
  resolveMacTarget,
67
+ resolveQuickchrTarget,
60
68
  resolveStringSetting,
61
69
  resolveTarget,
62
70
  toCoreSource,
63
71
  } from "./resolver/index.ts";
64
- import { toYaml } from "./retrieve.ts";
65
72
 
66
73
  export const apiOutputFormats = ["json", "yaml", "text"] as const;
67
74
  export type ApiOutputFormat = (typeof apiOutputFormats)[number];
@@ -72,6 +79,12 @@ export type ApiMethod = (typeof apiMethods)[number];
72
79
 
73
80
  export interface ApiRequest {
74
81
  targetInput?: string;
82
+ /**
83
+ * quickchr machine name (`--quickchr <name>`): resolve host/port/auth from
84
+ * the live VM descriptor instead of CDB/env (`docs/CONSTITUTION.md` →
85
+ * Resolution providers). Conflicts with host/port/username/password.
86
+ */
87
+ quickchr?: string;
75
88
  /** REST-style endpoint path, leniently normalized (`ip/address`, `/rest/ip/address`, `"ip address"`, `ip/address/*1`, `ip/address/listen`). */
76
89
  endpoint: string;
77
90
  /** HTTP method (`-X`), default `GET`, case-insensitive. */
@@ -88,7 +101,11 @@ export interface ApiRequest {
88
101
  rawQuery?: readonly string[];
89
102
  /** `--proplist` / `--attribute` property projection. */
90
103
  proplist?: readonly string[];
91
- /** `--raw`: bare RouterOS body passthrough; implies `--validate=false`. */
104
+ /**
105
+ * `--raw`: bare RouterOS body passthrough. DEFAULTS `--validate` to false; an
106
+ * explicit `validate` still wins, and its failures render through the `--raw`
107
+ * error contract (#154).
108
+ */
92
109
  raw?: boolean;
93
110
  /** `--listen`: native-api-only open-ended follow (later phase; rejected here). */
94
111
  listen?: boolean;
@@ -115,6 +132,23 @@ export interface ApiRequest {
115
132
  confirm?: (prompt: string) => Promise<boolean>;
116
133
  }
117
134
 
135
+ /**
136
+ * The `--raw` / `--validate` precedence for a summary built BEFORE (or instead
137
+ * of) full resolution — a usage error, or a fan-out member that never reached
138
+ * `resolveApiRequest`.
139
+ *
140
+ * Ambient sources are deliberately out of reach here: this runs where the CDB
141
+ * and config tiers have not been loaded, so it reports the two layers it CAN
142
+ * see. That is why it is a shared helper rather than an inlined expression —
143
+ * both call sites used to spell `request.raw ? false : …`, which contradicted
144
+ * the resolved request by reporting `validate: false` for an explicit
145
+ * `--raw --validate=true` (#154).
146
+ */
147
+ function summarizedValidate(request: ApiRequest): boolean {
148
+ if (request.validate !== undefined) return request.validate;
149
+ return !(request.raw ?? false);
150
+ }
151
+
118
152
  export interface NormalizedApiEndpoint {
119
153
  /** Canonical slash path with the leading `/rest` and any trailing id/`listen` removed. */
120
154
  path: string;
@@ -238,19 +272,25 @@ export async function apiEnvelope(
238
272
  env: Record<string, string | undefined> = Bun.env,
239
273
  ): Promise<ApiEnvelope> {
240
274
  let resolved: ResolvedApiRequest | undefined;
275
+ // The gate finishes inside `runResolvedApi`, but the error envelope is built
276
+ // out here after the throw has unwound past it. Without the trace, a
277
+ // `transport/*` or `routeros/*` fault on the request — both stages already
278
+ // settled — reported `/console/inspect` as the validator that failed.
279
+ const trace: ValidationTrace = {};
241
280
  try {
242
281
  resolved = await resolveApiRequest(request, env);
243
282
  await assertApiWriteConfirmed(request, resolved);
244
- return await runResolvedApi(resolved);
283
+ return await runResolvedApi(resolved, trace);
245
284
  } catch (error) {
246
285
  return resolved
247
- ? buildApiErrorEnvelopeFromResolved(resolved, error)
286
+ ? buildApiErrorEnvelopeFromResolved(resolved, error, trace)
248
287
  : buildApiErrorEnvelope(request, error, env);
249
288
  }
250
289
  }
251
290
 
252
291
  export async function runResolvedApi(
253
292
  resolved: ResolvedApiRequest,
293
+ trace: ValidationTrace = {},
254
294
  ): Promise<ApiSuccessEnvelope> {
255
295
  assertListenCapability(resolved);
256
296
  const backend = createProtocolAdapter({
@@ -270,18 +310,15 @@ export async function runResolvedApi(
270
310
  let tips: Tip[] = [];
271
311
  try {
272
312
  if (resolved.validate.value) {
273
- const result = await validateApiRequest(resolved, backend);
313
+ const result = await validateApiRequest(resolved, backend, trace);
274
314
  validation = result.validation;
275
315
  tips = result.tips;
276
316
  } else {
277
- validation = {
278
- enabled: false,
279
- source: "disabled",
280
- result: "skipped",
281
- syntax: false,
282
- semantic: false,
283
- };
317
+ validation = disabledApiValidationMeta(resolved);
284
318
  }
319
+ // Recorded BEFORE the request, so a transport drop or RouterOS fault below
320
+ // reports the gate that actually settled rather than a guess.
321
+ trace.validation = validation;
285
322
 
286
323
  const result = await backend.apiRequest(buildProtocolApiRequest(resolved));
287
324
  return {
@@ -542,32 +579,63 @@ export async function resolveApiRequest(
542
579
  const query = buildApiQuery(request);
543
580
  const proplist = buildApiProplist(request);
544
581
 
545
- const cdbResolution =
546
- override?.cdbResolution ??
547
- (await resolveCdb(
548
- {
549
- targetInput: request.targetInput,
550
- cdbFile: request.cdbFile,
551
- cdbPassword: request.cdbPassword,
552
- },
553
- env,
554
- config,
555
- ));
556
- const via = resolveApiProtocol(request, env, listen, cdbResolution, config);
557
- const format = resolveApiFormat(request, env, config);
558
- // `--raw` forces validation off (constitution: --raw waiver); otherwise the
559
- // inspect gate is on by default.
560
- const validate: ResolvedSetting<boolean> = raw
561
- ? { value: false, source: { kind: "cli", key: "--raw" } }
562
- : resolveBooleanSetting(
563
- request.validate,
582
+ // A quickchr target is a named-live-provider: host/port/auth come from the
583
+ // live descriptor, bypassing CDB, `__default__`, and MAC resolution.
584
+ let quickchrResolution: Awaited<
585
+ ReturnType<typeof resolveQuickchrTarget>
586
+ > | null = null;
587
+ if (request.quickchr !== undefined) {
588
+ assertNoQuickchrOverrideConflict(request, request.quickchr);
589
+ quickchrResolution = await resolveQuickchrTarget(request.quickchr);
590
+ }
591
+ const cdbResolution = quickchrResolution
592
+ ? undefined
593
+ : (override?.cdbResolution ??
594
+ (await resolveCdb(
595
+ {
596
+ targetInput: request.targetInput,
597
+ cdbFile: request.cdbFile,
598
+ cdbPassword: request.cdbPassword,
599
+ },
564
600
  env,
565
- "CENTRS_VALIDATE",
566
- true,
567
- "validate",
568
- cdbResolution?.overrides.validate,
569
601
  config,
570
- );
602
+ )));
603
+ const via = resolveApiProtocol(request, env, listen, cdbResolution, config);
604
+ const format = resolveApiFormat(request, env, config);
605
+ // `--raw` is a PRECEDENCE LAYER, not an override (#154). It ranks below an
606
+ // explicit `--validate` and above every ambient source, so:
607
+ //
608
+ // --validate=true|false explicit CLI flag ← wins
609
+ // --raw → validate=false
610
+ // CENTRS_VALIDATE / CDB comment-kv / config
611
+ // default → validate=true
612
+ //
613
+ // It used to short-circuit the whole ladder, which silently discarded an
614
+ // explicit `--validate=true` — and env, CDB and config with it. `--raw
615
+ // --validate=true` is the intended way to debug `api` when centrs itself is
616
+ // suspect: the gate runs and a failure renders through `rawErrorPayload` as
617
+ // `{code,message}` on stderr with a nonzero exit, which is the `--raw` error
618
+ // contract for any other preflight failure. `--raw` still outranks the
619
+ // ambient sources rather than merely lowering the default, so it behaves the
620
+ // same on every machine regardless of a `CENTRS_VALIDATE` in the environment.
621
+ //
622
+ // The `--raw` arm must SHORT-CIRCUIT, not merely win a comparison: resolving
623
+ // the ambient layer first would parse `CENTRS_VALIDATE` even when `--raw`
624
+ // decides the value, so a malformed ambient value threw `settings/invalid-
625
+ // boolean` out of a call `--raw` had already settled — the same
626
+ // machine-dependence this layer exists to remove.
627
+ const validate: ResolvedSetting<boolean> =
628
+ raw && request.validate === undefined
629
+ ? { value: false, source: { kind: "cli", key: "--raw" } }
630
+ : resolveBooleanSetting(
631
+ request.validate,
632
+ env,
633
+ "CENTRS_VALIDATE",
634
+ true,
635
+ "validate",
636
+ cdbResolution?.overrides.validate,
637
+ config,
638
+ );
571
639
  const timeoutMs = resolveApiTimeout(
572
640
  request.timeout,
573
641
  env,
@@ -575,48 +643,70 @@ export async function resolveApiRequest(
575
643
  cdbResolution?.overrides.timeoutMs,
576
644
  config,
577
645
  );
578
- const macResolution = isIpTransport(via.value)
579
- ? await resolveMacTarget({
580
- host: request.host,
581
- targetInput: request.targetInput,
582
- cdbTarget: cdbResolution?.target,
646
+ const macResolution =
647
+ quickchrResolution === null && isIpTransport(via.value)
648
+ ? await resolveMacTarget({
649
+ host: request.host,
650
+ targetInput: request.targetInput,
651
+ cdbTarget: cdbResolution?.target,
652
+ env,
653
+ config,
654
+ policy: parseResolvePolicy(
655
+ request.resolve ??
656
+ env["CENTRS_RESOLVE"] ??
657
+ config["CENTRS_RESOLVE"],
658
+ ),
659
+ // api has no Layer-2 transport (rest-api/native-api only), so MAC
660
+ // remediation matches retrieve's (no mac-telnet suggestion).
661
+ operation: "retrieve",
662
+ })
663
+ : undefined;
664
+ const connection = quickchrResolution
665
+ ? quickchrConnection(quickchrResolution, via.value)
666
+ : null;
667
+ const target = connection
668
+ ? connection.target
669
+ : resolveTarget(
670
+ {
671
+ targetInput: request.targetInput,
672
+ host: request.host,
673
+ port: request.port,
674
+ macResolution,
675
+ },
583
676
  env,
677
+ via.value,
678
+ cdbResolution,
584
679
  config,
585
- policy: parseResolvePolicy(
586
- request.resolve ?? env["CENTRS_RESOLVE"] ?? config["CENTRS_RESOLVE"],
587
- ),
588
- // api has no Layer-2 transport (rest-api/native-api only), so MAC
589
- // remediation matches retrieve's (no mac-telnet suggestion).
590
- operation: "retrieve",
591
- })
592
- : undefined;
593
- const target = resolveTarget(
594
- {
595
- targetInput: request.targetInput,
596
- host: request.host,
597
- port: request.port,
598
- macResolution,
599
- },
600
- env,
601
- via.value,
602
- cdbResolution,
603
- config,
604
- );
605
- const auth = resolveAuth(
606
- { username: request.username, password: request.password },
607
- env,
608
- cdbResolution,
609
- config,
610
- );
611
- const insecure = resolveBooleanSetting(
612
- request.insecure,
613
- env,
614
- "CENTRS_INSECURE",
615
- false,
616
- "insecure",
617
- cdbResolution?.overrides.insecure,
618
- config,
619
- );
680
+ );
681
+ const auth = connection
682
+ ? connection.auth
683
+ : resolveAuth(
684
+ { username: request.username, password: request.password },
685
+ env,
686
+ cdbResolution,
687
+ config,
688
+ );
689
+ // A quickchr TLS endpoint is trusted by provenance (self-signed VM cert) —
690
+ // `insecure` is forced with provider provenance and the provider-trust
691
+ // warning rides the envelope (see QuickchrConnection.insecure).
692
+ const insecure =
693
+ connection?.insecure === true
694
+ ? {
695
+ value: true,
696
+ source: {
697
+ kind: "provider" as const,
698
+ key: `quickchr:${request.quickchr}`,
699
+ },
700
+ }
701
+ : resolveBooleanSetting(
702
+ request.insecure,
703
+ env,
704
+ "CENTRS_INSECURE",
705
+ false,
706
+ "insecure",
707
+ cdbResolution?.overrides.insecure,
708
+ config,
709
+ );
620
710
 
621
711
  return {
622
712
  endpoint: request.endpoint,
@@ -644,7 +734,9 @@ export async function resolveApiRequest(
644
734
  insecure,
645
735
  yes: request.yes ?? false,
646
736
  verbose: request.verbose ?? false,
647
- warnings: cdbResolution?.warnings ?? [],
737
+ warnings: connection
738
+ ? [...connection.warnings]
739
+ : (cdbResolution?.warnings ?? []),
648
740
  };
649
741
  }
650
742
 
@@ -863,6 +955,9 @@ export function buildProtocolApiRequest(
863
955
  return request;
864
956
  }
865
957
 
958
+ /** The api device-stage validator: structured input is inspected, never `:parse`d. */
959
+ const API_DEVICE_STAGE_SOURCE = "/console/inspect request=child";
960
+
866
961
  interface ApiValidationResult {
867
962
  validation: EnvelopeValidationMeta;
868
963
  tips: Tip[];
@@ -870,22 +965,58 @@ interface ApiValidationResult {
870
965
 
871
966
  /**
872
967
  * The structured-input gate. Because the input is a path (not a CLI string) the
873
- * validator is `/console/inspect`, never `:put [:parse]`. Path existence via
874
- * `request=child`; add/set attribute validity via `request=child`+`completion`. A
968
+ * validator is `/console/inspect`, never `:put [:parse]`. Path existence and
969
+ * add/set attribute validity both come from `request=child` — command-level
970
+ * `request=completion` is unsafe on RouterOS 7.12.2 (`core/inspect.ts`). A
875
971
  * `/execute` script is a CLI string → `semantic: not-applicable`.
876
972
  */
877
973
  async function validateApiRequest(
878
974
  resolved: ResolvedApiRequest,
879
975
  backend: ProtocolAdapter,
976
+ trace: ValidationTrace = {},
880
977
  ): Promise<ApiValidationResult> {
881
978
  if (resolved.scriptMode) {
979
+ // `/execute` carries a CLI string, so `/console/inspect` has nothing to
980
+ // inspect — which until GH#354 meant the script was sent with NO gate at
981
+ // all (`syntax: false`, `semantic: "not-applicable"`, `result: "passed"`).
982
+ // The offline analyzer is the whole gate here: a syntax fault is now a
983
+ // byte span and no round trip, where before it reached the device.
984
+ const script = scriptOf(resolved);
985
+ const offline = script
986
+ ? assertOfflineSyntax(script, {
987
+ surface: "api /execute",
988
+ via: resolved.via.value,
989
+ })
990
+ : undefined;
991
+ trace.offlineVerdict = offline?.verdict;
992
+ const offlineReason =
993
+ offline === undefined
994
+ ? "no `script` field to analyze"
995
+ : offline.verdict === "warn"
996
+ ? "analyzer abstained on part of the input"
997
+ : undefined;
882
998
  return {
883
999
  validation: {
884
1000
  enabled: true,
885
- source: "/console/inspect (script, not-applicable)",
1001
+ source: offline === undefined ? "not-applicable" : OFFLINE_GATE_SOURCE,
886
1002
  result: "passed",
887
- syntax: false,
1003
+ syntax: offline !== undefined,
888
1004
  semantic: "not-applicable",
1005
+ stages: [
1006
+ {
1007
+ stage: "offline",
1008
+ source: OFFLINE_GATE_SOURCE,
1009
+ result: offline === undefined ? "skipped" : "passed",
1010
+ ...(offlineReason ? { reason: offlineReason } : {}),
1011
+ },
1012
+ {
1013
+ stage: "device",
1014
+ source: "/console/inspect request=child",
1015
+ result: "skipped",
1016
+ reason:
1017
+ "`/execute` is a CLI string, not a menu path; RouterOS re-validates it on the run",
1018
+ },
1019
+ ],
889
1020
  },
890
1021
  tips: [],
891
1022
  };
@@ -927,7 +1058,7 @@ async function validateApiRequest(
927
1058
  // Only reject when inspect actually surfaced the command's arguments; if it
928
1059
  // returns none, skip rather than false-reject a valid command.
929
1060
  if (isCommand && Object.keys(resolved.body).length > 0) {
930
- const available = await inspectApiAttributes(backend, tokens);
1061
+ const available = await inspectArgumentNames(backend, tokens);
931
1062
  if (available.length > 0) {
932
1063
  const requested = Object.keys(resolved.body);
933
1064
  const missing = requested.filter(
@@ -942,10 +1073,10 @@ async function validateApiRequest(
942
1073
  context: {
943
1074
  path: resolved.path,
944
1075
  verb: resolved.verb,
945
- attribute: missing[0],
1076
+ parameter: missing[0],
946
1077
  requestedAttributes: requested,
947
1078
  availableAttributes: available,
948
- validationSource: "/console/inspect request=child+completion",
1079
+ validationSource: "/console/inspect request=child",
949
1080
  },
950
1081
  });
951
1082
  }
@@ -970,7 +1101,7 @@ async function validateApiRequest(
970
1101
  }
971
1102
 
972
1103
  if (resolved.verb === "add" || resolved.verb === "set") {
973
- const available = await inspectApiAttributes(backend, [
1104
+ const available = await inspectArgumentNames(backend, [
974
1105
  ...tokens,
975
1106
  resolved.verb,
976
1107
  ]);
@@ -987,17 +1118,17 @@ async function validateApiRequest(
987
1118
  context: {
988
1119
  path: resolved.path,
989
1120
  verb: resolved.verb,
990
- attribute: missing[0],
1121
+ parameter: missing[0],
991
1122
  requestedAttributes: requested,
992
1123
  availableAttributes: available,
993
- validationSource: "/console/inspect request=child+completion",
1124
+ validationSource: "/console/inspect request=child",
994
1125
  },
995
1126
  });
996
1127
  }
997
1128
  return {
998
1129
  validation: {
999
1130
  enabled: true,
1000
- source: "/console/inspect request=child+completion",
1131
+ source: "/console/inspect request=child",
1001
1132
  result: "passed",
1002
1133
  syntax: false,
1003
1134
  semantic: true,
@@ -1019,6 +1150,21 @@ async function validateApiRequest(
1019
1150
  };
1020
1151
  }
1021
1152
 
1153
+ /**
1154
+ * The `/execute` script body, when there is one to analyze.
1155
+ *
1156
+ * A missing or blank `script` is not this gate's error to raise:
1157
+ * {@link buildProtocolApiRequest} already rejects it with an actionable
1158
+ * `input/invalid-command` naming the three ways to pass one. Reporting a syntax
1159
+ * fault for an absent field would be the wrong diagnosis.
1160
+ */
1161
+ function scriptOf(resolved: ResolvedApiRequest): string | undefined {
1162
+ const script = resolved.body["script"];
1163
+ return typeof script === "string" && script.trim().length > 0
1164
+ ? script
1165
+ : undefined;
1166
+ }
1167
+
1022
1168
  function unknownPathError(resolved: ResolvedApiRequest): CentrsError {
1023
1169
  return new CentrsError({
1024
1170
  code: "validation/unknown-path",
@@ -1033,23 +1179,6 @@ function unknownPathError(resolved: ResolvedApiRequest): CentrsError {
1033
1179
  });
1034
1180
  }
1035
1181
 
1036
- async function inspectApiAttributes(
1037
- backend: ProtocolAdapter,
1038
- commandTokens: readonly string[],
1039
- ): Promise<string[]> {
1040
- const children = await inspectChildren(backend, commandTokens);
1041
- const childAttributes = children
1042
- .filter(isArgumentNode)
1043
- .map((child) => child.name)
1044
- .filter(
1045
- (name): name is string => typeof name === "string" && name.length > 0,
1046
- );
1047
- const completionRows = await inspectCompletions(backend, commandTokens);
1048
- return [
1049
- ...new Set([...childAttributes, ...extractCompletionNames(completionRows)]),
1050
- ].sort();
1051
- }
1052
-
1053
1182
  async function assertApiWriteConfirmed(
1054
1183
  request: ApiRequest,
1055
1184
  resolved: ResolvedApiRequest,
@@ -1256,7 +1385,7 @@ export function buildApiErrorEnvelope(
1256
1385
  : false,
1257
1386
  listen: request.listen ?? false,
1258
1387
  yes: request.yes ?? false,
1259
- validate: request.raw ? false : (request.validate ?? true),
1388
+ validate: summarizedValidate(request),
1260
1389
  raw: request.raw ?? false,
1261
1390
  format: resolveErrorFormat(request, env),
1262
1391
  },
@@ -1272,6 +1401,7 @@ export function buildApiErrorEnvelope(
1272
1401
  export function buildApiErrorEnvelopeFromResolved(
1273
1402
  resolved: ResolvedApiRequest,
1274
1403
  error: unknown,
1404
+ trace?: ValidationTrace,
1275
1405
  ): ApiErrorEnvelope {
1276
1406
  const centrsError = asApiError(error);
1277
1407
  return {
@@ -1279,17 +1409,117 @@ export function buildApiErrorEnvelopeFromResolved(
1279
1409
  error: serializeCentrsError(centrsError),
1280
1410
  warnings: [...resolved.warnings],
1281
1411
  tips: [],
1282
- meta: metaFromResolved(resolved, {
1283
- enabled: resolved.validate.value,
1284
- source: resolved.validate.value ? "/console/inspect" : "disabled",
1285
- result: resolved.validate.value ? "failed" : "skipped",
1412
+ meta: metaFromResolved(
1413
+ resolved,
1414
+ trace?.validation ??
1415
+ failedApiValidationMeta(resolved, centrsError, trace),
1416
+ ),
1417
+ };
1418
+ }
1419
+
1420
+ /**
1421
+ * `meta.validation` for an api error envelope. A stage-1 rejection opened no
1422
+ * connection, so the meta must not report `/console/inspect` as the validator
1423
+ * that failed (GH#354).
1424
+ */
1425
+ function failedApiValidationMeta(
1426
+ resolved: ResolvedApiRequest,
1427
+ error: CentrsError,
1428
+ trace?: ValidationTrace,
1429
+ ): EnvelopeValidationMeta {
1430
+ if (!resolved.validate.value) {
1431
+ return disabledApiValidationMeta(resolved);
1432
+ }
1433
+ if (isOfflineGateRejection(error)) {
1434
+ return {
1435
+ enabled: true,
1436
+ source: OFFLINE_GATE_SOURCE,
1437
+ result: "failed",
1286
1438
  syntax: false,
1287
- semantic: resolved.validate.value
1288
- ? resolved.scriptMode
1289
- ? "not-applicable"
1290
- : false
1291
- : false,
1292
- }),
1439
+ semantic: "not-applicable",
1440
+ stages: [
1441
+ { stage: "offline", source: OFFLINE_GATE_SOURCE, result: "failed" },
1442
+ {
1443
+ stage: "device",
1444
+ source: API_DEVICE_STAGE_SOURCE,
1445
+ result: "skipped",
1446
+ reason: "offline analysis rejected the script; no connection opened",
1447
+ },
1448
+ ],
1449
+ };
1450
+ }
1451
+ // `stages` is only asserted when a `validation/*` error identifies the device
1452
+ // stage as the rejecter. Everything downstream — a RouterOS fault on the
1453
+ // request, a transport drop — passed the gate and is served by
1454
+ // `trace.validation` before this function is ever reached.
1455
+ const deviceRejected = error.code.startsWith("validation/");
1456
+ return {
1457
+ enabled: true,
1458
+ source: "/console/inspect",
1459
+ result: "failed",
1460
+ syntax: false,
1461
+ semantic: resolved.scriptMode ? "not-applicable" : false,
1462
+ ...(deviceRejected
1463
+ ? { stages: [offlineApiStage(resolved, trace), deviceStageFailed()] }
1464
+ : {}),
1465
+ };
1466
+ }
1467
+
1468
+ /**
1469
+ * The offline-stage entry for an api gate that reached the device stage.
1470
+ *
1471
+ * Script mode is the only api shape stage 1 analyzes; a structured path request
1472
+ * is a path plus a body, not a CLI string, so its offline stage is `skipped`
1473
+ * with the reason spelled out rather than silently reported as passed.
1474
+ */
1475
+ function offlineApiStage(
1476
+ resolved: ResolvedApiRequest,
1477
+ trace?: ValidationTrace,
1478
+ ): EnvelopeValidationStage {
1479
+ if (!resolved.scriptMode) {
1480
+ return {
1481
+ stage: "offline",
1482
+ source: OFFLINE_GATE_SOURCE,
1483
+ result: "skipped",
1484
+ reason: "structured path request, not a CLI string (GH#354)",
1485
+ };
1486
+ }
1487
+ return {
1488
+ stage: "offline",
1489
+ source: OFFLINE_GATE_SOURCE,
1490
+ result: "passed",
1491
+ ...(trace?.offlineVerdict === "warn"
1492
+ ? { reason: "analyzer abstained on part of the input" }
1493
+ : {}),
1494
+ };
1495
+ }
1496
+
1497
+ function deviceStageFailed(): EnvelopeValidationStage {
1498
+ return {
1499
+ stage: "device",
1500
+ source: API_DEVICE_STAGE_SOURCE,
1501
+ result: "failed",
1502
+ };
1503
+ }
1504
+
1505
+ /**
1506
+ * `--validate=false` (including a bare `--raw`). Both stages are listed as
1507
+ * `skipped` rather than omitted, so a consumer reading `stages` never has to
1508
+ * special-case the disabled shape; `enabled: false` already carries the why.
1509
+ */
1510
+ function disabledApiValidationMeta(
1511
+ resolved: ResolvedApiRequest,
1512
+ ): EnvelopeValidationMeta {
1513
+ return {
1514
+ enabled: false,
1515
+ source: "disabled",
1516
+ result: "skipped",
1517
+ syntax: false,
1518
+ semantic: resolved.scriptMode ? "not-applicable" : false,
1519
+ stages: [
1520
+ { stage: "offline", source: OFFLINE_GATE_SOURCE, result: "skipped" },
1521
+ { stage: "device", source: API_DEVICE_STAGE_SOURCE, result: "skipped" },
1522
+ ],
1293
1523
  };
1294
1524
  }
1295
1525
 
@@ -1482,7 +1712,7 @@ export function apiRequestSummaryFromRequest(
1482
1712
  write: parsed ? isApiMutating(parsed, normalized.path) : false,
1483
1713
  listen,
1484
1714
  yes: request.yes ?? false,
1485
- validate: request.raw ? false : (request.validate ?? true),
1715
+ validate: summarizedValidate(request),
1486
1716
  raw: request.raw ?? false,
1487
1717
  format: resolveErrorFormat(request, env),
1488
1718
  query: buildApiQuery(request),