@tikoci/centrs 0.1.8 → 0.1.9-next.22

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 (80) hide show
  1. package/CHANGELOG.md +216 -31
  2. package/GLOSSARY.txt +8 -0
  3. package/README.md +16 -8
  4. package/SECURITY.md +11 -3
  5. package/commands/api/README.md +44 -15
  6. package/commands/api/examples.md +44 -6
  7. package/commands/execute/README.md +218 -7
  8. package/commands/execute/examples.md +278 -1
  9. package/commands/explain/README.md +2 -1
  10. package/commands/mcp/README.md +11 -0
  11. package/commands/mcp/examples.md +6 -0
  12. package/commands/retrieve/README.md +32 -14
  13. package/commands/retrieve/examples.md +10 -6
  14. package/commands/transfer/examples.md +9 -0
  15. package/docs/CLI.md +10 -8
  16. package/docs/CONSTITUTION.md +54 -3
  17. package/docs/MATRIX.md +20 -3
  18. package/docs/errors/cdb/empty-group.md +19 -3
  19. package/docs/errors/cdb/empty-selection.md +9 -3
  20. package/docs/errors/cmr/caller-policy-not-enforced.md +46 -0
  21. package/docs/errors/cmr/cancelled.md +15 -0
  22. package/docs/errors/cmr/client-disconnected.md +19 -0
  23. package/docs/errors/cmr/client-script-not-validated.md +22 -0
  24. package/docs/errors/cmr/console-only.md +29 -0
  25. package/docs/errors/cmr/empty-selection.md +33 -0
  26. package/docs/errors/cmr/membership-changed.md +20 -0
  27. package/docs/errors/cmr/not-a-controller.md +26 -0
  28. package/docs/errors/cmr/outcome-unknown.md +29 -0
  29. package/docs/errors/cmr/output-truncated.md +16 -0
  30. package/docs/errors/cmr/policy-denied.md +31 -0
  31. package/docs/errors/cmr/rule-selects-every-device.md +32 -0
  32. package/docs/errors/cmr/selection-includes-controller.md +27 -0
  33. package/docs/errors/cmr/selection-incomplete.md +18 -0
  34. package/docs/errors/cmr/validation-incomplete.md +23 -0
  35. package/docs/errors/routeros/version-unsupported.md +4 -0
  36. package/docs/errors/transport/write-outcome-unknown.md +24 -0
  37. package/docs/errors/usage/flag-after-separator.md +34 -0
  38. package/docs/errors/usage/missing-labels.md +19 -0
  39. package/package.json +7 -5
  40. package/src/api-fanout.ts +18 -2
  41. package/src/api.ts +230 -71
  42. package/src/btest.ts +7 -2
  43. package/src/cli/api.ts +30 -16
  44. package/src/cli/common.ts +180 -1
  45. package/src/cli/devices.ts +0 -2
  46. package/src/cli/discover.ts +0 -1
  47. package/src/cli/execute.ts +176 -21
  48. package/src/cli/explain.ts +2 -0
  49. package/src/cli/mcp.ts +0 -1
  50. package/src/cli/retrieve.ts +40 -18
  51. package/src/cli/settings.ts +0 -3
  52. package/src/cli/terminal.ts +0 -2
  53. package/src/cli/transfer.ts +10 -8
  54. package/src/cli/version.ts +87 -0
  55. package/src/cli.ts +5 -3
  56. package/src/core/cmr-fleet.ts +486 -0
  57. package/src/core/cmr.ts +182 -0
  58. package/src/core/envelope.ts +46 -1
  59. package/src/core/error-catalog.ts +95 -2
  60. package/src/core/execute-command.ts +144 -19
  61. package/src/core/fanout.ts +207 -14
  62. package/src/errors.ts +3 -1
  63. package/src/execute-cmr.ts +1442 -0
  64. package/src/execute-fanout.ts +15 -1
  65. package/src/execute.ts +173 -40
  66. package/src/index.ts +35 -1
  67. package/src/mcp/safety.ts +11 -0
  68. package/src/mcp/tools.ts +4 -1
  69. package/src/protocols/adapter.ts +54 -12
  70. package/src/resolver/cdb.ts +13 -1
  71. package/src/resolver/index.ts +4 -0
  72. package/src/resolver/selection.ts +37 -1
  73. package/src/resolver/target.ts +96 -8
  74. package/src/retrieve-fanout.ts +4 -1
  75. package/src/retrieve-follow.ts +29 -33
  76. package/src/retrieve-sample.ts +14 -8
  77. package/src/retrieve-stream.ts +38 -6
  78. package/src/retrieve.ts +73 -59
  79. package/src/transfer-fanout.ts +8 -1
  80. package/src/transfer.ts +15 -19
