@tikoci/centrs 0.1.9-next.22 → 0.1.9-next.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/GLOSSARY.txt +38 -0
  3. package/README.md +2 -2
  4. package/commands/api/AGENTS.md +18 -1
  5. package/commands/api/README.md +10 -6
  6. package/commands/api/examples.md +1 -1
  7. package/commands/execute/README.md +26 -6
  8. package/commands/execute/examples.md +43 -5
  9. package/commands/explain/README.md +873 -182
  10. package/commands/explain/examples.md +447 -38
  11. package/commands/retrieve/README.md +13 -2
  12. package/commands/retrieve/examples.md +30 -1
  13. package/commands/transfer/README.md +8 -4
  14. package/docs/CLI.md +19 -5
  15. package/docs/CONSTITUTION.md +18 -9
  16. package/docs/MATRIX.md +51 -26
  17. package/docs/errors/cmr/caller-policy-not-enforced.md +5 -3
  18. package/docs/errors/routeros/permission-denied.md +24 -0
  19. package/docs/errors/transport/edge-auth-failed.md +22 -0
  20. package/docs/errors/transport/write-outcome-unknown.md +3 -0
  21. package/docs/errors/usage/invalid-cursor.md +20 -0
  22. package/docs/errors/validation/unknown-path.md +9 -0
  23. package/package.json +5 -2
  24. package/src/api.ts +34 -39
  25. package/src/cli/explain.ts +206 -47
  26. package/src/core/envelope.ts +10 -3
  27. package/src/core/error-catalog.ts +15 -0
  28. package/src/core/execute-command.ts +53 -0
  29. package/src/core/fanout.ts +12 -2
  30. package/src/core/inspect.ts +4 -5
  31. package/src/core/routeros-errors.ts +25 -0
  32. package/src/execute-cmr.ts +2 -8
  33. package/src/execute.ts +48 -20
  34. package/src/explain/args.ts +181 -15
  35. package/src/explain/blocks.ts +3 -4
  36. package/src/explain/brace-slots.ts +5 -0
  37. package/src/explain/catalog.ts +13 -6
  38. package/src/explain/defects.ts +1 -0
  39. package/src/explain/group-faults.ts +1060 -0
  40. package/src/explain/highlight-projection.ts +10 -8
  41. package/src/explain/operators.ts +22 -20
  42. package/src/explain/pathresolve.ts +102 -20
  43. package/src/explain/quoted-string.ts +41 -6
  44. package/src/explain/run-tokens.ts +223 -0
  45. package/src/explain/scope-brace.ts +319 -7
  46. package/src/explain/segment.ts +177 -4
  47. package/src/explain/spaced-args.ts +116 -0
  48. package/src/explain/symbols.ts +163 -7
  49. package/src/explain/transport.ts +13 -0
  50. package/src/explain/values.ts +55 -4
  51. package/src/explain/verbsplit.ts +14 -188
  52. package/src/explain/write.ts +8 -1
  53. package/src/explain-live-facets.ts +739 -0
  54. package/src/explain-live-types.ts +490 -0
  55. package/src/explain-live.ts +593 -0
  56. package/src/explain.ts +772 -60
  57. package/src/index.ts +6 -0
  58. package/src/offline-gate.ts +30 -9
  59. package/src/protocols/adapter.ts +88 -10
  60. package/src/protocols/btest-session.ts +30 -10
  61. package/src/protocols/mac-telnet-console.ts +16 -0
  62. package/src/protocols/mac-telnet.ts +38 -10
  63. package/src/protocols/native-api.ts +13 -12
  64. package/src/retrieve-follow.ts +2 -1
  65. package/src/retrieve-sample.ts +2 -1
  66. package/src/retrieve-wait.ts +10 -6
  67. package/src/retrieve.ts +77 -27
  68. package/src/transfer.ts +38 -13
