@tikoci/centrs 0.1.9-next.27 → 0.1.9

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.
package/src/devices.ts CHANGED
@@ -94,8 +94,16 @@ export interface DevicesListItem {
94
94
  location?: DeviceLocation;
95
95
  }
96
96
 
97
+ /**
98
+ * A CDB record as `devices show` / `add` / `set` return it. The record password
99
+ * is withheld by default (#551): `passwordSet` says whether one is stored, and
100
+ * only {@link DevicesShowItemWithPassword} (the `showPassword: true` opt-in)
101
+ * carries the value. Not to be confused with `savedPassword`, the WinBox
102
+ * "keep password" flag.
103
+ */
97
104
  export interface DevicesShowItem extends DevicesListItem {
98
- password: string;
105
+ /** True when the record stores a non-empty password. Always present. */
106
+ passwordSet: boolean;
99
107
  comment: string;
100
108
  commentMirror: string;
101
109
  profile: string;
@@ -105,6 +113,11 @@ export interface DevicesShowItem extends DevicesListItem {
105
113
  flags: number;
106
114
  }
107
115
 
116
+ /** {@link DevicesShowItem} plus the cleartext record password (`showPassword: true`). */
117
+ export interface DevicesShowItemWithPassword extends DevicesShowItem {
118
+ password: string;
119
+ }
120
+
108
121
  export interface DevicesGroupSummary {
109
122
  group: string;
110
123
  members: number;
@@ -614,10 +627,19 @@ export async function loadCdb(options: LoadCdbOptions): Promise<LoadedCdb> {
614
627
  for (const entry of entries) {
615
628
  const tags = unknownTcodeTags(entry.record);
616
629
  if (tags.length > 0) {
630
+ // `passwordSet` reports the password centrs decoded and would dial
631
+ // with. When the undecoded tail may hold one, say so here rather than
632
+ // let `passwordSet: false` read as certain.
633
+ const mayContainPassword =
634
+ passwordBearingRawTailIndex(entry.record) !== -1;
617
635
  warnings.push({
618
636
  code: "cdb/unknown-field",
619
- message: `Entry ${entry.target || "(unnamed)"} contains ${tags.length} field(s) with unknown tcodes; preserved verbatim.`,
620
- context: { target: entry.target, tags },
637
+ message: `Entry ${entry.target || "(unnamed)"} contains ${tags.length} field(s) with unknown tcodes; preserved verbatim.${mayContainPassword ? " The undecoded bytes may contain the record password, so passwordSet may under-report it." : ""}`,
638
+ context: {
639
+ target: entry.target,
640
+ tags,
641
+ ...(mayContainPassword ? { mayContainPassword: true } : {}),
642
+ },
621
643
  });
622
644
  }
623
645
  }
@@ -794,13 +816,24 @@ export interface ShowDeviceArgs {
794
816
  */
795
817
  match?: string;
796
818
  env?: Record<string, string | undefined>;
819
+ /**
820
+ * Return the record password in cleartext (`--show-password`). Default
821
+ * `false`: the entry carries `passwordSet` and no `password`, and the
822
+ * `explain` record has its password field value replaced.
823
+ */
824
+ showPassword?: boolean;
797
825
  }
798
826
 
799
- export interface DevicesShowEnvelopeData {
800
- entry: DevicesShowItem;
827
+ export interface DevicesShowEnvelopeData<
828
+ Entry extends DevicesShowItem = DevicesShowItem,
829
+ > {
830
+ entry: Entry;
801
831
  record?: WinBoxCdbRecord;
802
832
  }
803
833
 
834
+ /** Placeholder written over a withheld password in an `explain` record. */
835
+ export const REDACTED_PASSWORD_VALUE = "<redacted>";
836
+
804
837
  /**
805
838
  * Canonical form for `<router>` comparison: a MAC is normalized (case /
806
839
  * separator / zero-pad insensitive via {@link normalizeMac}); everything else is
@@ -931,28 +964,150 @@ function describeMatches(matches: readonly EntryMatch[]): {
931
964
  }));
932
965
  }
933
966
 
967
+ /**
968
+ * The raw CDB record `<router>` resolves to, password included. INTERNAL: for
969
+ * callers that dial or gate on the record (the resolver, the MCP allowlist),
970
+ * never for output. It is deliberately absent from `src/index.ts`; the public
971
+ * {@link showDevice} envelope withholds the password by default (#551).
972
+ */
973
+ export interface DeviceRecordLookup {
974
+ entry: WinBoxCdbEntry;
975
+ cdbRecordIndex: number;
976
+ /** CDB load warnings plus the record's comment-kv / `via` warnings. */
977
+ warnings: DevicesWarning[];
978
+ tips: Tip[];
979
+ target: EnvelopeTargetMeta;
980
+ }
981
+
982
+ /**
983
+ * Resolve `<router>` to its raw CDB record (see {@link DeviceRecordLookup}).
984
+ * Same matcher, `--match` selection and errors as {@link showDevice}.
985
+ */
986
+ export function lookupDeviceRecord(
987
+ args: Omit<ShowDeviceArgs, "explain" | "showPassword">,
988
+ ): DeviceRecordLookup {
989
+ const match = requireSingleMatch(args.cdb.entries, args.target, args.match);
990
+ const resolved = resolveDeviceShowTargetMeta(match.entry, match.index, args);
991
+ return {
992
+ entry: match.entry,
993
+ cdbRecordIndex: match.index,
994
+ warnings: [...args.cdb.warnings, ...resolved.warnings],
995
+ tips: credentialsMissingTips(match.entry, args.cdb.entries),
996
+ target: resolved.target,
997
+ };
998
+ }
999
+
1000
+ export function showDevice(
1001
+ args: ShowDeviceArgs & { showPassword: true },
1002
+ ): CentrsSuccessEnvelope<
1003
+ DevicesShowEnvelopeData<DevicesShowItemWithPassword>,
1004
+ DevicesOperationMeta
1005
+ >;
1006
+ export function showDevice(
1007
+ args: ShowDeviceArgs,
1008
+ ): CentrsSuccessEnvelope<DevicesShowEnvelopeData, DevicesOperationMeta>;
934
1009
  export function showDevice(
935
1010
  args: ShowDeviceArgs,
936
1011
  ): CentrsSuccessEnvelope<DevicesShowEnvelopeData, DevicesOperationMeta> {
937
- const match = requireSingleMatch(args.cdb.entries, args.target, args.match);
1012
+ const found = lookupDeviceRecord(args);
1013
+ const showPassword = args.showPassword === true;
938
1014
  const data: DevicesShowEnvelopeData = {
939
- entry: entryToShowItem(match.entry, match.index),
1015
+ entry: projectShowItem(
1016
+ entryToShowItem(found.entry, found.cdbRecordIndex),
1017
+ showPassword,
1018
+ ),
940
1019
  };
941
1020
  if (args.explain) {
942
- data.record = match.entry.record;
1021
+ data.record = showPassword
1022
+ ? found.entry.record
1023
+ : redactRecordPassword(found.entry.record);
943
1024
  }
944
1025
 
945
- const resolved = resolveDeviceShowTargetMeta(match.entry, match.index, args);
946
-
947
1026
  return {
948
1027
  ok: true,
949
1028
  data,
950
- warnings: [...args.cdb.warnings, ...resolved.warnings],
951
- tips: credentialsMissingTips(match.entry, args.cdb.entries),
952
- meta: devicesMeta("show", args.cdb.settings, resolved.target),
1029
+ warnings: found.warnings,
1030
+ tips: found.tips,
1031
+ meta: devicesMeta("show", args.cdb.settings, found.target),
953
1032
  };
954
1033
  }
955
1034
 
1035
+ /**
1036
+ * The shared output redaction for a device entry (#551): drop `password`, keep
1037
+ * `passwordSet`. The CLI, the library envelopes and MCP all return entries
1038
+ * through this, so they withhold the secret the same way.
1039
+ */
1040
+ export function redactDeviceEntry(
1041
+ entry: DevicesShowItemWithPassword,
1042
+ ): DevicesShowItem {
1043
+ const { password, ...rest } = entry;
1044
+ return { ...rest, passwordSet: password.length > 0 };
1045
+ }
1046
+
1047
+ function projectShowItem(
1048
+ entry: DevicesShowItemWithPassword,
1049
+ showPassword: boolean,
1050
+ ): DevicesShowItem {
1051
+ return showPassword ? entry : redactDeviceEntry(entry);
1052
+ }
1053
+
1054
+ /**
1055
+ * Replace the password field value in an `explain` record. The record is a
1056
+ * debugging view of the CDB bytes; it must not become a side door around the
1057
+ * entry redaction.
1058
+ */
1059
+ function redactRecordPassword(record: WinBoxCdbRecord): WinBoxCdbRecord {
1060
+ const tailIndex = passwordBearingRawTailIndex(record);
1061
+ if (tailIndex === -1 && !record.fields.some(isPasswordField)) {
1062
+ return record;
1063
+ }
1064
+ return {
1065
+ ...record,
1066
+ fields: record.fields.map((field, index) => {
1067
+ if (index === tailIndex) {
1068
+ const length =
1069
+ field.value instanceof Uint8Array ? field.value.length : 0;
1070
+ return { ...field, value: redactedRawTailValue(length) };
1071
+ }
1072
+ return isPasswordField(field)
1073
+ ? { ...field, value: REDACTED_PASSWORD_VALUE }
1074
+ : field;
1075
+ }),
1076
+ };
1077
+ }
1078
+
1079
+ /**
1080
+ * Index of a `rawTail` field whose bytes may hold the password, or -1. The
1081
+ * decoder folds everything after an unknown tcode into one opaque tail, so
1082
+ * when no decoded password field (tag 3) precedes the tail, the password — if
1083
+ * the record has one — is somewhere inside those bytes (or the tail is tag 3
1084
+ * itself).
1085
+ */
1086
+ function passwordBearingRawTailIndex(record: WinBoxCdbRecord): number {
1087
+ for (const [index, field] of record.fields.entries()) {
1088
+ if (field.rawTail) {
1089
+ return index;
1090
+ }
1091
+ if (field.tag === winBoxCdbFieldTag.password) {
1092
+ return -1;
1093
+ }
1094
+ }
1095
+ return -1;
1096
+ }
1097
+
1098
+ function redactedRawTailValue(length: number): string {
1099
+ return `<redacted: ${length} bytes, may contain password>`;
1100
+ }
1101
+
1102
+ function isPasswordField(field: WinBoxCdbField): boolean {
1103
+ return (
1104
+ field.tag === winBoxCdbFieldTag.password &&
1105
+ !field.rawTail &&
1106
+ typeof field.value === "string" &&
1107
+ field.value.length > 0
1108
+ );
1109
+ }
1110
+
956
1111
  function entryToListItem(
957
1112
  entry: WinBoxCdbEntry,
958
1113
  cdbRecordIndex: number,
@@ -1006,7 +1161,7 @@ interface ResolvedDeviceTargetMeta {
1006
1161
  function resolveDeviceShowTargetMeta(
1007
1162
  entry: WinBoxCdbEntry,
1008
1163
  cdbRecordIndex: number,
1009
- args: ShowDeviceArgs,
1164
+ args: Pick<ShowDeviceArgs, "target" | "via" | "env">,
1010
1165
  ): ResolvedDeviceTargetMeta {
1011
1166
  const parsed = parseCommentKv(entry.comment);
1012
1167
  const warnings: DevicesWarning[] = parsed.warnings.map((warning) => ({
@@ -1122,14 +1277,15 @@ function parseProtocolOption(
1122
1277
  function entryToShowItem(
1123
1278
  entry: WinBoxCdbEntry,
1124
1279
  cdbRecordIndex: number,
1125
- ): DevicesShowItem {
1126
- const item: DevicesShowItem = {
1280
+ ): DevicesShowItemWithPassword {
1281
+ const item: DevicesShowItemWithPassword = {
1127
1282
  target: entry.target,
1128
1283
  recordType: entry.recordType,
1129
1284
  recordTypeName: recordTypeName(entry.recordType),
1130
1285
  group: entry.group,
1131
1286
  user: entry.user,
1132
1287
  password: entry.password,
1288
+ passwordSet: entry.password.length > 0,
1133
1289
  session: entry.session,
1134
1290
  comment: entry.comment,
1135
1291
  commentMirror: entry.commentMirror,
@@ -1200,7 +1356,9 @@ export function listGroups(
1200
1356
  * resulting record (absent for `remove`); `preservedUnknownTags` lists any
1201
1357
  * unknown/`rawTail` field tags carried over verbatim from the prior record.
1202
1358
  */
1203
- export interface DevicesMutationData {
1359
+ export interface DevicesMutationData<
1360
+ Entry extends DevicesShowItem = DevicesShowItem,
1361
+ > {
1204
1362
  action: "add" | "edit" | "set" | "remove";
1205
1363
  target: string;
1206
1364
  cdbRecordIndex: number;
@@ -1209,13 +1367,13 @@ export interface DevicesMutationData {
1209
1367
  backupPath?: string;
1210
1368
  prunedBackups: readonly string[];
1211
1369
  preservedUnknownTags?: readonly number[];
1212
- entry?: DevicesShowItem;
1370
+ /** The resulting record; the password is withheld unless `showPassword`. */
1371
+ entry?: Entry;
1213
1372
  }
1214
1373
 
1215
- export type DevicesMutationEnvelope = CentrsSuccessEnvelope<
1216
- DevicesMutationData,
1217
- DevicesOperationMeta
1218
- >;
1374
+ export type DevicesMutationEnvelope<
1375
+ Entry extends DevicesShowItem = DevicesShowItem,
1376
+ > = CentrsSuccessEnvelope<DevicesMutationData<Entry>, DevicesOperationMeta>;
1219
1377
 
1220
1378
  const KNOWN_FIELD_TAGS = new Set<number>(Object.values(winBoxCdbFieldTag));
1221
1379
 
@@ -1583,8 +1741,16 @@ export interface AddDeviceArgs {
1583
1741
  force?: boolean;
1584
1742
  strict?: boolean;
1585
1743
  writeOptions?: WriteWinBoxCdbOptions;
1744
+ /** Return the stored password in `data.entry` (`--show-password`). Default `false`. */
1745
+ showPassword?: boolean;
1586
1746
  }
1587
1747
 
1748
+ export function addDevice(
1749
+ args: AddDeviceArgs & { showPassword: true },
1750
+ ): Promise<DevicesMutationEnvelope<DevicesShowItemWithPassword>>;
1751
+ export function addDevice(
1752
+ args: AddDeviceArgs,
1753
+ ): Promise<DevicesMutationEnvelope>;
1588
1754
  export async function addDevice(
1589
1755
  args: AddDeviceArgs,
1590
1756
  ): Promise<DevicesMutationEnvelope> {
@@ -1665,7 +1831,10 @@ export async function addDevice(
1665
1831
  args.target,
1666
1832
  cdbRecordIndex,
1667
1833
  existing !== undefined,
1668
- entryToShowItem(decodeWinBoxCdbEntry(record), cdbRecordIndex),
1834
+ projectShowItem(
1835
+ entryToShowItem(decodeWinBoxCdbEntry(record), cdbRecordIndex),
1836
+ args.showPassword === true,
1837
+ ),
1669
1838
  preserved,
1670
1839
  warnings,
1671
1840
  );
@@ -1686,6 +1855,8 @@ export interface SetDeviceArgs {
1686
1855
  savedPassword?: boolean;
1687
1856
  strict?: boolean;
1688
1857
  writeOptions?: WriteWinBoxCdbOptions;
1858
+ /** Return the stored password in `data.entry` (`--show-password`). Default `false`. */
1859
+ showPassword?: boolean;
1689
1860
  }
1690
1861
 
1691
1862
  /**
@@ -1695,6 +1866,12 @@ export interface SetDeviceArgs {
1695
1866
  * {@link requireSingleMatch}; a missing target throws `cdb/not-found-target`.
1696
1867
  * Unchanged fields and the comment's free-form prose are preserved verbatim.
1697
1868
  */
1869
+ export function setDevice(
1870
+ args: SetDeviceArgs & { showPassword: true },
1871
+ ): Promise<DevicesMutationEnvelope<DevicesShowItemWithPassword>>;
1872
+ export function setDevice(
1873
+ args: SetDeviceArgs,
1874
+ ): Promise<DevicesMutationEnvelope>;
1698
1875
  export async function setDevice(
1699
1876
  args: SetDeviceArgs,
1700
1877
  ): Promise<DevicesMutationEnvelope> {
@@ -1771,7 +1948,10 @@ export async function setDevice(
1771
1948
  args.target,
1772
1949
  match.index,
1773
1950
  true,
1774
- entryToShowItem(decodeWinBoxCdbEntry(record), match.index),
1951
+ projectShowItem(
1952
+ entryToShowItem(decodeWinBoxCdbEntry(record), match.index),
1953
+ args.showPassword === true,
1954
+ ),
1775
1955
  preserved,
1776
1956
  warnings,
1777
1957
  );
@@ -2068,6 +2248,7 @@ function renderMutationText(lines: string[], data: DevicesMutationData): void {
2068
2248
  }
2069
2249
  if (data.entry) {
2070
2250
  lines.push(`user: ${data.entry.user || "-"}`);
2251
+ lines.push(passwordLine(data.entry));
2071
2252
  lines.push(`group: ${data.entry.group || "-"}`);
2072
2253
  lines.push(`comment: ${data.entry.comment || "-"}`);
2073
2254
  }
@@ -2104,6 +2285,7 @@ function renderShowText(lines: string[], data: DevicesShowEnvelopeData): void {
2104
2285
  lines.push(`record-type: ${entry.recordTypeName} (${entry.recordType})`);
2105
2286
  lines.push(`cdb-index: ${entry.cdbRecordIndex}`);
2106
2287
  lines.push(`user: ${entry.user || "-"}`);
2288
+ lines.push(passwordLine(entry));
2107
2289
  lines.push(`saved-password: ${entry.savedPassword ? "yes" : "no"}`);
2108
2290
  lines.push(`group: ${entry.group || "-"}`);
2109
2291
  lines.push(`profile: ${entry.profile || "-"}`);
@@ -2129,9 +2311,27 @@ function renderShowText(lines: string[], data: DevicesShowEnvelopeData): void {
2129
2311
  }
2130
2312
  }
2131
2313
 
2314
+ /**
2315
+ * The `password:` text line. The cleartext value appears only when the entry
2316
+ * carries it (`--show-password`); otherwise it reads `(set, hidden)` or
2317
+ * `(not set)`.
2318
+ */
2319
+ function passwordLine(entry: DevicesShowItem): string {
2320
+ const shown = (entry as Partial<DevicesShowItemWithPassword>).password;
2321
+ if (typeof shown === "string") {
2322
+ // An empty password reads `(not set)`, never `-`, which is a valid password.
2323
+ return `password: ${shown === "" ? "(not set)" : shown}`;
2324
+ }
2325
+ return `password: ${entry.passwordSet ? "(set, hidden)" : "(not set)"}`;
2326
+ }
2327
+
2132
2328
  function describeField(field: WinBoxCdbField): string {
2133
2329
  const head = `tag=${field.tag} marker=0x${field.marker.toString(16).padStart(2, "0")} tcode=0x${field.tcode.toString(16).padStart(2, "0")}`;
2134
2330
  if (field.rawTail) {
2331
+ if (typeof field.value === "string") {
2332
+ // Withheld by `redactRecordPassword`: the tail may hold the password.
2333
+ return `${head} rawTail ${field.value}`;
2334
+ }
2135
2335
  const bytes = field.value instanceof Uint8Array ? field.value : null;
2136
2336
  const preview = bytes
2137
2337
  ? Array.from(bytes.subarray(0, 16))
package/src/index.ts CHANGED
@@ -220,6 +220,7 @@ export {
220
220
  type DevicesOutputFormat,
221
221
  type DevicesShowEnvelopeData,
222
222
  type DevicesShowItem,
223
+ type DevicesShowItemWithPassword,
223
224
  type DevicesWarning,
224
225
  type DevicesWhereClause,
225
226
  defaultCdbPath,
package/src/mcp/safety.ts CHANGED
@@ -11,10 +11,10 @@
11
11
  */
12
12
 
13
13
  import {
14
- type DevicesShowItem,
14
+ type DeviceRecordLookup,
15
15
  type LoadedCdb,
16
16
  loadCdb,
17
- showDevice,
17
+ lookupDeviceRecord,
18
18
  } from "../devices.ts";
19
19
  import { CentrsError } from "../errors.ts";
20
20
  import type { McpWritePolicy } from "../mcp.ts";
@@ -63,7 +63,7 @@ export async function loadAllowlistCdb(
63
63
  * MCP-specific framing of "this device is not on the allowlist", pointing at
64
64
  * `centrs_devices add` rather than leaking the CLI's `cdb/not-found-target`.
65
65
  *
66
- * Matches with {@link showDevice}, the matcher the core resolver dials with
66
+ * Matches with {@link lookupDeviceRecord}, the matcher the core resolver dials with
67
67
  * (target, `identity=`, `mac=`, `ip=`), so the record whose write policy is
68
68
  * checked here is the record the call then runs against (#444). A name that
69
69
  * matches more than one record fails `identity/ambiguous` rather than taking
@@ -75,9 +75,9 @@ export function resolveRegisteredTarget(
75
75
  ): RegisteredTarget {
76
76
  // Before the lookup, whose refusal echoes the target (#478).
77
77
  assertNoTargetPassword(target);
78
- let entry: DevicesShowItem;
78
+ let found: DeviceRecordLookup;
79
79
  try {
80
- entry = showDevice({ cdb, target }).data.entry;
80
+ found = lookupDeviceRecord({ cdb, target });
81
81
  } catch (error) {
82
82
  // Preserve registered identities; mask credential-shaped lookup failures.
83
83
  if (redactTargetInput(target) !== target) {
@@ -105,11 +105,12 @@ export function resolveRegisteredTarget(
105
105
  }
106
106
  throw error;
107
107
  }
108
+ const { entry } = found;
108
109
  return {
109
110
  target: entry.target,
110
111
  user: entry.user,
111
112
  group: entry.group,
112
- recordIndex: entry.cdbRecordIndex,
113
+ recordIndex: found.cdbRecordIndex,
113
114
  writePolicy: readWritePolicy(entry.comment),
114
115
  };
115
116
  }
package/src/mcp/tools.ts CHANGED
@@ -63,8 +63,6 @@ export interface McpOperationMeta {
63
63
  */
64
64
  type McpEnvelope = CentrsEnvelope;
65
65
 
66
- type EnvelopeWithData = CentrsEnvelope<unknown> & { data?: unknown };
67
-
68
66
  /**
69
67
  * Build a minimal centrs error envelope for failures that happen in the MCP
70
68
  * adapter itself (allowlist rejections, policy denials) — before or instead of
@@ -111,39 +109,6 @@ function requireConfirm(tool: string, confirmed: boolean | undefined): void {
111
109
  }
112
110
  }
113
111
 
114
- function redactEntry(entry: unknown): unknown {
115
- if (typeof entry !== "object" || entry === null) {
116
- return entry;
117
- }
118
- const { password, ...rest } = entry as Record<string, unknown>;
119
- return {
120
- ...rest,
121
- passwordSet: typeof password === "string" ? password.length > 0 : undefined,
122
- };
123
- }
124
-
125
- function redactDeviceSecrets(envelope: McpEnvelope): McpEnvelope {
126
- if (!envelope.ok) {
127
- return envelope;
128
- }
129
- const withData = envelope as EnvelopeWithData;
130
- const data = withData.data;
131
- if (typeof data !== "object" || data === null) {
132
- return envelope;
133
- }
134
- const deviceData = data as { entry?: unknown };
135
- if (deviceData.entry === undefined) {
136
- return envelope;
137
- }
138
- return {
139
- ...envelope,
140
- data: {
141
- ...data,
142
- entry: redactEntry(deviceData.entry),
143
- },
144
- };
145
- }
146
-
147
112
  // --- centrs_explain ---------------------------------------------------------
148
113
 
149
114
  export const explainInputShape = {
@@ -630,6 +595,9 @@ export async function handleDevices(
630
595
  context: { op: args.op },
631
596
  });
632
597
  }
598
+ // `show`/`add`/`set` entries come back with the password withheld and
599
+ // `passwordSet` in its place: core redacts by default and MCP never
600
+ // passes `showPassword` (#551).
633
601
  switch (args.op) {
634
602
  case "list":
635
603
  return listDevices({
@@ -643,29 +611,25 @@ export async function handleDevices(
643
611
  return listGroups({ cdb, withMembers: true });
644
612
  case "show": {
645
613
  const target = requireDevicesTarget(args, "show");
646
- return redactDeviceSecrets(
647
- showDevice({ cdb, target, env: config.env }),
648
- );
614
+ return showDevice({ cdb, target, env: config.env });
649
615
  }
650
616
  case "add": {
651
617
  requireConfirm("centrs_devices op=add", args.confirm);
652
618
  const target = requireDevicesTarget(args, "add");
653
- return redactDeviceSecrets(
654
- await addDevice({
655
- cdb,
656
- target,
657
- recordType: resolveRecordType(args.recordType),
658
- user: args.user,
659
- password: args.password,
660
- group: args.group,
661
- profile: args.profile,
662
- session: args.session,
663
- comment: args.comment,
664
- commentKvUpdates: buildGeoUpdates(args),
665
- savedPassword: args.savedPassword,
666
- force: args.force,
667
- }),
668
- );
619
+ return await addDevice({
620
+ cdb,
621
+ target,
622
+ recordType: resolveRecordType(args.recordType),
623
+ user: args.user,
624
+ password: args.password,
625
+ group: args.group,
626
+ profile: args.profile,
627
+ session: args.session,
628
+ comment: args.comment,
629
+ commentKvUpdates: buildGeoUpdates(args),
630
+ savedPassword: args.savedPassword,
631
+ force: args.force,
632
+ });
669
633
  }
670
634
  case "edit":
671
635
  // The interactive editor has no MCP analogue; modify via op=set.
@@ -673,20 +637,18 @@ export async function handleDevices(
673
637
  case "set": {
674
638
  requireConfirm("centrs_devices op=set", args.confirm);
675
639
  const target = requireDevicesTarget(args, "set");
676
- return redactDeviceSecrets(
677
- await setDevice({
678
- cdb,
679
- target,
680
- updates: [...buildGeoUpdates(args), ...(args.updates ?? [])],
681
- user: args.user,
682
- password: args.password,
683
- group: args.group,
684
- profile: args.profile,
685
- session: args.session,
686
- savedPassword: args.savedPassword,
687
- strict: args.strict,
688
- }),
689
- );
640
+ return await setDevice({
641
+ cdb,
642
+ target,
643
+ updates: [...buildGeoUpdates(args), ...(args.updates ?? [])],
644
+ user: args.user,
645
+ password: args.password,
646
+ group: args.group,
647
+ profile: args.profile,
648
+ session: args.session,
649
+ savedPassword: args.savedPassword,
650
+ strict: args.strict,
651
+ });
690
652
  }
691
653
  case "remove": {
692
654
  requireConfirm("centrs_devices op=remove", args.confirm);
@@ -4,7 +4,7 @@
4
4
  * This is the transport-agnostic seam every command (`retrieve`, and later
5
5
  * `execute` / `devices`) shares for turning a `<router>` argument into a
6
6
  * concrete target plus per-device option overrides. It reuses the devices CDB
7
- * primitives (`loadCdb` / `showDevice`) so decode/decrypt logic lives in one
7
+ * primitives (`loadCdb` / `lookupDeviceRecord`) so decode/decrypt logic lives in one
8
8
  * place, then layers the comment kv-soup contract on top.
9
9
  *
10
10
  * Comment-kv values arrive as RAW strings from {@link parseCommentKv}. This
@@ -32,8 +32,8 @@ import {
32
32
  type DevicesWarning,
33
33
  type LoadedCdb,
34
34
  loadCdb,
35
+ lookupDeviceRecord,
35
36
  resolveDevicesSettings,
36
- showDevice,
37
37
  } from "../devices.ts";
38
38
  import { CentrsError } from "../errors.ts";
39
39
  import { plannedProtocols, type RouterOsProtocol } from "../protocols/index.ts";
@@ -256,17 +256,18 @@ export async function resolveCdb(
256
256
  const defaults = findDefaultCreds(cdb);
257
257
 
258
258
  try {
259
- const envelope = showDevice({
259
+ // The raw record, not the `showDevice` envelope: that envelope withholds
260
+ // the password (#551), and dialing needs the stored credential.
261
+ const found = lookupDeviceRecord({
260
262
  cdb,
261
263
  target: input.targetInput,
262
264
  match: input.match,
263
265
  });
264
- const entry = envelope.data.entry;
265
- const warnings: ResolverWarning[] =
266
- envelope.warnings.map(warningFromDevices);
266
+ const entry = found.entry;
267
+ const warnings: ResolverWarning[] = found.warnings.map(warningFromDevices);
267
268
  const overrides = coerceCommentKv(
268
269
  entry.comment,
269
- entry.cdbRecordIndex,
270
+ found.cdbRecordIndex,
270
271
  warnings,
271
272
  );
272
273
  return {
@@ -275,7 +276,7 @@ export async function resolveCdb(
275
276
  mac: macFromComment(entry.comment, entry.target),
276
277
  username: entry.user,
277
278
  password: entry.password,
278
- recordIndex: entry.cdbRecordIndex,
279
+ recordIndex: found.cdbRecordIndex,
279
280
  overrides,
280
281
  warnings,
281
282
  ...(defaults ? { defaults } : {}),
@@ -12,7 +12,7 @@
12
12
  * Boundaries this module is careful about:
13
13
  * - The CDB is loaded + decrypted ONCE for the whole expansion.
14
14
  * - A positional is matched against the already-loaded entries with
15
- * {@link showDevice} (the identity matcher), NOT `resolveCdb` — `resolveCdb`
15
+ * {@link lookupDeviceRecord} (the identity matcher), NOT `resolveCdb` — `resolveCdb`
16
16
  * re-loads the CDB and, when a `__default__` record exists, returns a
17
17
  * synthetic resolution for ANY unmatched target, which would collide two
18
18
  * distinct ad-hoc literals on the default record's index. A non-matching
@@ -30,8 +30,8 @@
30
30
  import {
31
31
  type LoadedCdb,
32
32
  loadCdb,
33
+ lookupDeviceRecord,
33
34
  resolveDevicesSettings,
34
- showDevice,
35
35
  } from "../devices.ts";
36
36
  import { CentrsError } from "../errors.ts";
37
37
  import {
@@ -282,8 +282,8 @@ export async function expandSelection(
282
282
  for (const positional of selection.positionals) {
283
283
  assertNoTargetPassword(positional);
284
284
  try {
285
- const shown = showDevice({ cdb, target: positional });
286
- indices.add(shown.data.entry.cdbRecordIndex);
285
+ const shown = lookupDeviceRecord({ cdb, target: positional });
286
+ indices.add(shown.cdbRecordIndex);
287
287
  } catch (error) {
288
288
  // Preserve registered identities; mask credential-shaped lookup failures.
289
289
  if (redactTargetInput(positional) !== positional) {