@@ -180,6 +180,16 @@ export interface ProtocolApiResult {
180
180
  data: unknown;
181
181
  }
182
182
 
183
+ /**
184
+ * Options for a single `execute`/`apiRequest` call. `onSent` fires once the
185
+ * command itself is about to leave for the router: after a native or console
186
+ * login, never during it. A failure before it fired cannot have run the
187
+ * command (#448).
188
+ */
189
+ export interface ProtocolSendOptions {
190
+ onSent?: () => void;
191
+ }
192
+
183
193
  /** Options for incremental native command replies. */
184
194
  export interface ProtocolListenOptions {
185
195
  /** Abort the listen (sends `/cancel`); the generator then ends cleanly. */
@@ -237,9 +247,15 @@ export interface ProtocolAdapter {
237
247
  /** Read a menu as an array of records, optionally projected/detailed. */
238
248
  list(path: string, options: RetrieveListOptions): Promise<unknown[]>;
239
249
  /** Run a CLI-shaped command (WP-1c write surface). */
240
- execute(request: ProtocolExecuteRequest): Promise<ProtocolExecuteResult>;
250
+ execute(
251
+ request: ProtocolExecuteRequest,
252
+ options?: ProtocolSendOptions,
253
+ ): Promise<ProtocolExecuteResult>;
241
254
  /** Run a normalized structured `api` request (the gh-api passthrough surface). */
242
- apiRequest(request: ProtocolApiRequest): Promise<ProtocolApiResult>;
255
+ apiRequest(
256
+ request: ProtocolApiRequest,
257
+ options?: ProtocolSendOptions,
258
+ ): Promise<ProtocolApiResult>;
243
259
  /**
244
260
  * Incremental native replies to the literal command (the same mapping as
245
261
  * `apiRequest`; never rewritten into a `listen`). Yields each `!re` and
@@ -341,7 +357,10 @@ class RestAdapter implements ProtocolAdapter {
341
357
 
342
358
  async execute(
343
359
  request: ProtocolExecuteRequest,
360
+ options?: ProtocolSendOptions,
344
361
  ): Promise<ProtocolExecuteResult> {
362
+ // REST has no session to open first: the request is the command.
363
+ options?.onSent?.();
345
364
  if (request.script !== undefined) {
346
365
  const data = await this.restPost<unknown>("/execute", {
347
366
  script: request.script,
@@ -369,7 +388,11 @@ class RestAdapter implements ProtocolAdapter {
369
388
  return normalizeRestExecute(data);
370
389
  }
371
390
 
372
- async apiRequest(request: ProtocolApiRequest): Promise<ProtocolApiResult> {
391
+ async apiRequest(
392
+ request: ProtocolApiRequest,
393
+ options?: ProtocolSendOptions,
394
+ ): Promise<ProtocolApiResult> {
395
+ options?.onSent?.();
373
396
  const base = request.path.replace(/\/$/, "");
374
397
  switch (request.verb) {
375
398
  case "print": {
@@ -765,9 +788,12 @@ class NativeApiAdapter implements ProtocolAdapter {
765
788
 
766
789
  async execute(
767
790
  request: ProtocolExecuteRequest,
791
+ options?: ProtocolSendOptions,
768
792
  ): Promise<ProtocolExecuteResult> {
769
793
  if (request.script !== undefined) {
770
- return normalizeNativeExecute(await this.executeScript(request.script));
794
+ return normalizeNativeExecute(
795
+ await this.executeScript(request.script, options),
796
+ );
771
797
  }
772
798
  const command: NativeApiCommand = {
773
799
  command: `${request.path.replace(/\/$/, "")}/${request.command}`,
@@ -781,7 +807,7 @@ class NativeApiAdapter implements ProtocolAdapter {
781
807
  if (request.binary) {
782
808
  command.binary = true;
783
809
  }
784
- const replies = await this.talk(command);
810
+ const replies = await this.talk(command, options);
785
811
  return { records: repliesToRecords(replies) };
786
812
  }
787
813
 
@@ -794,17 +820,24 @@ class NativeApiAdapter implements ProtocolAdapter {
794
820
  */
795
821
  private async executeScript(
796
822
  script: string,
823
+ options?: ProtocolSendOptions,
797
824
  ): Promise<Record<string, string>[]> {
798
- const replies = await this.talk({
799
- command: "/execute",
800
- attributes: { script, "as-string": "" },
801
- });
825
+ const replies = await this.talk(
826
+ {
827
+ command: "/execute",
828
+ attributes: { script, "as-string": "" },
829
+ },
830
+ options,
831
+ );
802
832
  return repliesToRecords(replies);
803
833
  }
804
834
 
805
- async apiRequest(request: ProtocolApiRequest): Promise<ProtocolApiResult> {
835
+ async apiRequest(
836
+ request: ProtocolApiRequest,
837
+ options?: ProtocolSendOptions,
838
+ ): Promise<ProtocolApiResult> {
806
839
  const records = repliesToRecords(
807
- await this.talk(nativeCommandFor(request)),
840
+ await this.talk(nativeCommandFor(request), options),
808
841
  );
809
842
  switch (request.verb) {
810
843
  case "print":
@@ -880,8 +913,13 @@ class NativeApiAdapter implements ProtocolAdapter {
880
913
  return session;
881
914
  }
882
915
 
883
- private async talk(command: NativeApiCommand): Promise<ApiReply[]> {
916
+ private async talk(
917
+ command: NativeApiCommand,
918
+ options?: ProtocolSendOptions,
919
+ ): Promise<ApiReply[]> {
920
+ // Connecting logs in lazily; only the sentence after `/login` is the command.
884
921
  const session = await this.connect();
922
+ options?.onSent?.();
885
923
  return this.withTimeout(session.talk(command));
886
924
  }
887
925
 
@@ -982,6 +1020,7 @@ class MacTelnetAdapter implements ProtocolAdapter {
982
1020
 
983
1021
  async execute(
984
1022
  request: ProtocolExecuteRequest,
1023
+ options?: ProtocolSendOptions,
985
1024
  ): Promise<ProtocolExecuteResult> {
986
1025
  const cli = request.script;
987
1026
  if (cli === undefined) {
@@ -994,6 +1033,7 @@ class MacTelnetAdapter implements ProtocolAdapter {
994
1033
  });
995
1034
  }
996
1035
  const console = await this.open();
1036
+ options?.onSent?.();
997
1037
  const { output } = await console.run(cli);
998
1038
  return { records: [], ret: output };
999
1039
  }
@@ -1134,6 +1174,7 @@ class SshExecAdapter implements ProtocolAdapter {
1134
1174
 
1135
1175
  async execute(
1136
1176
  request: ProtocolExecuteRequest,
1177
+ options?: ProtocolSendOptions,
1137
1178
  ): Promise<ProtocolExecuteResult> {
1138
1179
  const cli = request.script;
1139
1180
  if (cli === undefined) {
@@ -1145,6 +1186,7 @@ class SshExecAdapter implements ProtocolAdapter {
1145
1186
  "Report this bug; runCommand must pass the raw command as `script` for ssh.",
1146
1187
  });
1147
1188
  }
1189
+ options?.onSent?.();
1148
1190
  const output = await this.client.exec(cli);
1149
1191
  return { records: [], ret: output };
1150
1192
  }
@@ -44,6 +44,11 @@ import {
44
44
  parseDuration,
45
45
  type ResolvedSetting,
46
46
  } from "./settings.ts";
47
+ import {
48
+ assertNoTargetCredentials,
49
+ assertNoTargetPassword,
50
+ redactTargetInput,
51
+ } from "./target.ts";
47
52
 
48
53
  const ENV_CDB_FILE = "CENTRS_CDB_FILE";
49
54
  const ENV_CDB_PASSWORD = "CENTRS_CDB_PASSWORD";
@@ -161,6 +166,8 @@ export async function resolveCdb(
161
166
  if (!input.targetInput) {
162
167
  return undefined;
163
168
  }
169
+ // Before any lookup: a CDB miss echoes the target it was given (#478).
170
+ assertNoTargetPassword(input.targetInput);
164
171
 
165
172
  const settings = resolveDevicesSettings({
166
173
  cdbFile: input.cdbFile,
@@ -225,6 +232,10 @@ export async function resolveCdb(
225
232
  ...(defaults ? { defaults } : {}),
226
233
  };
227
234
  } catch (error) {
235
+ // Preserve registered identities; mask credential-shaped lookup failures.
236
+ if (redactTargetInput(input.targetInput) !== input.targetInput) {
237
+ assertNoTargetCredentials(input.targetInput);
238
+ }
228
239
  if (error instanceof CentrsError && error.code === "cdb/not-found-target") {
229
240
  // A `__default__` record supplies fallback credentials even when the
230
241
  // target has no record of its own. On the CLI/API this fills creds for
@@ -366,7 +377,8 @@ export function resolutionFromEntry(
366
377
  * ambiguity path). See `commands/devices/README.md` (Identity model).
367
378
  */
368
379
  export function identityFromComment(comment: string, target: string): string {
369
- return parseCommentKv(comment).lookups.identity ?? target;
380
+ // The record's target is shown as its handle; never with a password (#478).
381
+ return parseCommentKv(comment).lookups.identity ?? redactTargetInput(target);
370
382
  }
371
383
 
372
384
  /**
@@ -97,6 +97,7 @@ export {
97
97
  SUPPORTED_DESCRIPTOR_VERSION,
98
98
  } from "./quickchr-provider.ts";
99
99
  export {
100
+ emptyWriteSelectionError,
100
101
  expandSelection,
101
102
  isDefaultRecordTarget,
102
103
  type SelectionExpansion,
@@ -130,10 +131,13 @@ export {
130
131
  toCoreSource,
131
132
  } from "./settings.ts";
132
133
  export {
134
+ assertNoTargetCredentials,
135
+ assertNoTargetPassword,
133
136
  defaultPortForScheme,
134
137
  parseHostCandidate,
135
138
  type ResolvedAuth,
136
139
  type ResolvedTarget,
140
+ redactTargetInput,
137
141
  resolveAuth,
138
142
  resolveTarget,
139
143
  type TargetResolveInput,
@@ -47,6 +47,11 @@ import {
47
47
  matchesNear,
48
48
  type NearPredicate,
49
49
  } from "./geo.ts";
50
+ import {
51
+ assertNoTargetCredentials,
52
+ assertNoTargetPassword,
53
+ redactTargetInput,
54
+ } from "./target.ts";
50
55
 
51
56
  /** One `--where attr=value` device-class clause (exact-match, AND-combined). */
52
57
  export interface SelectionWhereClause {
@@ -106,10 +111,36 @@ export interface SelectionExpansion {
106
111
  /** Members ordered by record index; literals appended in positional order. */
107
112
  targets: readonly SelectionMember[];
108
113
  warnings: readonly ResolverWarning[];
109
- /** True when nothing matched (an empty/unknown selection is not an error). */
114
+ /** True when nothing matched (an error only for a write; see {@link emptyWriteSelectionError}). */
110
115
  empty: boolean;
111
116
  }
112
117
 
118
+ /**
119
+ * An empty selection for a write-shaped fan-out (#460). A read over it is
120
+ * `ok: true` with the expansion's warning, so a poller over an occasionally
121
+ * empty group stays quiet. A write is an error, as RouterOS refuses an empty
122
+ * CMR selection (`no devices selected`): a misspelled group must not look like
123
+ * a successful bulk write. The code is the expansion's own empty warning.
124
+ */
125
+ export function emptyWriteSelectionError(
126
+ expansion: Pick<SelectionExpansion, "warnings">,
127
+ action: string,
128
+ ): CentrsError {
129
+ const warning = expansion.warnings.findLast(
130
+ (w) => w.code === "cdb/empty-group" || w.code === "cdb/empty-selection",
131
+ );
132
+ return new CentrsError({
133
+ code:
134
+ warning?.code === "cdb/empty-group"
135
+ ? "cdb/empty-group"
136
+ : "cdb/empty-selection",
137
+ summary: `${warning?.message ?? "The target selection matched no CDB records."} Nothing ran: a write-shaped ${action} must select at least one router.`,
138
+ remediation:
139
+ "Check the selector against `centrs devices list` and `centrs devices groups` (groups and `--where` values match as written), then re-run. A read over the same selection succeeds with a warning instead.",
140
+ context: { ...warning?.context, write: true },
141
+ });
142
+ }
143
+
113
144
  /** True when a resolved target is the reserved credential-fallback record. */
114
145
  export function isDefaultRecordTarget(target: string): boolean {
115
146
  return target === DEFAULT_RECORD_TARGET;
@@ -249,10 +280,15 @@ export async function expandSelection(
249
280
  const literals: SelectionMember[] = [];
250
281
  const seenLiteral = new Set<string>();
251
282
  for (const positional of selection.positionals) {
283
+ assertNoTargetPassword(positional);
252
284
  try {
253
285
  const shown = showDevice({ cdb, target: positional });
254
286
  indices.add(shown.data.entry.cdbRecordIndex);
255
287
  } catch (error) {
288
+ // Preserve registered identities; mask credential-shaped lookup failures.
289
+ if (redactTargetInput(positional) !== positional) {
290
+ assertNoTargetCredentials(positional);
291
+ }
256
292
  if (
257
293
  error instanceof CentrsError &&
258
294
  error.code === "cdb/not-found-target"
@@ -104,11 +104,12 @@ export function resolveTarget(
104
104
  summary: "No host could be resolved for `retrieve`.",
105
105
  remediation:
106
106
  "Pass a target positional like `centrs retrieve 192.0.2.10 /system/resource --via rest-api` or set `--host` / `CENTRS_HOST`.",
107
- context: { targetInput: input.targetInput },
107
+ context: { targetInput: redactTargetInput(input.targetInput) },
108
108
  });
109
109
  }
110
110
 
111
111
  const rawCandidate = hostSetting.value.trim();
112
+ assertNoTargetCredentials(rawCandidate);
112
113
  const macCandidate = normalizeMac(rawCandidate);
113
114
 
114
115
  // L2 transports (mac-telnet) address the device by **MAC** directly — no IP
@@ -127,11 +128,13 @@ export function resolveTarget(
127
128
  if (!mac) {
128
129
  throw new CentrsError({
129
130
  code: "target/mac-required",
130
- summary: `mac-telnet needs a MAC address target; got "${input.targetInput ?? cdb?.target ?? rawCandidate}".`,
131
+ summary: `mac-telnet needs a MAC address target; got "${redactTargetInput(input.targetInput ?? cdb?.target ?? rawCandidate)}".`,
131
132
  remediation:
132
133
  "Pass the device MAC (aa:bb:cc:dd:ee:ff), or pin an IP transport with `--via native-api`/`rest-api` (add `--resolve arp` for a MAC).",
133
134
  context: {
134
- target: input.targetInput ?? cdb?.target ?? rawCandidate,
135
+ target: redactTargetInput(
136
+ input.targetInput ?? cdb?.target ?? rawCandidate,
137
+ ),
135
138
  via,
136
139
  },
137
140
  });
@@ -178,7 +181,7 @@ export function resolveTarget(
178
181
  const deliveryHost = deliveryHostSetting?.value.trim() || "255.255.255.255";
179
182
  const port = macPortSetting?.value ?? MAC_TELNET_PORT;
180
183
  return {
181
- input: input.targetInput,
184
+ input: redactTargetInput(input.targetInput),
182
185
  host: deliveryHost,
183
186
  mac,
184
187
  port,
@@ -290,7 +293,7 @@ export function resolveTarget(
290
293
  };
291
294
 
292
295
  return {
293
- input: input.targetInput,
296
+ input: redactTargetInput(input.targetInput),
294
297
  // WHATWG keeps an IPv6 hostname bracketed; sockets and ssh need the bare
295
298
  // address (`baseUrl` re-brackets it).
296
299
  host: parsedUrl.hostname.replace(/^\[(.*)\]$/, "$1"),
@@ -471,6 +474,91 @@ export function resolveAuth(
471
474
  };
472
475
  }
473
476
 
477
+ /** An authority with userinfo: `user@host`, `user:pass@host`, with or without a scheme. */
478
+ const URL_USERINFO = /^\s*([a-z][a-z0-9+.-]*:\/\/)?[^/@\s]*@/i;
479
+ /** Leading whitespace and an optional `scheme://`, kept by redaction. */
480
+ const TARGET_PREFIX = /^\s*(?:[a-z][a-z0-9+.-]*:\/\/)?/i;
481
+
482
+ /**
483
+ * Where credentials could sit: a `:` before the LAST `@`, or explicit URL
484
+ * userinfo. Display also masks a percent-encoded colon; lookup validation
485
+ * allows that ambiguous spelling until CDB lookup has run. Bare `user@host`
486
+ * remains a CDB identity candidate. Index arithmetic, so a password holding `/`, `?` or
487
+ * `#` (which ends a URL authority early, CodeRabbit on PR #479) still counts,
488
+ * and nothing can backtrack (CodeQL js/polynomial-redos).
489
+ */
490
+ function targetCredentialsSpan(
491
+ value: string,
492
+ includeEncodedColon = true,
493
+ ): { start: number; at: number } | undefined {
494
+ const at = value.lastIndexOf("@");
495
+ if (at < 0) return undefined;
496
+ const prefix = TARGET_PREFIX.exec(value)?.[0] ?? "";
497
+ const start = prefix.length;
498
+ const urlUserinfo = prefix.includes("://") && URL_USERINFO.test(value);
499
+ const userinfo = value.slice(start, at);
500
+ return userinfo.includes(":") ||
501
+ (includeEncodedColon && /%3a/i.test(userinfo)) ||
502
+ urlUserinfo
503
+ ? { start, at }
504
+ : undefined;
505
+ }
506
+
507
+ /**
508
+ * A target string with any `user:pass@` or explicit URL userinfo replaced by
509
+ * `***@`, for display (#478).
510
+ * A target is echoed in `meta.target.input` and in error messages, so the
511
+ * password part must never reach them, not even when the target is refused.
512
+ * Everything up to the last `@` goes, so an odd password cannot slip past.
513
+ * Other targets, including a CDB identity like `rtr@site`, pass unchanged.
514
+ * An encoded colon is masked conservatively without changing lookup input.
515
+ */
516
+ export function redactTargetInput(value: string): string;
517
+ export function redactTargetInput(
518
+ value: string | undefined,
519
+ ): string | undefined;
520
+ export function redactTargetInput(
521
+ value: string | undefined,
522
+ ): string | undefined {
523
+ if (value === undefined) return value;
524
+ const span = targetCredentialsSpan(value);
525
+ return span
526
+ ? `${value.slice(0, span.start)}***${value.slice(span.at)}`
527
+ : value;
528
+ }
529
+
530
+ function targetCredentialsError(value: string): CentrsError {
531
+ const target = redactTargetInput(value);
532
+ return new CentrsError({
533
+ code: "input/invalid-target",
534
+ summary: `A target must not carry credentials (\`user[:password]@\`): ${target}`,
535
+ remediation:
536
+ "Drop the `user:password@` part and pass the credentials with `--username` and `--password` (or `CENTRS_USERNAME`/`CENTRS_PASSWORD`, or a CDB record); give the target as `host[:port]` or `http(s)://host[:port]`.",
537
+ context: { target },
538
+ });
539
+ }
540
+
541
+ /**
542
+ * Refuse a target that may hold a password, BEFORE any lookup whose miss would
543
+ * echo it (#478): `resolveCdb`, the MCP allowlist, the fan-out selection. A
544
+ * bare `user@host` is left to the lookup; a CDB identity may contain `@`.
545
+ * Explicit URL userinfo is always refused, including percent-encoded forms.
546
+ */
547
+ export function assertNoTargetPassword(value: string): void {
548
+ if (targetCredentialsSpan(value, false)) throw targetCredentialsError(value);
549
+ }
550
+
551
+ /**
552
+ * centrs never reads credentials from a target (#478): `user:pass@host` used
553
+ * to be accepted with its credentials ignored, and echoed the password back.
554
+ * After lookup, refuse any userinfo in the host to dial, from any source.
555
+ */
556
+ export function assertNoTargetCredentials(value: string): void {
557
+ if (targetCredentialsSpan(value) || URL_USERINFO.test(value)) {
558
+ throw targetCredentialsError(value);
559
+ }
560
+ }
561
+
474
562
  export function parseHostCandidate(value: string): URL {
475
563
  const hasScheme = /^https?:\/\//i.test(value);
476
564
  // A bare IPv6 literal (`2001:db8::1`) has no port; URL needs it bracketed.
@@ -484,7 +572,7 @@ export function parseHostCandidate(value: string): URL {
484
572
  if (hasScheme && url.pathname !== "/" && url.pathname !== "") {
485
573
  throw new CentrsError({
486
574
  code: "input/invalid-target",
487
- summary: `Target URL must not include a path. Received: ${value}`,
575
+ summary: `Target URL must not include a path. Received: ${redactTargetInput(value)}`,
488
576
  remediation:
489
577
  "Pass only the RouterOS host or base URL, then provide the RouterOS menu path as the second positional argument.",
490
578
  });
@@ -500,12 +588,12 @@ function invalidTargetError(value: string, cause: unknown): CentrsError {
500
588
  const quickchrGuess = /^quickchr:(.+)$/i.exec(value)?.[1];
501
589
  return new CentrsError({
502
590
  code: "input/invalid-target",
503
- summary: `Target is not a known device or a valid host: ${value}`,
591
+ summary: `Target is not a known device or a valid host: ${redactTargetInput(value)}`,
504
592
  remediation:
505
593
  quickchrGuess !== undefined
506
594
  ? `Select a quickchr VM with the flag: \`--quickchr ${quickchrGuess}\` (there is no \`quickchr:\` target prefix).`
507
595
  : "Pass a CDB target or identity (see `centrs devices list`), an IP or hostname with optional `:port`, or an `http(s)://host[:port]` base URL.",
508
- context: { target: value },
596
+ context: { target: redactTargetInput(value) },
509
597
  cause,
510
598
  });
511
599
  }
@@ -59,6 +59,7 @@ import {
59
59
  expandSelection,
60
60
  isDefaultRecordTarget,
61
61
  loadEnvFileDefaults,
62
+ redactTargetInput,
62
63
  type SelectionExpansion,
63
64
  type SelectionMember,
64
65
  type SelectionResolveInput,
@@ -97,6 +98,8 @@ export interface RetrieveFanoutOperationMeta {
97
98
  concurrency: number;
98
99
  summary: FanoutSummary;
99
100
  request: RetrieveRequestSummary;
101
+ /** Hoisted from the targets when every target used the same login (#461). */
102
+ auth?: { username?: string; passwordProvided: boolean };
100
103
  selection: RetrieveSelectionSummary;
101
104
  }
102
105
 
@@ -143,7 +146,7 @@ function summarizeSelection(
143
146
  where: selection.where.map((clause) => `${clause.key}=${clause.value}`),
144
147
  all: selection.all,
145
148
  default: selection.default,
146
- positionals: selection.positionals,
149
+ positionals: selection.positionals.map((value) => redactTargetInput(value)),
147
150
  quickchr: selection.quickchr,
148
151
  };
149
152
  }
@@ -28,17 +28,22 @@ import {
28
28
  buildRetrieveErrorEnvelope,
29
29
  buildRetrieveErrorEnvelopeFromResolved,
30
30
  type ResolvedRetrieveRequest,
31
- type RetrieveEnvelope,
32
31
  type RetrieveFollowCounts,
32
+ type RetrieveFollowLine,
33
33
  type RetrieveFollowStopReason,
34
34
  type RetrieveFollowSummary,
35
35
  type RetrieveRequest,
36
+ type RetrieveStreamLineEnvelope,
36
37
  type RetrieveStreamMeta,
37
38
  type RetrieveSuccessEnvelope,
38
39
  resolveRetrieveRequest,
39
40
  validateRetrieveRead,
40
41
  } from "./retrieve.ts";
41
- import { failedStreamEnvelope, streamEnvelope } from "./retrieve-stream.ts";
42
+ import {
43
+ failedStreamEnvelope,
44
+ streamEnvelope,
45
+ streamLineEnvelope,
46
+ } from "./retrieve-stream.ts";
42
47
 
43
48
  /** Changes allowed to wait unread before the follow ends (`transport/stream-overflow`). */
44
49
  export const FOLLOW_BUFFER_LIMIT = 50_000;
@@ -52,7 +57,8 @@ export interface RetrieveFollowOptions {
52
57
  bufferLimit?: number;
53
58
  }
54
59
 
55
- type FollowEvent =
60
+ /** `at`: when the reply arrived (host clock), stamped by `push` in the wire tap. */
61
+ type FollowEvent = { at?: number } & (
56
62
  | { type: "listen"; reply: ProtocolTappedReply; version?: number }
57
63
  | { type: "listen-end" }
58
64
  | { type: "snapshot-row"; row: Record<string, string> }
@@ -65,7 +71,8 @@ type FollowEvent =
65
71
  rows: Map<string, Record<string, string>>;
66
72
  }
67
73
  | { type: "stop" }
68
- | { type: "error"; error: unknown };
74
+ | { type: "error"; error: unknown }
75
+ );
69
76
 
70
77
  /**
71
78
  * The wire-ordered event queue: every source feeds it from a synchronous
@@ -144,8 +151,8 @@ class FollowQueue {
144
151
  type FrameMeta = Extract<RetrieveStreamMeta, { kind: "frame" }>;
145
152
 
146
153
  /**
147
- * Follow one menu: yields an optional `notice`, the snapshot frames, `synced`,
148
- * live frames, then exactly one summary (successful, or an error envelope
154
+ * Follow one menu: yields `started` (the full meta and any advice), the
155
+ * snapshot frames, `synced`, live frames, then exactly one summary (successful, or an error envelope
149
156
  * carrying the partial counts). Errors before the follow starts (resolution,
150
157
  * validation, a non-followable menu, a non-native transport) yield one error
151
158
  * envelope without a summary.
@@ -154,7 +161,7 @@ export async function* retrieveFollow(
154
161
  request: RetrieveRequest,
155
162
  env: Record<string, string | undefined> = Bun.env,
156
163
  options: RetrieveFollowOptions = {},
157
- ): AsyncGenerator<RetrieveEnvelope, void, void> {
164
+ ): AsyncGenerator<RetrieveFollowLine, void, void> {
158
165
  const followRequest: RetrieveRequest = { ...request, follow: true };
159
166
  let resolved: ResolvedRetrieveRequest;
160
167
  try {
@@ -269,7 +276,7 @@ async function* runFollow(
269
276
  options: RetrieveFollowOptions,
270
277
  startedAt: number,
271
278
  query?: readonly string[],
272
- ): AsyncGenerator<RetrieveEnvelope, void, void> {
279
+ ): AsyncGenerator<RetrieveFollowLine, void, void> {
273
280
  const follow = resolved.follow ?? { sweepMs: 0 };
274
281
  const bufferLimit = options.bufferLimit ?? FOLLOW_BUFFER_LIMIT;
275
282
  const counts: RetrieveFollowCounts = {
@@ -322,7 +329,7 @@ async function* runFollow(
322
329
  controller.abort();
323
330
  return;
324
331
  }
325
- queue.push(event);
332
+ queue.push({ ...event, at: Date.now() });
326
333
  };
327
334
  const stop = (reason: RetrieveFollowStopReason): void => {
328
335
  stopReason ??= reason;
@@ -529,14 +536,16 @@ async function* runFollow(
529
536
  id: string,
530
537
  source: FrameMeta["source"],
531
538
  row: Record<string, string> | null,
532
- ): RetrieveSuccessEnvelope => {
539
+ at: number | undefined,
540
+ ): RetrieveStreamLineEnvelope => {
533
541
  counts.frames += 1;
534
542
  if (phase === "snapshot") counts.snapshot += 1;
535
543
  else counts.changes += 1;
536
544
  const data = row === null ? null : projectRow(row, resolved.attributes);
537
- return streamEnvelope(resolved, validation, data, {
545
+ return streamLineEnvelope(data, {
538
546
  kind: "frame",
539
547
  index: counts.frames,
548
+ observedAt: new Date(at ?? Date.now()).toISOString(),
540
549
  phase,
541
550
  change,
542
551
  id,
@@ -547,7 +556,7 @@ async function* runFollow(
547
556
  const applyListen = (
548
557
  event: Extract<FollowEvent, { type: "listen" }>,
549
558
  phase: FrameMeta["phase"],
550
- ): RetrieveSuccessEnvelope | undefined => {
559
+ ): RetrieveStreamLineEnvelope | undefined => {
551
560
  const { reply } = event;
552
561
  // `!empty` on a listen is the cancel acknowledgement, never a table state.
553
562
  if (reply.type === "empty") return undefined;
@@ -568,10 +577,10 @@ async function* runFollow(
568
577
  if (reply.attributes[".dead"] === "true") {
569
578
  // A `.dead` for an id never reported is a coalesced add+delete.
570
579
  if (!held.delete(id)) return undefined;
571
- return frame(phase, "removed", id, "listen", null);
580
+ return frame(phase, "removed", id, "listen", null, event.at);
572
581
  }
573
582
  held.add(id);
574
- return frame(phase, "upsert", id, "listen", reply.attributes);
583
+ return frame(phase, "upsert", id, "listen", reply.attributes, event.at);
575
584
  };
576
585
  /**
577
586
  * Between frames of one batch (the A1 replay, a sweep's removals): a stop
@@ -586,15 +595,7 @@ async function* runFollow(
586
595
  follow.count !== undefined && counts.changes >= follow.count;
587
596
 
588
597
  try {
589
- if (tips.length > 0) {
590
- yield streamEnvelope(
591
- resolved,
592
- validation,
593
- null,
594
- { kind: "notice" },
595
- tips,
596
- );
597
- }
598
+ yield streamEnvelope(resolved, validation, null, { kind: "started" }, tips);
598
599
  loop: for (;;) {
599
600
  const event = await queue.next();
600
601
  switch (event.type) {
@@ -620,7 +621,7 @@ async function* runFollow(
620
621
  const id = event.row[".id"];
621
622
  if (id !== undefined) {
622
623
  held.add(id);
623
- yield frame("snapshot", "upsert", id, "print", event.row);
624
+ yield frame("snapshot", "upsert", id, "print", event.row, event.at);
624
625
  }
625
626
  break;
626
627
  }
@@ -660,9 +661,9 @@ async function* runFollow(
660
661
  const phase = counts.synced ? "live" : "snapshot";
661
662
  if (row) {
662
663
  held.add(id);
663
- yield frame(phase, "upsert", id, "membership", row);
664
+ yield frame(phase, "upsert", id, "membership", row, event.at);
664
665
  } else if (held.delete(id)) {
665
- yield frame(phase, "removed", id, "membership", null);
666
+ yield frame(phase, "removed", id, "membership", null, event.at);
666
667
  }
667
668
  if (counts.synced && countReached()) {
668
669
  stopReason ??= "count-reached";
@@ -688,13 +689,8 @@ async function* runFollow(
688
689
  continue;
689
690
  if (haltRequested()) break loop;
690
691
  held.delete(id);
691
- yield frame(
692
- "live",
693
- "removed",
694
- id,
695
- query ? "membership" : "sweep",
696
- null,
697
- );
692
+ // The sweep found it gone, filtered or not (#462).
693
+ yield frame("live", "removed", id, "sweep", null, event.at);
698
694
  if (countReached()) {
699
695
  stopReason ??= "count-reached";
700
696
  break loop;