package/src/index.ts CHANGED
@@ -480,6 +480,7 @@ export {
480
480
  type ExplainEvidence,
481
481
  type ExplainFailOn,
482
482
  type ExplainInput,
483
+ type ExplainLiveData,
483
484
  type ExplainMenuReading,
484
485
  type ExplainOperationMeta,
485
486
  type ExplainOutputFormat,
@@ -514,6 +515,11 @@ export {
514
515
  explainOutputFormats,
515
516
  renderExplainEnvelope,
516
517
  } from "./explain.ts";
518
+ export {
519
+ EXPLAIN_LIVE_LIMITS,
520
+ type ExplainLiveRequest,
521
+ explainLiveEnvelope,
522
+ } from "./explain-live.ts";
517
523
  export {
518
524
  assertOfflineSyntax,
519
525
  isOfflineGateRejection,
@@ -25,6 +25,15 @@
25
25
  * module never reports success on its behalf. `verdict: "warn"` is an
26
26
  * ABSTENTION, not a rejection, and passes through for the same reason.
27
27
  *
28
+ * **One warning is refused by code: {@link REFUSED_WARNING_CODES}.** It is not
29
+ * an abstention but a known device behaviour. A `}` or `]` that closes nothing
30
+ * at the top level makes RouterOS accept the script and silently stop reading
31
+ * there, so `/system identity set name=a}` sets `a` and `:put 1};:put 2` runs
32
+ * one command (#504, CHR 7.24.5 and 7.23.8). `explain` reports it as a warning
33
+ * because nothing is rejected; the gate refuses it because the script the
34
+ * device would run is not the one that was written. The list is explicit and
35
+ * stays small: a code joins it only for the same reason.
36
+ *
28
37
  * `--validate=false` disables BOTH stages — one flag, one meaning. That was
29
38
  * settled with the `--raw` precedence layer on GH#154 and is why callers gate
30
39
  * this on the same resolved `validate` setting as the device stage; the escape
@@ -39,11 +48,20 @@
39
48
  */
40
49
 
41
50
  import { CentrsError } from "./errors.ts";
42
- import { type ExplainDiagnostic, explainCommand } from "./explain.ts";
51
+ import {
52
+ DISCARDED_CLOSE_CODE,
53
+ type ExplainDiagnostic,
54
+ explainCommand,
55
+ } from "./explain.ts";
43
56
 
44
57
  /** Validator identity for `meta.validation` and error context. */
45
58
  export const OFFLINE_GATE_SOURCE = "offline explain (canonicalizer)";
46
59
 
60
+ /** Warning codes the gate refuses as it refuses an error (module header). */
61
+ export const REFUSED_WARNING_CODES: ReadonlySet<string> = new Set([
62
+ DISCARDED_CLOSE_CODE,
63
+ ]);
64
+
47
65
  /** Which stage of the two-stage gate produced a verdict. */
48
66
  export type ValidationStageName = "offline" | "device";
49
67
 
@@ -61,8 +79,8 @@ export interface OfflineGateFinding {
61
79
  export interface OfflineGateResult {
62
80
  /**
63
81
  * `pass` — the analyzer read the whole input. `warn` — it ABSTAINED on part
64
- * of it (an unresolved menu, say). Both proceed to the device stage; only
65
- * `fail` throws.
82
+ * of it (an unresolved menu, say). Both proceed to the device stage; a
83
+ * `fail`, or a warning in {@link REFUSED_WARNING_CODES}, throws.
66
84
  */
67
85
  verdict: "pass" | "warn";
68
86
  /** Warning-severity diagnostics, kept so a caller can surface the abstention. */
@@ -95,25 +113,28 @@ export function assertOfflineSyntax(
95
113
  ): OfflineGateResult {
96
114
  const analysis = explainCommand(command);
97
115
  const errors = analysis.diagnostics.filter(
98
- (diagnostic) => diagnostic.severity === "error",
116
+ (diagnostic) =>
117
+ diagnostic.severity === "error" ||
118
+ REFUSED_WARNING_CODES.has(diagnostic.code),
99
119
  );
100
120
  const warnings = analysis.diagnostics
101
121
  .filter((diagnostic) => diagnostic.severity === "warning")
102
122
  .map(findingOf);
103
123
 
104
- if (analysis.verdict !== "fail") {
124
+ if (errors.length === 0) {
105
125
  return { verdict: analysis.verdict === "warn" ? "warn" : "pass", warnings };
106
126
  }
107
127
 
108
- // A `fail` verdict always carries at least one error diagnostic, but read it
109
- // defensively: an empty list would otherwise produce an error that says
110
- // nothing about what was wrong, which is worse than a generic summary.
111
128
  const findings = errors.map(findingOf);
112
129
  const first = findings[0];
113
130
  throw new CentrsError({
114
131
  code: "validation/syntax",
115
132
  summary: first
116
- ? `RouterOS syntax rejected by offline analysis: ${first.message}`
133
+ ? `${
134
+ REFUSED_WARNING_CODES.has(first.code)
135
+ ? "Refused by offline analysis"
136
+ : "RouterOS syntax rejected by offline analysis"
137
+ }: ${first.message}`
117
138
  : "RouterOS syntax rejected by offline analysis.",
118
139
  remediation:
119
140
  "Run `centrs explain '<command>'` to see the offending bytes in context, " +
@@ -235,10 +235,10 @@ export interface ProtocolAdapter {
235
235
  readonly protocol: RouterOsProtocol;
236
236
  /** Capability flags describing what this adapter supports. */
237
237
  readonly capabilities: ProtocolAdapterCapabilities;
238
- /** `/console/inspect` probe (`request=child` or `request=completion`). */
239
- /** `/console/inspect`; `input` is the CLI-text form (`request=completion input=…`). */
238
+ /** `/console/inspect` discovery, completion, syntax help or byte highlight. */
239
+ /** `input` is CLI text for completion or highlight. */
240
240
  inspect(
241
- request: "child" | "completion",
241
+ request: "child" | "completion" | "highlight" | "syntax",
242
242
  path: string,
243
243
  input?: string,
244
244
  ): Promise<unknown[]>;
@@ -309,7 +309,7 @@ class RestAdapter implements ProtocolAdapter {
309
309
  }
310
310
 
311
311
  async inspect(
312
- request: "child" | "completion",
312
+ request: "child" | "completion" | "highlight" | "syntax",
313
313
  path: string,
314
314
  input?: string,
315
315
  ): Promise<unknown[]> {
@@ -562,7 +562,13 @@ class RestAdapter implements ProtocolAdapter {
562
562
  );
563
563
  const data = parseResponseBody(text);
564
564
  if (!response.ok) {
565
- throw this.mapHttpFailure(response.status, text, data, path);
565
+ throw this.mapHttpFailure(
566
+ response.status,
567
+ text,
568
+ data,
569
+ path,
570
+ response.headers,
571
+ );
566
572
  }
567
573
 
568
574
  return { status: response.status, text, data };
@@ -581,19 +587,54 @@ class RestAdapter implements ProtocolAdapter {
581
587
  text: string,
582
588
  data: unknown,
583
589
  path: string,
590
+ headers: Headers,
584
591
  ): CentrsError {
585
592
  const { protocol, host, port } = this.config;
586
593
  if (status === 401 || status === 403) {
594
+ if (!isRouterOsAuthReply(status, data, headers)) {
595
+ return new CentrsError({
596
+ code: "transport/edge-auth-failed",
597
+ summary: `An HTTP layer in front of RouterOS at ${formatEndpoint(host, port)} refused the request (HTTP ${status}); RouterOS did not answer it.`,
598
+ remediation:
599
+ "An auth proxy, tunnel edge, or WAF wants its own credentials, and centrs sends only RouterOS Basic auth. Point `--host` at an address that reaches RouterOS directly, or at a local proxy that authenticates to that layer for you. Changing the RouterOS username or password will not help.",
600
+ context: {
601
+ via: protocol,
602
+ host,
603
+ port,
604
+ path,
605
+ status,
606
+ ...edgeEvidence(headers),
607
+ },
608
+ causeData: data ?? text,
609
+ });
610
+ }
587
611
  return new CentrsError({
588
612
  code: "transport/auth-failed",
589
613
  summary: `RouterOS rejected the REST credentials for ${formatEndpoint(host, port)}.`,
590
614
  remediation:
591
- "Check `--username` / `--password` or the matching `CENTRS_*` environment variables, and confirm the user has RouterOS REST access.",
615
+ "Check `--username` / `--password` or the matching `CENTRS_*` environment variables. RouterOS answers the same 401 when the user's group lacks the `rest-api` policy or the user's `address` excludes this client.",
592
616
  context: { via: protocol, host, port, path, status },
593
617
  causeData: data ?? text,
594
618
  });
595
619
  }
596
620
 
621
+ const detail = isPlainObject(data)
622
+ ? readRecordString(data, "detail")
623
+ : undefined;
624
+ if (detail !== undefined) {
625
+ // RouterOS reports a policy refusal as HTTP 500 with a `(9)` detail.
626
+ // It is a deterministic router answer, not a dropped connection, so it
627
+ // must not reach the 5xx retry path below.
628
+ const mapped = mapRouterOsError(detail, {
629
+ transport: "rest-api",
630
+ httpStatus: status,
631
+ context: { via: protocol, host, port, path, status },
632
+ });
633
+ if (mapped.code === "routeros/permission-denied") {
634
+ return mapped;
635
+ }
636
+ }
637
+
597
638
  if (status >= 500 && status <= 599) {
598
639
  // A 5xx is a transient server/connection drop (RouterOS REST under
599
640
  // fanout load drops a fraction of parallel POSTs). Classify it as a
@@ -610,9 +651,6 @@ class RestAdapter implements ProtocolAdapter {
610
651
  });
611
652
  }
612
653
 
613
- const detail = isPlainObject(data)
614
- ? readRecordString(data, "detail")
615
- : undefined;
616
654
  const rawRouterOsError = detail ?? text;
617
655
  return mapRouterOsError(rawRouterOsError, {
618
656
  transport: "rest-api",
@@ -750,7 +788,7 @@ class NativeApiAdapter implements ProtocolAdapter {
750
788
  }
751
789
 
752
790
  async inspect(
753
- request: "child" | "completion",
791
+ request: "child" | "completion" | "highlight" | "syntax",
754
792
  path: string,
755
793
  input?: string,
756
794
  ): Promise<unknown[]> {
@@ -1360,6 +1398,46 @@ function collectTransportSignalsInto(
1360
1398
  }
1361
1399
  }
1362
1400
 
1401
+ /**
1402
+ * True when a REST 401/403 reply has RouterOS's own shape. RouterOS answers
1403
+ * every auth refusal (wrong password, no `rest-api` policy, `address`
1404
+ * mismatch) with a JSON body `{"error":401,...}`, adding
1405
+ * `WWW-Authenticate: Basic` only when no credentials were sent. Measured on
1406
+ * CHR 7.12.1, 7.16.2, 7.23.8, 7.25, 7.26beta1 and 7.26beta3. Any other body,
1407
+ * or a challenge for another scheme (Cloudflare Access sends `Bearer`), came
1408
+ * from a layer in front of RouterOS. A `cf-ray` header alone proves nothing:
1409
+ * a tunnel passes RouterOS's own 401 through with it.
1410
+ */
1411
+ function isRouterOsAuthReply(
1412
+ status: number,
1413
+ data: unknown,
1414
+ headers: Headers,
1415
+ ): boolean {
1416
+ const challenge = headers.get("www-authenticate");
1417
+ if (challenge !== null && !/^\s*basic\b/i.test(challenge)) {
1418
+ return false;
1419
+ }
1420
+ return isPlainObject(data) && data["error"] === status;
1421
+ }
1422
+
1423
+ /** Non-secret header facts that show which layer refused a request. */
1424
+ function edgeEvidence(headers: Headers): {
1425
+ challengeScheme?: string;
1426
+ server?: string;
1427
+ contentType?: string;
1428
+ cfRay?: "present";
1429
+ } {
1430
+ const evidence: ReturnType<typeof edgeEvidence> = {};
1431
+ const scheme = headers.get("www-authenticate")?.trim().split(/\s+/)[0];
1432
+ if (scheme) evidence.challengeScheme = scheme;
1433
+ const server = headers.get("server");
1434
+ if (server) evidence.server = server;
1435
+ const contentType = headers.get("content-type");
1436
+ if (contentType) evidence.contentType = contentType;
1437
+ if (headers.has("cf-ray")) evidence.cfRay = "present";
1438
+ return evidence;
1439
+ }
1440
+
1363
1441
  function isPlainObject(value: unknown): value is Record<string, unknown> {
1364
1442
  return typeof value === "object" && value !== null && !Array.isArray(value);
1365
1443
  }
@@ -72,6 +72,7 @@ import {
72
72
  } from "./btest.ts";
73
73
  import {
74
74
  bytesToBigIntBE,
75
+ type EcSrp5Shared,
75
76
  ecSrp5ClientConfirm,
76
77
  ecSrp5ClientShared,
77
78
  ecSrp5Id,
@@ -676,11 +677,13 @@ async function runClientEcSrp5(
676
677
 
677
678
  const challenge = parseServerChallenge(await readAuthFrame(channel));
678
679
  const validator = ecSrp5Id(username, password, challenge.salt);
679
- const shared = ecSrp5ClientShared(
680
- keypair.privateKey,
681
- challenge.serverPublicKey,
682
- keypair.publicKey,
683
- validator,
680
+ const shared = btestShared(
681
+ ecSrp5ClientShared(
682
+ keypair.privateKey,
683
+ challenge.serverPublicKey,
684
+ keypair.publicKey,
685
+ validator,
686
+ ),
684
687
  );
685
688
  const clientCc = ecSrp5ClientConfirm(shared);
686
689
  await channel.write(encodeConfirmation(clientCc));
@@ -782,11 +785,13 @@ async function runServerEcSrp5(
782
785
  );
783
786
  await channel.write(encodeServerChallenge(serverPublicKey, salt));
784
787
 
785
- const shared = ecSrp5ServerShared(
786
- keypair.privateKey,
787
- validatorScalar,
788
- hello.publicKey,
789
- serverPublicKey,
788
+ const shared = btestShared(
789
+ ecSrp5ServerShared(
790
+ keypair.privateKey,
791
+ validatorScalar,
792
+ hello.publicKey,
793
+ serverPublicKey,
794
+ ),
790
795
  );
791
796
  const clientCc = await readAuthFrame(channel);
792
797
  const expectedCc = ecSrp5ClientConfirm(shared);
@@ -799,6 +804,21 @@ async function runServerEcSrp5(
799
804
  return hello.username;
800
805
  }
801
806
 
807
+ /**
808
+ * The shared secret as btest hashes it: `z` without its leading zero bytes
809
+ * (OpenSSL `BN_bn2bin`), where MAC-Telnet's `mtwei.c` pads it to 32
810
+ * (`BN_bn2binpad`), so the shared math in `ec-srp5.ts` keeps 32 bytes.
811
+ *
812
+ * Grounded on CHR 7.24.5 `/tool/bandwidth-server`: over 800 client handshakes,
813
+ * every one whose `z` began with 0x00 (9) failed with the padded form and no
814
+ * other did. Stripped, 600 of 600 verified, 4 of them with a leading zero.
815
+ */
816
+ export function btestShared(shared: EcSrp5Shared): EcSrp5Shared {
817
+ let start = 0;
818
+ while (start < shared.z.length - 1 && shared.z[start] === 0) start++;
819
+ return start === 0 ? shared : { j: shared.j, z: shared.z.subarray(start) };
820
+ }
821
+
802
822
  function ecSrp5RandomSalt(): Uint8Array {
803
823
  return crypto.getRandomValues(new Uint8Array(16));
804
824
  }
@@ -639,6 +639,22 @@ const BARE_BAD_PARAMETER =
639
639
  const BARE_SYNTAX =
640
640
  /^[ \t]*(?:syntax error|bad command name|(?:expected|missing) [a-z]+(?: [a-z]+)*)[^\n]*\(line \d+ column \d+\)/im;
641
641
 
642
+ /**
643
+ * Whether the printed value of `:parse` carries a rejection, by the same
644
+ * position-anchored grammar {@link classifyParseResult} throws on. For probes
645
+ * and censuses that need the verdict as a boolean. On the schema-5 corpus it
646
+ * agrees with the upstream `response_class` on every one of 5,640 channel and
647
+ * legacy observations (#498).
648
+ */
649
+ export function isParseRejection(output: string): boolean {
650
+ return (
651
+ WRAPPED_BAD_PARAMETER.test(output) ||
652
+ BARE_BAD_PARAMETER.test(output) ||
653
+ WRAPPED_BAD_COMMAND.test(output) ||
654
+ BARE_SYNTAX.test(output)
655
+ );
656
+ }
657
+
642
658
  /**
643
659
  * Classify the printed value of `:put [:parse "<cli>"]`. Grounded on CHR 7.23.3
644
660
  * (see GH#230): `:parse` *never* throws — it returns a value whose text carries
@@ -546,6 +546,39 @@ async function egressAddressFor(
546
546
  });
547
547
  }
548
548
 
549
+ /**
550
+ * Pure egress-MAC selection over an injected interface list: the decision
551
+ * {@link resolveEgressMac} makes, without touching the host's NICs, so unit
552
+ * tests can pin it with stubbed interfaces instead of depending on the
553
+ * runner's network setup.
554
+ *
555
+ * A broadcast host owns no route: `connect()` to one reports local
556
+ * `0.0.0.0`, so match it to the interface whose directed broadcast it is
557
+ * instead. Without this, an explicit `--host <broadcast>` falls back to a
558
+ * synthetic source MAC, which RouterOS's mac-server silently ignores
559
+ * (orca 2026-10-26: 30s login timeout vs 7.26beta3 hardware despite
560
+ * mac-server=all and proven L2 adjacency; CHR answers synthetic MACs, so
561
+ * the integration suite stayed green). The limited `255.255.255.255`
562
+ * belongs to no one interface and still falls through to undefined — the
563
+ * default-broadcast path sprays discovery instead. On a tie (two NICs on
564
+ * one subnet sharing a broadcast) the first match wins, which may not be
565
+ * the route's pick; the discovery spray remains the way to probe every NIC.
566
+ */
567
+ export function selectEgressMac(
568
+ host: string,
569
+ interfaces: readonly BroadcastInterface[],
570
+ localAddr: string | undefined,
571
+ ): MacAddress | undefined {
572
+ const owner = interfaces.find((ifc) => ifc.broadcast === host);
573
+ if (owner !== undefined) {
574
+ return owner.mac;
575
+ }
576
+ if (!localAddr || localAddr === "0.0.0.0") {
577
+ return undefined;
578
+ }
579
+ return interfaces.find((ifc) => ifc.address === localAddr)?.mac;
580
+ }
581
+
549
582
  /**
550
583
  * Resolve the **real MAC of the interface that egresses toward `host:port`**, for
551
584
  * use as the in-packet source MAC.
@@ -561,16 +594,11 @@ export async function resolveEgressMac(
561
594
  host: string,
562
595
  port: number,
563
596
  ): Promise<MacAddress | undefined> {
564
- const localAddr = await egressAddressFor(host, port);
565
- if (!localAddr) {
566
- return undefined;
567
- }
568
- for (const ifc of listBroadcastInterfaces()) {
569
- if (ifc.address === localAddr) {
570
- return ifc.mac;
571
- }
572
- }
573
- return undefined;
597
+ return selectEgressMac(
598
+ host,
599
+ listBroadcastInterfaces(),
600
+ await egressAddressFor(host, port),
601
+ );
574
602
  }
575
603
 
576
604
  /** Which interface (source MAC + delivery host) reaches a target over MAC-Telnet. */
@@ -345,19 +345,20 @@ export function readAttribute(
345
345
  /**
346
346
  * Classify a native-API `!trap` as an authentication failure.
347
347
  *
348
- * Auth failures surface on `/login` (bad credentials) or on any command when
349
- * the session is not authorized, so the trap message is matched rather than
350
- * relying on the command word alone. The strings are grounded on live RouterOS
351
- * CHR output: "invalid user name or password", "could not authenticate -
352
- * radius timeout", "not logged in", "access denied". Misclassifying these as a
353
- * syntax/validation error was the reported `execute` bug.
348
+ * Only the login exchange (`/login`) can fail authentication: a trap there
349
+ * means bad credentials (or a dead RADIUS backend), whatever its text —
350
+ * grounded on live RouterOS CHR output ("invalid user name or password",
351
+ * "could not authenticate - radius timeout"). Traps from any later sentence
352
+ * are RouterOS command answers and classify under `routeros/*` regardless of
353
+ * text: matching login-like words on every command misreported `/user/add
354
+ * name=-2` ("invalid user name") as an auth failure though login had succeeded
355
+ * (#540), while REST mapped the same string to `routeros/command-failed`.
354
356
  */
355
- export function isNativeAuthFailure(command: string, message: string): boolean {
356
- return (
357
- /not logged in|access denied|invalid user|could not authenticate|authentication failed|radius (?:timeout|error)|bad password|password|\blogin\b|\buser\b/i.test(
358
- message,
359
- ) || command === "/login"
360
- );
357
+ export function isNativeAuthFailure(
358
+ command: string,
359
+ _message: string,
360
+ ): boolean {
361
+ return command === "/login";
361
362
  }
362
363
 
363
364
  /**
@@ -27,6 +27,7 @@ import {
27
27
  import {
28
28
  buildRetrieveErrorEnvelope,
29
29
  buildRetrieveErrorEnvelopeFromResolved,
30
+ notReachedValidationMeta,
30
31
  type ResolvedRetrieveRequest,
31
32
  type RetrieveFollowCounts,
32
33
  type RetrieveFollowLine,
@@ -176,7 +177,7 @@ export async function* retrieveFollow(
176
177
  if (options.signal?.aborted) {
177
178
  yield interruptedSummary(
178
179
  resolved,
179
- { enabled: resolved.validate.value, result: "skipped" },
180
+ notReachedValidationMeta(resolved, "interrupted"),
180
181
  startedAt,
181
182
  );
182
183
  return;
@@ -16,6 +16,7 @@ import {
16
16
  buildRetrieveErrorEnvelope,
17
17
  buildRetrieveErrorEnvelopeFromResolved,
18
18
  executeRetrieve,
19
+ notReachedValidationMeta,
19
20
  type ResolvedRetrieveRequest,
20
21
  type RetrieveRequest,
21
22
  type RetrieveSampleStopReason,
@@ -85,7 +86,7 @@ export async function* retrieveSample(
85
86
  if (options.signal?.aborted) {
86
87
  yield summaryEnvelope(
87
88
  resolved,
88
- { enabled: resolved.validate.value, result: "skipped" },
89
+ notReachedValidationMeta(resolved, "interrupted"),
89
90
  { stopReason: "interrupted", samples: 0 },
90
91
  startedAt,
91
92
  );
@@ -1,4 +1,5 @@
1
1
  /** Bounded readiness/condition reads; the router evaluates the shared predicate. */
2
+ import type { EnvelopeValidationMeta } from "./core/envelope.ts";
2
3
  import { parseQueries, queryErrorForFlag } from "./core/query.ts";
3
4
  import { CentrsError } from "./errors.ts";
4
5
  import {
@@ -17,6 +18,7 @@ import {
17
18
  type RetrieveRequest,
18
19
  type RetrieveWaitMeta,
19
20
  resolveRetrieveRequest,
21
+ stoppedBeforeGateAnswer,
20
22
  validateRetrieveRead,
21
23
  } from "./retrieve.ts";
22
24
 
@@ -72,6 +74,9 @@ export async function retrieveWait(
72
74
  let observations = 0;
73
75
  let backend: ProtocolAdapter | undefined;
74
76
  let prepared: Awaited<ReturnType<typeof validateRetrieveRead>> | undefined;
77
+ // A retry drops `prepared` with its backend, but a gate that passed once
78
+ // stays passed: a later failure that is not a gate answer keeps it (#537).
79
+ let settled: EnvelopeValidationMeta | undefined;
75
80
  let lastError: unknown;
76
81
  const meta = (
77
82
  stopReason: RetrieveWaitMeta["stopReason"],
@@ -105,12 +110,10 @@ export async function retrieveWait(
105
110
  )
106
111
  error = queryErrorForFlag(error, "--until");
107
112
  const envelope = buildRetrieveErrorEnvelopeFromResolved(resolved, error);
108
- if (prepared) envelope.meta.validation = prepared.validation;
109
- else if (attempts === 0)
110
- envelope.meta.validation = {
111
- enabled: resolved.validate.value,
112
- result: "skipped",
113
- };
113
+ const validation =
114
+ prepared?.validation ??
115
+ (stoppedBeforeGateAnswer(envelope.error.code) ? settled : undefined);
116
+ if (validation) envelope.meta.validation = validation;
114
117
  if (envelope.meta.operation) envelope.meta.operation.wait = meta(reason);
115
118
  return envelope;
116
119
  };
@@ -151,6 +154,7 @@ export async function retrieveWait(
151
154
  signal: controller.signal,
152
155
  });
153
156
  prepared ??= await validateRetrieveRead(observationRequest, backend);
157
+ settled = prepared.validation;
154
158
  if (
155
159
  settings.untilEmpty &&
156
160
  (prepared.inspection?.singleton ??