@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
@@ -33,6 +33,7 @@ import {
33
33
  buildFanoutResolveFailure,
34
34
  commonVia,
35
35
  defaultFanoutSleep,
36
+ mayRetryUnsentWrite,
36
37
  resolveFanoutConcurrency,
37
38
  runFanout,
38
39
  summarizeFanout,
@@ -59,9 +60,11 @@ import {
59
60
  } from "./execute.ts";
60
61
  import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
61
62
  import {
63
+ emptyWriteSelectionError,
62
64
  expandSelection,
63
65
  isDefaultRecordTarget,
64
66
  loadEnvFileDefaults,
67
+ redactTargetInput,
65
68
  type SelectionExpansion,
66
69
  type SelectionMember,
67
70
  type SelectionResolveInput,
@@ -84,6 +87,8 @@ export interface ExecuteFanoutOperationMeta {
84
87
  concurrency: number;
85
88
  summary: FanoutSummary;
86
89
  request: ExecuteRequestSummary;
90
+ /** Hoisted from the targets when every target used the same login (#461). */
91
+ auth?: { username?: string; passwordProvided: boolean };
87
92
  selection: ExecuteSelectionSummary;
88
93
  }
89
94
 
@@ -105,6 +110,7 @@ export interface ExecuteFanoutInternals {
105
110
  ) => Promise<SelectionExpansion>;
106
111
  execute?: (
107
112
  resolved: ResolvedExecuteRequest,
113
+ trace: ValidationTrace,
108
114
  ) => Promise<ExecuteSuccessEnvelope>;
109
115
  sleep?: (ms: number) => Promise<void>;
110
116
  }
@@ -123,7 +129,7 @@ function summarizeSelection(
123
129
  where: selection.where.map((clause) => `${clause.key}=${clause.value}`),
124
130
  all: selection.all,
125
131
  default: selection.default,
126
- positionals: selection.positionals,
132
+ positionals: selection.positionals.map((value) => redactTargetInput(value)),
127
133
  quickchr: selection.quickchr,
128
134
  };
129
135
  }
@@ -223,6 +229,12 @@ export async function executeFanout(
223
229
  const concurrency = resolveFanoutConcurrency(options.concurrency, via);
224
230
 
225
231
  if (expansion.empty) {
232
+ if (global.summary.write) {
233
+ throw emptyWriteSelectionError(
234
+ expansion,
235
+ `\`${global.summary.command}\``,
236
+ );
237
+ }
226
238
  return executeFanoutEnvelope({
227
239
  selection: selectionSummary,
228
240
  concurrency,
@@ -308,6 +320,8 @@ export async function executeFanout(
308
320
  ),
309
321
  member,
310
322
  ),
323
+ mayRetry: (resolved) =>
324
+ mayRetryUnsentWrite(global.summary.write, traces.get(resolved)),
311
325
  sleep,
312
326
  });
313
327
 
package/src/execute.ts CHANGED
@@ -1,3 +1,16 @@
1
+ import {
2
+ assertNotCmrDashboardIl,
3
+ assertNotCmrDashboardPath,
4
+ cmrRunScriptWarnings,
5
+ cmrScriptWarnings,
6
+ } from "./core/cmr.ts";
7
+ import {
8
+ type CmrFleetRead,
9
+ cmrFleetCommand,
10
+ cmrFleetGate,
11
+ cmrFleetReadable,
12
+ readCmrFleet,
13
+ } from "./core/cmr-fleet.ts";
1
14
  import type {
2
15
  CentrsEnvelope,
3
16
  CentrsErrorEnvelope,
@@ -16,6 +29,7 @@ import {
16
29
  canonicalizeExecuteCommand,
17
30
  isWriteShaped,
18
31
  } from "./core/execute-command.ts";
32
+ import { sentWriteWarnings } from "./core/fanout.ts";
19
33
  import { inspectArgumentNames, pathTokens } from "./core/inspect.ts";
20
34
  import { mapRouterOsResultError } from "./core/routeros-errors.ts";
21
35
  import { routerOsStringLiteral } from "./core/routeros-string.ts";
@@ -37,6 +51,7 @@ import {
37
51
  getProtocolPlan,
38
52
  type ProtocolAdapter,
39
53
  type ProtocolExecuteResult,
54
+ type ProtocolSendOptions,
40
55
  plannedProtocols,
41
56
  type RouterOsProtocol,
42
57
  } from "./protocols/index.ts";
@@ -57,6 +72,7 @@ import {
57
72
  type ResolvedAuth,
58
73
  type ResolvedSetting,
59
74
  type ResolvedTarget,
75
+ redactTargetInput,
60
76
  resolveAuth,
61
77
  resolveBooleanSetting,
62
78
  resolveCdb,
@@ -163,6 +179,8 @@ export interface ResolvedExecuteRequest {
163
179
  yes: boolean;
164
180
  verbose: boolean;
165
181
  warnings: readonly Warning[];
182
+ /** A CMR fleet command's reach, read before the write gate (#453). */
183
+ cmrFleet?: CmrFleetRead;
166
184
  }
167
185
 
168
186
  export async function execute(
@@ -194,6 +212,7 @@ export async function executeEnvelope(
194
212
  const trace: ValidationTrace = {};
195
213
  try {
196
214
  resolved = await resolveExecuteRequest(request, env);
215
+ resolved = await withCmrFleetScope(resolved);
197
216
  await assertWriteConfirmed(request, resolved);
198
217
  return await runResolvedExecute(resolved, trace);
199
218
  } catch (error) {
@@ -288,7 +307,11 @@ export async function runResolvedExecute(
288
307
  // reports the gate that actually passed rather than a guess.
289
308
  trace.validation = validation;
290
309
 
291
- const result = await runCommand(resolved, backend);
310
+ const result = await runCommand(resolved, backend, {
311
+ onSent: () => {
312
+ trace.dispatched = true;
313
+ },
314
+ });
292
315
  const routerOsFailure = routerOsFailureFromResult(
293
316
  result,
294
317
  resolved.via.value,
@@ -319,6 +342,7 @@ export async function resolveExecuteRequest(
319
342
  ): Promise<ResolvedExecuteRequest> {
320
343
  validateExecuteRequestShape(request);
321
344
  const canonical = canonicalizeExecuteCommand(request.command);
345
+ assertNotStructuredDashboard(canonical);
322
346
  const config = options.config ?? (await loadEnvFileDefaults(env));
323
347
  // A quickchr target is a named-live-provider: it supplies host/port/auth from
324
348
  // the live descriptor and bypasses CDB/env resolution for those fields
@@ -450,9 +474,12 @@ export async function resolveExecuteRequest(
450
474
  maxResultsBytes,
451
475
  yes: request.yes ?? false,
452
476
  verbose: request.verbose ?? false,
453
- warnings: connection
454
- ? [...connection.warnings]
455
- : (cdbResolution?.warnings ?? []),
477
+ warnings: [
478
+ ...(connection ? connection.warnings : (cdbResolution?.warnings ?? [])),
479
+ ...(canonical.mode === "structured"
480
+ ? cmrRunScriptWarnings(`${canonical.path}/${canonical.verb}`)
481
+ : cmrScriptWarnings(canonical.input)),
482
+ ],
456
483
  };
457
484
  }
458
485
 
@@ -477,6 +504,7 @@ export function resolveExecuteGlobalContext(
477
504
  ): ExecuteGlobalContext {
478
505
  validateExecuteRequestShape(request);
479
506
  const canonical = canonicalizeExecuteCommand(request.command);
507
+ assertNotStructuredDashboard(canonical);
480
508
  const via = resolveExecuteProtocol(request, env, undefined, config);
481
509
  const format = resolveFormat(request, env, config);
482
510
  const validate = resolveBooleanSetting(
@@ -556,7 +584,7 @@ export function buildExecuteErrorEnvelope(
556
584
  warnings: [],
557
585
  tips: [],
558
586
  meta: {
559
- target: { input: request.targetInput },
587
+ target: { input: redactTargetInput(request.targetInput) },
560
588
  via: requestedVia,
561
589
  settings: {},
562
590
  operation: {
@@ -599,7 +627,14 @@ export function buildExecuteErrorEnvelopeFromResolved(
599
627
  return {
600
628
  ok: false,
601
629
  error: serializeCentrsError(centrsError),
602
- warnings: [...resolved.warnings],
630
+ warnings: [
631
+ ...resolved.warnings,
632
+ ...sentWriteWarnings(
633
+ isWriteShaped(resolved.canonical),
634
+ centrsError,
635
+ trace,
636
+ ),
637
+ ],
603
638
  tips: resolvedExecuteTips(resolved),
604
639
  meta: metaFromResolved(
605
640
  resolved,
@@ -729,7 +764,7 @@ function deviceStageSource(resolved: ResolvedExecuteRequest): string {
729
764
  * judge. An absent verdict means the caller did not thread a trace, so say
730
765
  * nothing extra rather than invent the reason.
731
766
  */
732
- function offlineStagePassed(
767
+ export function offlineStagePassed(
733
768
  verdict: ValidationTrace["offlineVerdict"],
734
769
  ): EnvelopeValidationStage {
735
770
  return {
@@ -748,7 +783,7 @@ function offlineStagePassed(
748
783
  * a consumer reading `stages` never has to special-case the disabled shape.
749
784
  * `reason` is left off — `enabled: false` already says why.
750
785
  */
751
- function disabledValidationMeta(): EnvelopeValidationMeta {
786
+ export function disabledValidationMeta(): EnvelopeValidationMeta {
752
787
  return {
753
788
  enabled: false,
754
789
  source: "disabled",
@@ -774,7 +809,7 @@ export function renderExecuteEnvelope(
774
809
  return toYaml(envelope);
775
810
  case "text":
776
811
  return envelope.ok
777
- ? renderExecuteSuccessText(envelope, options)
812
+ ? renderExecuteSuccessText(envelope)
778
813
  : formatCentrsErrorText(envelope.error, options);
779
814
  default:
780
815
  return exhaustiveOutputFormat(format);
@@ -888,6 +923,7 @@ async function validateConsoleParseCommand(
888
923
  });
889
924
  // Throws validation/syntax or validation/unknown-attribute on a bad parse.
890
925
  classifyParseResult(result.ret ?? "", resolved.command, resolved.via.value);
926
+ assertNotCmrDashboardIl(result.ret ?? "", resolved.command);
891
927
  return {
892
928
  enabled: true,
893
929
  source: `:put [:parse ...] over ${resolved.via.value}`,
@@ -908,7 +944,7 @@ async function validateConsoleParseCommand(
908
944
  * `warn` is an abstention, and saying so is the difference between "the analyzer
909
945
  * read all of this" and "it declined to judge part of it".
910
946
  */
911
- function passedStages(
947
+ export function passedStages(
912
948
  offline: OfflineGateResult,
913
949
  deviceSource: string,
914
950
  ): EnvelopeValidationStage[] {
@@ -939,6 +975,7 @@ export async function validateRouterOsScript(
939
975
  via: RouterOsProtocol,
940
976
  ): Promise<void> {
941
977
  const script = `:put [:parse ${routerOsStringLiteral(command)}]`;
978
+ let il: string;
942
979
  try {
943
980
  // GH#230: `:parse` never throws (CHR 7.23.3). The diagnostic rides the
944
981
  // success value's text — over REST/native the `ret` field (HTTP 200 /
@@ -947,7 +984,8 @@ export async function validateRouterOsScript(
947
984
  // Read the return and classify it: one `:parse` covers syntax and the
948
985
  // unknown-attribute (name-level) gate.
949
986
  const result = await backend.execute({ path: "", command: "", script });
950
- classifyParseResult(result.ret ?? "", command, via);
987
+ il = result.ret ?? "";
988
+ classifyParseResult(il, command, via);
951
989
  } catch (error) {
952
990
  // The preflight is a syntax gate, but `backend.execute` also surfaces
953
991
  // connection and authentication failures (login happens lazily here).
@@ -984,6 +1022,20 @@ export async function validateRouterOsScript(
984
1022
  causeData: syntaxCause(error),
985
1023
  });
986
1024
  }
1025
+ assertNotCmrDashboardIl(il, command);
1026
+ }
1027
+
1028
+ /**
1029
+ * The plain spellings of the dashboard are refused before anything is dialed.
1030
+ * Every other spelling (a relative menu change, a block, a substitution) is
1031
+ * found in the `:parse` return, so it is refused by validation.
1032
+ */
1033
+ function assertNotStructuredDashboard(
1034
+ canonical: CanonicalExecuteCommand,
1035
+ ): void {
1036
+ if (canonical.mode === "structured") {
1037
+ assertNotCmrDashboardPath(`${canonical.path}/${canonical.verb}`);
1038
+ }
987
1039
  }
988
1040
 
989
1041
  /**
@@ -998,22 +1050,32 @@ function isPreflightTransportError(error: CentrsError): boolean {
998
1050
  async function runCommand(
999
1051
  resolved: ResolvedExecuteRequest,
1000
1052
  backend: ProtocolAdapter,
1053
+ options: ProtocolSendOptions,
1001
1054
  ): Promise<ProtocolExecuteResult> {
1002
1055
  if (resolved.via.value === "mac-telnet" || resolved.via.value === "ssh") {
1003
1056
  // Console transports: run the raw CLI line and capture the console output
1004
1057
  // (structured path-POST / tagged talk do not apply). ssh runs it as a
1005
1058
  // single `ssh host "<command>"`; mac-telnet drives the L2 console.
1006
- return backend.execute({ path: "", command: "", script: resolved.command });
1059
+ return backend.execute(
1060
+ { path: "", command: "", script: resolved.command },
1061
+ options,
1062
+ );
1007
1063
  }
1008
1064
  if (resolved.canonical.mode === "script") {
1009
- return backend.execute({ path: "", command: "", script: resolved.command });
1065
+ return backend.execute(
1066
+ { path: "", command: "", script: resolved.command },
1067
+ options,
1068
+ );
1010
1069
  }
1011
- return backend.execute({
1012
- path: resolved.canonical.path,
1013
- command: resolved.canonical.verb,
1014
- attributes: resolved.canonical.attributes,
1015
- queries: resolved.canonical.queries,
1016
- });
1070
+ return backend.execute(
1071
+ {
1072
+ path: resolved.canonical.path,
1073
+ command: resolved.canonical.verb,
1074
+ attributes: resolved.canonical.attributes,
1075
+ queries: resolved.canonical.queries,
1076
+ },
1077
+ options,
1078
+ );
1017
1079
  }
1018
1080
 
1019
1081
  async function assertWriteConfirmed(
@@ -1023,9 +1085,13 @@ async function assertWriteConfirmed(
1023
1085
  if (!isWriteShaped(resolved.canonical) || resolved.yes) {
1024
1086
  return;
1025
1087
  }
1088
+ const fleet = resolved.cmrFleet
1089
+ ? cmrFleetGate(resolved.cmrFleet, targetName(resolved))
1090
+ : undefined;
1026
1091
  if (request.stdinIsTty ?? process.stdin.isTTY) {
1027
1092
  const confirmed = await (request.confirm ?? promptForWriteConfirmation)(
1028
- `Run write-shaped RouterOS command over ${resolved.via.value}? Type yes to continue: `,
1093
+ fleet?.prompt ??
1094
+ `Run write-shaped RouterOS command over ${resolved.via.value}? Type yes to continue: `,
1029
1095
  );
1030
1096
  if (confirmed) {
1031
1097
  return;
@@ -1034,6 +1100,7 @@ async function assertWriteConfirmed(
1034
1100
  throw new CentrsError({
1035
1101
  code: "usage/confirmation-required",
1036
1102
  summary:
1103
+ fleet?.summary ??
1037
1104
  "Write-shaped RouterOS execute commands require explicit confirmation.",
1038
1105
  remediation:
1039
1106
  "Pass `--yes` in non-interactive automation, or answer `yes` at the TTY prompt after reviewing the command.",
@@ -1042,10 +1109,75 @@ async function assertWriteConfirmed(
1042
1109
  path: resolved.canonical.path,
1043
1110
  verb: resolved.canonical.verb,
1044
1111
  via: resolved.via.value,
1112
+ ...fleet?.context,
1045
1113
  },
1046
1114
  });
1047
1115
  }
1048
1116
 
1117
+ function targetName(resolved: ResolvedExecuteRequest): string {
1118
+ return (
1119
+ resolved.target.identity ?? resolved.target.input ?? resolved.target.host
1120
+ );
1121
+ }
1122
+
1123
+ /**
1124
+ * #453: a write-shaped CMR fleet command (`/cmr/device/reboot labels=all`) is
1125
+ * one request to the controller that acts on every device it selects. Read
1126
+ * the controller's inventory before the gate so the gate names that reach,
1127
+ * and add the fleet warnings, with or without `--yes`. Structured input only:
1128
+ * a script's selectors (`[find …]`, positionals) are not resolved.
1129
+ */
1130
+ async function withCmrFleetScope(
1131
+ resolved: ResolvedExecuteRequest,
1132
+ ): Promise<ResolvedExecuteRequest> {
1133
+ const { canonical } = resolved;
1134
+ if (canonical.mode !== "structured" || !isWriteShaped(canonical)) {
1135
+ return resolved;
1136
+ }
1137
+ const command = cmrFleetCommand(
1138
+ `${canonical.path}/${canonical.verb}`,
1139
+ canonical.attributes,
1140
+ );
1141
+ if (!command) return resolved;
1142
+ if (!cmrFleetReadable(resolved.via.value)) {
1143
+ return {
1144
+ ...resolved,
1145
+ cmrFleet: {
1146
+ scope: {
1147
+ command: command.command,
1148
+ unresolved: `${resolved.via.value} has no structured read`,
1149
+ },
1150
+ warnings: [],
1151
+ },
1152
+ };
1153
+ }
1154
+ const adapter = createProtocolAdapter({
1155
+ protocol: resolved.via.value,
1156
+ host: resolved.target.host,
1157
+ port: resolved.target.port,
1158
+ tls: resolved.target.tls,
1159
+ baseUrl: resolved.target.baseUrl,
1160
+ username: resolved.auth.username,
1161
+ password: resolved.auth.password,
1162
+ timeoutMs: resolved.timeoutMs.value,
1163
+ insecure: resolved.insecure.value,
1164
+ });
1165
+ try {
1166
+ const cmrFleet = await readCmrFleet(
1167
+ command,
1168
+ adapter,
1169
+ resolved.auth.username,
1170
+ );
1171
+ return {
1172
+ ...resolved,
1173
+ cmrFleet,
1174
+ warnings: [...resolved.warnings, ...cmrFleet.warnings],
1175
+ };
1176
+ } finally {
1177
+ await adapter.close();
1178
+ }
1179
+ }
1180
+
1049
1181
  export async function promptForWriteConfirmation(
1050
1182
  prompt: string,
1051
1183
  ): Promise<boolean> {
@@ -1399,27 +1531,28 @@ function applyMaxResultsBudget(
1399
1531
  return envelope;
1400
1532
  }
1401
1533
 
1402
- function renderExecuteSuccessText(
1403
- envelope: ExecuteSuccessEnvelope,
1404
- options: { verbose?: boolean },
1405
- ): string {
1406
- const lines: string[] = [];
1407
- if (options.verbose) {
1408
- lines.push(
1409
- `target: ${envelope.meta.target.input ?? envelope.meta.target.host}`,
1410
- );
1411
- lines.push(`via: ${envelope.meta.via}`);
1412
- if (envelope.meta.validation?.source) {
1413
- lines.push(`validation: ${envelope.meta.validation.source}`);
1414
- }
1415
- lines.push("");
1416
- }
1417
- if (typeof envelope.data === "string") {
1418
- lines.push(envelope.data);
1419
- } else {
1420
- lines.push(JSON.stringify(envelope.data, null, 2));
1534
+ /** Text mode renders only the data; the CLI writes the rest to stderr (#470). */
1535
+ function renderExecuteSuccessText(envelope: ExecuteSuccessEnvelope): string {
1536
+ return typeof envelope.data === "string"
1537
+ ? envelope.data
1538
+ : JSON.stringify(envelope.data, null, 2);
1539
+ }
1540
+
1541
+ /**
1542
+ * The `--verbose` header of a text-mode execute result (stderr, #470). A
1543
+ * failure after resolution still names its target; one before has no
1544
+ * transport (`via: null`) and no header.
1545
+ */
1546
+ export function executeVerboseLines(envelope: ExecuteEnvelope): string[] {
1547
+ if (envelope.meta.via === null) return [];
1548
+ const lines = [
1549
+ `target: ${envelope.meta.target.input ?? envelope.meta.target.host}`,
1550
+ `via: ${envelope.meta.via}`,
1551
+ ];
1552
+ if (envelope.meta.validation?.source) {
1553
+ lines.push(`validation: ${envelope.meta.validation.source}`);
1421
1554
  }
1422
- return lines.join("\n");
1555
+ return lines;
1423
1556
  }
1424
1557
 
1425
1558
  function syntaxCause(error: unknown): unknown {
package/src/index.ts CHANGED
@@ -6,6 +6,8 @@ export {
6
6
  type ApiOutputFormat,
7
7
  type ApiRequest,
8
8
  type ApiRequestSummary,
9
+ type ApiStreamFrameEnvelope,
10
+ type ApiStreamLine,
9
11
  type ApiStreamMeta,
10
12
  type ApiStreamStopReason,
11
13
  type ApiStreamSummary,
@@ -16,6 +18,7 @@ export {
16
18
  apiOutputFormats,
17
19
  apiRequestSummaryFromRequest,
18
20
  apiStream,
21
+ apiVerboseLines,
19
22
  buildApiBody,
20
23
  buildApiErrorEnvelope,
21
24
  buildApiErrorEnvelopeFromResolved,
@@ -81,10 +84,12 @@ export {
81
84
  type EnvelopeValidationMeta,
82
85
  type EnvelopeValidationStage,
83
86
  type FanoutData,
87
+ type FanoutMemberOperation,
84
88
  type FanoutSummary,
85
89
  normalizeWarnings,
86
90
  type SettingSource,
87
91
  type SettingSourceKind,
92
+ type StreamLineEnvelope,
88
93
  type ValidationTrace,
89
94
  type Warning,
90
95
  } from "./core/envelope.ts";
@@ -92,6 +97,8 @@ export {
92
97
  buildFanoutEnvelope,
93
98
  buildFanoutResolveFailure,
94
99
  commonVia,
100
+ compactFanoutTargets,
101
+ expandFanoutTarget,
95
102
  FANOUT_BACKOFF_BASE_MS,
96
103
  FANOUT_CONCURRENCY_DEFAULTS,
97
104
  FANOUT_MAX_RETRIES,
@@ -279,6 +286,7 @@ export {
279
286
  execute,
280
287
  executeEnvelope,
281
288
  executeOutputFormats,
289
+ executeVerboseLines,
282
290
  isWriteShaped,
283
291
  renderExecuteEnvelope,
284
292
  resolveExecuteGlobalContext,
@@ -286,6 +294,23 @@ export {
286
294
  runResolvedExecute,
287
295
  validateExecuteRequestShape,
288
296
  } from "./execute.ts";
297
+ export {
298
+ buildExecuteCmrErrorEnvelope,
299
+ CMR_VALIDATION_SOURCE,
300
+ type CmrController,
301
+ type CmrDevice,
302
+ type CmrScriptCall,
303
+ type CmrScriptOutcome,
304
+ type CmrScriptRow,
305
+ type ExecuteCmrData,
306
+ type ExecuteCmrEnvelope,
307
+ type ExecuteCmrErrorEnvelope,
308
+ type ExecuteCmrInternals,
309
+ type ExecuteCmrOperationMeta,
310
+ type ExecuteCmrRequest,
311
+ executeCmr,
312
+ renderExecuteCmrEnvelope,
313
+ } from "./execute-cmr.ts";
289
314
  export {
290
315
  buildExecuteFanoutErrorEnvelope,
291
316
  type ExecuteFanoutData,
@@ -587,6 +612,7 @@ export {
587
612
  type RetrieveEnvelope,
588
613
  type RetrieveErrorEnvelope,
589
614
  type RetrieveFollowCounts,
615
+ type RetrieveFollowLine,
590
616
  type RetrieveFollowSettings,
591
617
  type RetrieveFollowStopReason,
592
618
  type RetrieveFollowSummary,
@@ -599,15 +625,19 @@ export {
599
625
  type RetrieveSampleSettings,
600
626
  type RetrieveSampleStopReason,
601
627
  type RetrieveSampleSummary,
628
+ type RetrieveStreamLine,
629
+ type RetrieveStreamLineEnvelope,
602
630
  type RetrieveStreamMeta,
603
631
  type RetrieveSuccessEnvelope,
604
632
  type RetrieveWarning,
605
633
  renderRetrieveEnvelope,
606
634
  resolveRetrieveGlobalContext,
635
+ resolveRetrieveOutputFormat,
607
636
  resolveRetrieveRequest,
608
637
  retrieve,
609
638
  retrieveEnvelope,
610
639
  retrieveOutputFormats,
640
+ retrieveVerboseLines,
611
641
  runResolvedRetrieve,
612
642
  validateRetrieveRequestShape,
613
643
  } from "./retrieve.ts";
@@ -633,7 +663,10 @@ export {
633
663
  type RetrieveSampleOptions,
634
664
  retrieveSample,
635
665
  } from "./retrieve-sample.ts";
636
- export { renderRetrieveStreamLine } from "./retrieve-stream.ts";
666
+ export {
667
+ isStreamDataLine,
668
+ renderRetrieveStreamLine,
669
+ } from "./retrieve-stream.ts";
637
670
  export { type RetrieveWaitOptions, retrieveWait } from "./retrieve-wait.ts";
638
671
  export {
639
672
  buildSettingsErrorEnvelope,
@@ -701,6 +734,7 @@ export {
701
734
  transferEnvelope,
702
735
  transferMethods,
703
736
  transferOutputFormats,
737
+ transferVerboseLines,
704
738
  transferVerbs,
705
739
  validateTransferRequestShape,
706
740
  } from "./transfer.ts";
package/src/mcp/safety.ts CHANGED
@@ -19,6 +19,11 @@ import {
19
19
  import { CentrsError } from "../errors.ts";
20
20
  import type { McpWritePolicy } from "../mcp.ts";
21
21
  import { parseCommentKv } from "../resolver/comment-kv.ts";
22
+ import {
23
+ assertNoTargetCredentials,
24
+ assertNoTargetPassword,
25
+ redactTargetInput,
26
+ } from "../resolver/target.ts";
22
27
  import type { CentrsMcpConfig } from "./config.ts";
23
28
 
24
29
  export interface RegisteredTarget {
@@ -68,10 +73,16 @@ export function resolveRegisteredTarget(
68
73
  cdb: LoadedCdb,
69
74
  target: string,
70
75
  ): RegisteredTarget {
76
+ // Before the lookup, whose refusal echoes the target (#478).
77
+ assertNoTargetPassword(target);
71
78
  let entry: DevicesShowItem;
72
79
  try {
73
80
  entry = showDevice({ cdb, target }).data.entry;
74
81
  } catch (error) {
82
+ // Preserve registered identities; mask credential-shaped lookup failures.
83
+ if (redactTargetInput(target) !== target) {
84
+ assertNoTargetCredentials(target);
85
+ }
75
86
  if (error instanceof CentrsError && error.code === "cdb/not-found-target") {
76
87
  throw new CentrsError({
77
88
  code: "cdb/target-not-registered",
package/src/mcp/tools.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  import type { CommentKvUpdate } from "../resolver/comment-kv.ts";
37
37
  import { parseBbox, parseNear } from "../resolver/geo.ts";
38
38
  import { parseDuration } from "../resolver/settings.ts";
39
+ import { redactTargetInput } from "../resolver/target.ts";
39
40
  import { retrieveEnvelope } from "../retrieve.ts";
40
41
  import {
41
42
  buildRetrieveFanoutErrorEnvelope,
@@ -89,7 +90,9 @@ function mcpErrorEnvelope(
89
90
  warnings: [],
90
91
  tips: [],
91
92
  meta: {
92
- target: context.target ? { input: context.target } : {},
93
+ target: context.target
94
+ ? { input: redactTargetInput(context.target) }
95
+ : {},
93
96
  via: null,
94
97
  settings: {},
95
98
  operation: { kind: "mcp", tool },