@wlfi-agent/cli 1.4.17 → 1.4.18

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 (93) hide show
  1. package/Cargo.lock +5 -0
  2. package/README.md +61 -28
  3. package/crates/vault-cli-admin/src/io_utils.rs +149 -1
  4. package/crates/vault-cli-admin/src/main.rs +639 -16
  5. package/crates/vault-cli-admin/src/shared_config.rs +18 -18
  6. package/crates/vault-cli-admin/src/tui/token_rpc.rs +190 -3
  7. package/crates/vault-cli-admin/src/tui/utils.rs +59 -0
  8. package/crates/vault-cli-admin/src/tui.rs +1205 -120
  9. package/crates/vault-cli-agent/Cargo.toml +1 -0
  10. package/crates/vault-cli-agent/src/io_utils.rs +163 -2
  11. package/crates/vault-cli-agent/src/main.rs +648 -32
  12. package/crates/vault-cli-daemon/Cargo.toml +4 -0
  13. package/crates/vault-cli-daemon/src/main.rs +617 -67
  14. package/crates/vault-cli-daemon/src/relay_sync.rs +776 -4
  15. package/crates/vault-cli-daemon/tests/system_keychain_helper_acl.rs +5 -0
  16. package/crates/vault-daemon/src/daemon_parts/api_impl_and_utils.rs +32 -1
  17. package/crates/vault-daemon/src/persistence.rs +637 -100
  18. package/crates/vault-daemon/src/tests.rs +1013 -3
  19. package/crates/vault-daemon/src/tests_parts/part2.rs +99 -0
  20. package/crates/vault-daemon/src/tests_parts/part4.rs +11 -7
  21. package/crates/vault-domain/src/nonce.rs +4 -0
  22. package/crates/vault-domain/src/tests.rs +616 -0
  23. package/crates/vault-policy/src/engine.rs +55 -32
  24. package/crates/vault-policy/src/tests.rs +195 -0
  25. package/crates/vault-sdk-agent/src/lib.rs +415 -22
  26. package/crates/vault-signer/Cargo.toml +3 -0
  27. package/crates/vault-signer/src/lib.rs +266 -40
  28. package/crates/vault-transport-unix/src/lib.rs +653 -5
  29. package/crates/vault-transport-xpc/src/tests.rs +531 -3
  30. package/crates/vault-transport-xpc/tests/e2e_flow.rs +3 -0
  31. package/dist/cli.cjs +663 -190
  32. package/dist/cli.cjs.map +1 -1
  33. package/package.json +5 -2
  34. package/packages/cache/.turbo/turbo-build.log +20 -20
  35. package/packages/cache/coverage/clover.xml +529 -394
  36. package/packages/cache/coverage/coverage-final.json +2 -2
  37. package/packages/cache/coverage/index.html +21 -21
  38. package/packages/cache/coverage/src/client/index.html +1 -1
  39. package/packages/cache/coverage/src/client/index.ts.html +1 -1
  40. package/packages/cache/coverage/src/errors/index.html +1 -1
  41. package/packages/cache/coverage/src/errors/index.ts.html +12 -12
  42. package/packages/cache/coverage/src/index.html +1 -1
  43. package/packages/cache/coverage/src/index.ts.html +1 -1
  44. package/packages/cache/coverage/src/service/index.html +21 -21
  45. package/packages/cache/coverage/src/service/index.ts.html +769 -313
  46. package/packages/cache/dist/{chunk-QNK6GOTI.js → chunk-KC53LH5Z.js} +35 -2
  47. package/packages/cache/dist/chunk-KC53LH5Z.js.map +1 -0
  48. package/packages/cache/dist/{chunk-QF4XKEIA.cjs → chunk-UVU7VFE3.cjs} +35 -2
  49. package/packages/cache/dist/chunk-UVU7VFE3.cjs.map +1 -0
  50. package/packages/cache/dist/index.cjs +2 -2
  51. package/packages/cache/dist/index.js +1 -1
  52. package/packages/cache/dist/service/index.cjs +2 -2
  53. package/packages/cache/dist/service/index.js +1 -1
  54. package/packages/cache/node_modules/.bin/tsc +2 -2
  55. package/packages/cache/node_modules/.bin/tsserver +2 -2
  56. package/packages/cache/node_modules/.bin/tsup +2 -2
  57. package/packages/cache/node_modules/.bin/tsup-node +2 -2
  58. package/packages/cache/node_modules/.bin/vitest +4 -4
  59. package/packages/cache/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  60. package/packages/cache/src/service/index.test.ts +165 -19
  61. package/packages/cache/src/service/index.ts +38 -1
  62. package/packages/config/.turbo/turbo-build.log +4 -4
  63. package/packages/config/dist/index.cjs +0 -17
  64. package/packages/config/dist/index.cjs.map +1 -1
  65. package/packages/config/src/index.ts +0 -17
  66. package/packages/rpc/.turbo/turbo-build.log +11 -11
  67. package/packages/rpc/dist/index.cjs +0 -17
  68. package/packages/rpc/dist/index.cjs.map +1 -1
  69. package/packages/rpc/src/index.js +1 -0
  70. package/packages/ui/node_modules/.bin/tsc +2 -2
  71. package/packages/ui/node_modules/.bin/tsserver +2 -2
  72. package/packages/ui/node_modules/.bin/tsup +2 -2
  73. package/packages/ui/node_modules/.bin/tsup-node +2 -2
  74. package/scripts/install-cli-launcher.mjs +37 -0
  75. package/scripts/install-rust-binaries.mjs +47 -0
  76. package/scripts/run-tests-isolated.mjs +210 -0
  77. package/src/cli.ts +310 -50
  78. package/src/lib/admin-reset.ts +15 -30
  79. package/src/lib/admin-setup.ts +246 -55
  80. package/src/lib/agent-auth-migrate.ts +5 -1
  81. package/src/lib/asset-broadcast.ts +15 -4
  82. package/src/lib/config-amounts.ts +6 -4
  83. package/src/lib/hidden-tty-prompt.js +1 -0
  84. package/src/lib/hidden-tty-prompt.ts +105 -0
  85. package/src/lib/keychain.ts +1 -0
  86. package/src/lib/local-admin-access.ts +4 -29
  87. package/src/lib/rust.ts +129 -33
  88. package/src/lib/signed-tx.ts +1 -0
  89. package/src/lib/sudo.ts +15 -5
  90. package/src/lib/wallet-profile.ts +3 -0
  91. package/src/lib/wallet-setup.ts +52 -0
  92. package/packages/cache/dist/chunk-QF4XKEIA.cjs.map +0 -1
  93. package/packages/cache/dist/chunk-QNK6GOTI.js.map +0 -1
@@ -41,6 +41,7 @@ import {
41
41
  resolveWalletSetupCleanupAction,
42
42
  type WalletSetupPlan,
43
43
  } from './wallet-setup.js';
44
+ import { promptHiddenTty } from './hidden-tty-prompt.js';
44
45
 
45
46
  const DEFAULT_LAUNCH_DAEMON_LABEL = 'com.wlfi.agent.daemon';
46
47
  const DEFAULT_SIGNER_BACKEND = 'software';
@@ -69,6 +70,7 @@ interface AdminSetupOptions {
69
70
  nonInteractive?: boolean;
70
71
  plan?: boolean;
71
72
  yes?: boolean;
73
+ reuseExistingWallet?: boolean;
72
74
  printAgentAuthToken?: boolean;
73
75
  daemonSocket?: string;
74
76
  perTxMaxWei?: string;
@@ -170,6 +172,12 @@ export interface ExistingWalletSetupTarget {
170
172
  hasLegacyAgentAuthToken: boolean;
171
173
  }
172
174
 
175
+ interface ReusableWalletSetupTarget {
176
+ address?: string;
177
+ existingVaultKeyId: string;
178
+ existingVaultPublicKey: string;
179
+ }
180
+
173
181
  interface ConfirmAdminSetupOverwriteDeps {
174
182
  prompt?: (query: string) => Promise<string>;
175
183
  stderr?: Pick<NodeJS.WriteStream, 'write'>;
@@ -203,34 +211,13 @@ async function readTrimmedStdin(label: string): Promise<string> {
203
211
  return validateSecret(raw.replace(/[\r\n]+$/u, ''), label);
204
212
  }
205
213
 
206
- async function promptHidden(query: string): Promise<string> {
207
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
208
- throw new Error('vault password is required; use --vault-password-stdin or a local TTY prompt');
209
- }
210
-
211
- const rl = readline.createInterface({
212
- input: process.stdin,
213
- output: process.stdout,
214
- terminal: true,
215
- }) as readline.Interface & { stdoutMuted?: boolean; _writeToOutput?: (value: string) => void };
216
-
217
- rl.stdoutMuted = true;
218
- rl._writeToOutput = (value: string) => {
219
- if (value.includes(query)) {
220
- (rl as unknown as { output: NodeJS.WritableStream }).output.write(value);
221
- return;
222
- }
223
- if (!rl.stdoutMuted) {
224
- (rl as unknown as { output: NodeJS.WritableStream }).output.write(value);
225
- }
226
- };
227
-
228
- const answer = await new Promise<string>((resolve) => {
229
- rl.question(query, resolve);
230
- });
231
- rl.close();
232
- process.stdout.write('\n');
233
- return validateSecret(answer, 'vault password');
214
+ async function promptHidden(
215
+ query: string,
216
+ label = 'vault password',
217
+ nonInteractiveError = 'vault password is required; use --vault-password-stdin or a local TTY prompt',
218
+ ): Promise<string> {
219
+ const answer = await promptHiddenTty(query, nonInteractiveError);
220
+ return validateSecret(answer, label);
234
221
  }
235
222
 
236
223
  async function promptVisible(query: string, nonInteractiveError: string): Promise<string> {
@@ -260,6 +247,7 @@ function deriveWalletAddress(vaultPublicKey: string | undefined): string | undef
260
247
  return publicKeyToAddress(
261
248
  (normalized.startsWith('0x') ? normalized : `0x${normalized}`) as Hex,
262
249
  );
250
+ /* c8 ignore next 2 -- viem currently normalizes malformed public-key strings instead of throwing; catch is defensive */
263
251
  } catch {
264
252
  return undefined;
265
253
  }
@@ -284,8 +272,33 @@ export function resolveExistingWalletSetupTarget(
284
272
  };
285
273
  }
286
274
 
275
+ function resolveReusableWalletSetupTarget(config: WlfiConfig): ReusableWalletSetupTarget {
276
+ let walletProfile: ReturnType<typeof resolveWalletProfile>;
277
+ try {
278
+ walletProfile = resolveWalletProfile(config);
279
+ } catch (error) {
280
+ throw new Error(
281
+ `--reuse-existing-wallet requires a local wallet to reuse; ${renderError(error)}`,
282
+ );
283
+ }
284
+
285
+ const existingVaultKeyId = walletProfile.vaultKeyId?.trim();
286
+ const existingVaultPublicKey = walletProfile.vaultPublicKey?.trim();
287
+ if (!existingVaultKeyId || !existingVaultPublicKey) {
288
+ throw new Error(
289
+ '--reuse-existing-wallet requires wallet.vaultKeyId and wallet.vaultPublicKey in the local wallet profile',
290
+ );
291
+ }
292
+
293
+ return {
294
+ address: walletProfile.address?.trim() || deriveWalletAddress(existingVaultPublicKey),
295
+ existingVaultKeyId,
296
+ existingVaultPublicKey,
297
+ };
298
+ }
299
+
287
300
  export async function confirmAdminSetupOverwrite(
288
- options: Pick<AdminSetupOptions, 'yes' | 'nonInteractive'>,
301
+ options: Pick<AdminSetupOptions, 'yes' | 'nonInteractive' | 'reuseExistingWallet'>,
289
302
  config: WlfiConfig,
290
303
  deps: ConfirmAdminSetupOverwriteDeps = {},
291
304
  ): Promise<void> {
@@ -295,13 +308,17 @@ export async function confirmAdminSetupOverwrite(
295
308
  }
296
309
  if (options.nonInteractive) {
297
310
  throw new Error(
298
- '`wlfi-agent admin setup` would overwrite the existing wallet; rerun with --yes in non-interactive mode',
311
+ options.reuseExistingWallet
312
+ ? '`wlfi-agent admin setup --reuse-existing-wallet` would refresh the existing local wallet metadata and agent credentials; rerun with --yes in non-interactive mode'
313
+ : '`wlfi-agent admin setup` would overwrite the existing wallet; rerun with --yes in non-interactive mode',
299
314
  );
300
315
  }
301
316
 
302
317
  const stderr = deps.stderr ?? process.stderr;
303
318
  stderr.write(
304
- 'warning: admin setup will overwrite the current local wallet metadata and agent credentials.\n',
319
+ options.reuseExistingWallet
320
+ ? 'warning: admin setup will reuse the current vault and refresh the local wallet metadata and agent credentials.\n'
321
+ : 'warning: admin setup will overwrite the current local wallet metadata and agent credentials.\n',
305
322
  );
306
323
  if (existing.address) {
307
324
  stderr.write(`current address: ${existing.address}\n`);
@@ -313,15 +330,21 @@ export async function confirmAdminSetupOverwrite(
313
330
  stderr.write('legacy agent auth token is still present in config.json\n');
314
331
  }
315
332
 
333
+ const confirmationToken = options.reuseExistingWallet ? 'REUSE' : 'OVERWRITE';
334
+ const confirmationPrompt = options.reuseExistingWallet
335
+ ? 'Type REUSE to reattach the current local vault: '
336
+ : 'Type OVERWRITE to replace the current local wallet: ';
316
337
  const confirmation = await (
317
338
  deps.prompt ??
318
339
  ((query: string) =>
319
340
  promptVisible(
320
341
  query,
321
- '`wlfi-agent admin setup` requires --yes in non-interactive environments when overwriting an existing wallet',
342
+ options.reuseExistingWallet
343
+ ? '`wlfi-agent admin setup --reuse-existing-wallet` requires --yes in non-interactive environments when reusing an existing wallet'
344
+ : '`wlfi-agent admin setup` requires --yes in non-interactive environments when overwriting an existing wallet',
322
345
  ))
323
- )('Type OVERWRITE to replace the current local wallet: ');
324
- if (confirmation !== 'OVERWRITE') {
346
+ )(confirmationPrompt);
347
+ if (confirmation !== confirmationToken) {
325
348
  throw new Error('admin setup aborted');
326
349
  }
327
350
  }
@@ -357,7 +380,9 @@ export async function resolveAdminSetupVaultPassword(
357
380
  'vault password is required in non-interactive mode; use --vault-password-stdin',
358
381
  );
359
382
  }
360
- return promptForVaultPassword('Vault password (input hidden; nothing will be echoed): ');
383
+ return promptForVaultPassword(
384
+ 'Vault password (input hidden; this unlocks the wallet, not sudo): ',
385
+ );
361
386
  }
362
387
 
363
388
  function resolveDaemonSocket(optionValue: string | undefined): string {
@@ -398,6 +423,9 @@ export function createAdminSetupPlan(
398
423
  }
399
424
 
400
425
  const existingWallet = resolveExistingWalletSetupTarget(config);
426
+ const reusableWallet = options.reuseExistingWallet
427
+ ? resolveReusableWalletSetupTarget(config)
428
+ : null;
401
429
 
402
430
  return {
403
431
  command: 'setup',
@@ -442,6 +470,8 @@ export function createAdminSetupPlan(
442
470
  recipient: options.recipient,
443
471
  attachPolicyId: options.attachPolicyId,
444
472
  attachBootstrapPolicies: options.attachBootstrapPolicies,
473
+ existingVaultKeyId: reusableWallet?.existingVaultKeyId,
474
+ existingVaultPublicKey: reusableWallet?.existingVaultPublicKey,
445
475
  bootstrapOutputPath: options.bootstrapOutput,
446
476
  deleteBootstrapOutput: options.deleteBootstrapOutput,
447
477
  },
@@ -851,7 +881,9 @@ async function daemonAcceptsVaultPassword(
851
881
  const sudoSession = createSudoSession({
852
882
  promptPassword: async () =>
853
883
  await promptHidden(
854
- 'Root password (input hidden; required to install or recover the root daemon): ',
884
+ 'macOS admin password for sudo (input hidden; required to install or recover the root daemon): ',
885
+ 'macOS admin password for sudo',
886
+ 'macOS admin password for sudo is required; rerun on a local TTY',
855
887
  ),
856
888
  });
857
889
 
@@ -873,6 +905,158 @@ async function managedStateFileExists(stateFile: string): Promise<boolean> {
873
905
  );
874
906
  }
875
907
 
908
+ async function inspectManagedState(
909
+ stateFile: string,
910
+ showProgress: boolean,
911
+ message = 'Inspecting managed daemon state',
912
+ ): Promise<boolean> {
913
+ await sudoSession.prime();
914
+ const stateProbeProgress = createProgress(message, showProgress);
915
+ let stateExists: boolean;
916
+ try {
917
+ stateExists = await managedStateFileExists(stateFile);
918
+ stateProbeProgress.succeed(
919
+ stateExists ? 'Managed daemon state already exists' : 'No managed daemon state found',
920
+ );
921
+ } catch (error) {
922
+ stateProbeProgress.fail();
923
+ throw error;
924
+ }
925
+ return stateExists;
926
+ }
927
+
928
+ function createManagedStatePasswordMismatchError(stateFile: string): Error {
929
+ return new Error(
930
+ `managed daemon state already exists at ${stateFile} and is encrypted with a different vault password. Re-run setup with the original vault password, or remove/reset the managed daemon state before initializing a fresh wallet.`,
931
+ );
932
+ }
933
+
934
+ function isManagedStatePasswordMismatch(output: string): boolean {
935
+ return /failed to decrypt state|wrong password or tampered file|authentication failed/iu.test(
936
+ output,
937
+ );
938
+ }
939
+
940
+ async function managedStateAcceptsRequestedVaultPassword(
941
+ config: WlfiConfig,
942
+ daemonSocket: string,
943
+ stateFile: string,
944
+ vaultPassword: string,
945
+ ): Promise<boolean> {
946
+ const installPreconditions = assertManagedDaemonInstallPreconditions(
947
+ config,
948
+ daemonSocket,
949
+ stateFile,
950
+ );
951
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'wlfi-managed-state-probe-'));
952
+ const tempSocket = path.join(tempRoot, 'daemon.sock');
953
+ const allowedUid = String(process.getuid?.() ?? process.geteuid?.() ?? 0);
954
+ const probeScript = [
955
+ 'set -euo pipefail',
956
+ 'vault_password="$(cat)"',
957
+ 'daemon_bin="$1"',
958
+ 'state_file="$2"',
959
+ 'daemon_socket="$3"',
960
+ 'admin_uid="$4"',
961
+ 'agent_uid="$5"',
962
+ 'signer_backend="$6"',
963
+ '"$daemon_bin" \\',
964
+ ' --non-interactive \\',
965
+ ' --vault-password-stdin \\',
966
+ ' --state-file "$state_file" \\',
967
+ ' --daemon-socket "$daemon_socket" \\',
968
+ ' --signer-backend "$signer_backend" \\',
969
+ ' --allow-admin-euid "$admin_uid" \\',
970
+ ' --allow-agent-euid "$agent_uid" <<<"$vault_password" &',
971
+ 'child="$!"',
972
+ 'for _ in $(seq 1 20); do',
973
+ ' if ! kill -0 "$child" 2>/dev/null; then',
974
+ ' wait "$child"',
975
+ ' exit $?',
976
+ ' fi',
977
+ ' sleep 0.25',
978
+ 'done',
979
+ 'kill "$child" >/dev/null 2>&1 || true',
980
+ 'wait "$child" >/dev/null 2>&1 || true',
981
+ 'exit 0',
982
+ ].join('\n');
983
+
984
+ try {
985
+ const result = await sudoSession.run(
986
+ [
987
+ '/bin/bash',
988
+ '-lc',
989
+ probeScript,
990
+ '--',
991
+ installPreconditions.daemonBin,
992
+ stateFile,
993
+ tempSocket,
994
+ allowedUid,
995
+ allowedUid,
996
+ DEFAULT_SIGNER_BACKEND,
997
+ ],
998
+ {
999
+ stdin: `${vaultPassword}\n`,
1000
+ },
1001
+ );
1002
+ if (result.code === 0) {
1003
+ return true;
1004
+ }
1005
+
1006
+ const combinedOutput = `${result.stderr}\n${result.stdout}`.trim();
1007
+ if (isManagedStatePasswordMismatch(combinedOutput)) {
1008
+ return false;
1009
+ }
1010
+ throw new Error(
1011
+ combinedOutput ||
1012
+ `failed to validate the managed daemon state with the requested vault password (exit code ${result.code})`,
1013
+ );
1014
+ } finally {
1015
+ fs.rmSync(tempRoot, { recursive: true, force: true });
1016
+ }
1017
+ }
1018
+
1019
+ async function assertManagedStateMatchesRequestedVaultPasswordBeforeInstall(
1020
+ config: WlfiConfig,
1021
+ daemonSocket: string,
1022
+ stateFile: string,
1023
+ vaultPassword: string,
1024
+ showProgress: boolean,
1025
+ ): Promise<void> {
1026
+ const stateExists = await inspectManagedState(
1027
+ stateFile,
1028
+ showProgress,
1029
+ 'Inspecting managed daemon state before install',
1030
+ );
1031
+ if (!stateExists) {
1032
+ return;
1033
+ }
1034
+
1035
+ const verifyProgress = createProgress(
1036
+ 'Verifying the requested vault password against the managed daemon state',
1037
+ showProgress,
1038
+ );
1039
+ let accepted: boolean;
1040
+ try {
1041
+ accepted = await managedStateAcceptsRequestedVaultPassword(
1042
+ config,
1043
+ daemonSocket,
1044
+ stateFile,
1045
+ vaultPassword,
1046
+ );
1047
+ } catch (error) {
1048
+ verifyProgress.fail();
1049
+ throw error;
1050
+ }
1051
+
1052
+ if (!accepted) {
1053
+ verifyProgress.fail('Existing daemon password does not unlock the stored daemon state');
1054
+ throw createManagedStatePasswordMismatchError(stateFile);
1055
+ }
1056
+
1057
+ verifyProgress.succeed('Requested vault password unlocks the existing managed daemon state');
1058
+ }
1059
+
876
1060
  async function waitForTrustedDaemonSocket(targetPath: string, timeoutMs = 15_000): Promise<void> {
877
1061
  async function canConnect(socketPath: string): Promise<boolean> {
878
1062
  return new Promise<boolean>((resolve) => {
@@ -1109,6 +1293,9 @@ async function runAdminSetup(options: AdminSetupOptions): Promise<void> {
1109
1293
  const config = readConfig();
1110
1294
  const daemonSocket = resolveDaemonSocket(options.daemonSocket);
1111
1295
  const stateFile = resolveStateFile();
1296
+ const reusableWallet = options.reuseExistingWallet
1297
+ ? resolveReusableWalletSetupTarget(config)
1298
+ : null;
1112
1299
  assertManagedDaemonInstallPreconditions(config, daemonSocket, stateFile);
1113
1300
  await confirmAdminSetupOverwrite(options, config);
1114
1301
  const vaultPassword = await resolveAdminSetupVaultPassword(options);
@@ -1176,10 +1363,17 @@ async function runAdminSetup(options: AdminSetupOptions): Promise<void> {
1176
1363
  }
1177
1364
  if (!options.json && typeof process.geteuid === 'function' && process.geteuid() !== 0) {
1178
1365
  process.stderr.write(
1179
- 'Root password required: setup must install or recover the root LaunchDaemon and store the daemon password in System Keychain.\n',
1366
+ 'macOS admin password required: setup uses sudo to install or recover the root LaunchDaemon and store the daemon password in System Keychain.\n',
1180
1367
  );
1181
1368
  }
1182
1369
  await sudoSession.prime();
1370
+ await assertManagedStateMatchesRequestedVaultPasswordBeforeInstall(
1371
+ config,
1372
+ daemonSocket,
1373
+ stateFile,
1374
+ vaultPassword,
1375
+ showProgress,
1376
+ );
1183
1377
  const installProgress = createProgress('Installing and restarting daemon', showProgress);
1184
1378
  try {
1185
1379
  daemon = await installLaunchDaemon(config, daemonSocket, stateFile, vaultPassword);
@@ -1212,29 +1406,15 @@ async function runAdminSetup(options: AdminSetupOptions): Promise<void> {
1212
1406
  if (!daemonAcceptedPassword) {
1213
1407
  if (existingDaemonRejectedPassword) {
1214
1408
  authProgress.fail('Existing daemon password does not unlock the stored daemon state');
1215
- throw new Error(
1216
- `managed daemon state already exists at ${stateFile} and is encrypted with a different vault password. Re-run setup with the original vault password, or remove/reset the managed daemon state before initializing a fresh wallet.`,
1217
- );
1409
+ throw createManagedStatePasswordMismatchError(stateFile);
1218
1410
  }
1219
1411
 
1220
1412
  authProgress.info('Daemon rejected the requested vault password; inspecting managed state');
1221
- const stateProbeProgress = createProgress('Inspecting managed daemon state', showProgress);
1222
- let stateExists: boolean;
1223
- try {
1224
- stateExists = await managedStateFileExists(stateFile);
1225
- stateProbeProgress.succeed(
1226
- stateExists ? 'Managed daemon state already exists' : 'No managed daemon state found',
1227
- );
1228
- } catch (error) {
1229
- stateProbeProgress.fail();
1230
- throw error;
1231
- }
1413
+ const stateExists = await inspectManagedState(stateFile, showProgress);
1232
1414
 
1233
1415
  if (stateExists) {
1234
1416
  authProgress.fail('Existing daemon password does not unlock the stored daemon state');
1235
- throw new Error(
1236
- `managed daemon state already exists at ${stateFile} and is encrypted with a different vault password. Re-run setup with the original vault password, or remove/reset the managed daemon state before initializing a fresh wallet.`,
1237
- );
1417
+ throw createManagedStatePasswordMismatchError(stateFile);
1238
1418
  }
1239
1419
 
1240
1420
  authProgress.fail(
@@ -1243,7 +1423,7 @@ async function runAdminSetup(options: AdminSetupOptions): Promise<void> {
1243
1423
 
1244
1424
  if (!options.json && typeof process.geteuid === 'function' && process.geteuid() !== 0) {
1245
1425
  process.stderr.write(
1246
- 'Root password required: setup must reinstall the root LaunchDaemon to rotate the managed daemon password.\n',
1426
+ 'macOS admin password required: setup uses sudo to reinstall the root LaunchDaemon and rotate the managed daemon password.\n',
1247
1427
  );
1248
1428
  }
1249
1429
  await sudoSession.prime();
@@ -1333,6 +1513,8 @@ async function runAdminSetup(options: AdminSetupOptions): Promise<void> {
1333
1513
  recipient: options.recipient,
1334
1514
  attachPolicyId: options.attachPolicyId,
1335
1515
  attachBootstrapPolicies: options.attachBootstrapPolicies,
1516
+ existingVaultKeyId: reusableWallet?.existingVaultKeyId,
1517
+ existingVaultPublicKey: reusableWallet?.existingVaultPublicKey,
1336
1518
  bootstrapOutputPath: bootstrapOutput.path,
1337
1519
  });
1338
1520
 
@@ -1460,6 +1642,8 @@ export function buildAdminSetupBootstrapInvocation(input: {
1460
1642
  recipient?: string;
1461
1643
  attachPolicyId?: string[];
1462
1644
  attachBootstrapPolicies?: boolean;
1645
+ existingVaultKeyId?: string;
1646
+ existingVaultPublicKey?: string;
1463
1647
  bootstrapOutputPath: string;
1464
1648
  }): { args: string[]; stdin: string } {
1465
1649
  return {
@@ -1481,6 +1665,8 @@ export function buildAdminSetupBootstrapInvocation(input: {
1481
1665
  recipient: input.recipient,
1482
1666
  attachPolicyId: input.attachPolicyId,
1483
1667
  attachBootstrapPolicies: input.attachBootstrapPolicies,
1668
+ existingVaultKeyId: input.existingVaultKeyId,
1669
+ existingVaultPublicKey: input.existingVaultPublicKey,
1484
1670
  bootstrapOutputPath: input.bootstrapOutputPath,
1485
1671
  }),
1486
1672
  stdin: `${validateSecret(input.vaultPassword, 'vault password')}\n`,
@@ -1552,6 +1738,11 @@ export async function runAdminSetupCli(argv: string[]): Promise<void> {
1552
1738
  .option('--vault-password-stdin', 'Read vault password from stdin', false)
1553
1739
  .option('--non-interactive', 'Disable password prompts', false)
1554
1740
  .option('-y, --yes', 'Skip the overwrite confirmation prompt', false)
1741
+ .option(
1742
+ '--reuse-existing-wallet',
1743
+ 'Reuse the current local vault instead of generating a fresh wallet',
1744
+ false,
1745
+ )
1555
1746
  .option('--daemon-socket <path>', 'Daemon unix socket path')
1556
1747
  .option('--per-tx-max-wei <wei>', 'Per-transaction max spend in wei')
1557
1748
  .option('--daily-max-wei <wei>', 'Daily max spend in wei')
@@ -62,8 +62,12 @@ function resolveConfiguredAgentKeyId(
62
62
  if (explicitAgentKeyId) {
63
63
  return undefined;
64
64
  }
65
+ const renderedError =
66
+ error instanceof Error
67
+ ? error.message
68
+ : /* c8 ignore next -- assertValidAgentKeyId throws Error objects */ String(error);
65
69
  throw new Error(
66
- (error instanceof Error ? error.message : String(error)) +
70
+ renderedError +
67
71
  '; pass --agent-key-id to migrate the legacy config secret explicitly'
68
72
  );
69
73
  }
@@ -94,6 +94,16 @@ export interface WaitForOnchainReceiptDeps {
94
94
  sleep?: (ms: number) => Promise<void>;
95
95
  }
96
96
 
97
+ export function resolveEstimatedPriorityFeePerGasWei(fees: BroadcastFeeEstimate): bigint {
98
+ const resolved = fees.maxPriorityFeePerGas ?? fees.gasPrice;
99
+ if (resolved === null || resolved <= 0n) {
100
+ throw new Error(
101
+ 'Could not determine maxPriorityFeePerGas; pass --max-priority-fee-per-gas-wei',
102
+ );
103
+ }
104
+ return resolved;
105
+ }
106
+
97
107
  export function encodeErc20TransferData(recipient: Address, amountWei: bigint): Hex {
98
108
  return encodeFunctionData({
99
109
  abi: erc20Abi,
@@ -128,14 +138,15 @@ export async function resolveAssetBroadcastPlan(
128
138
  const fees = await deps.estimateFees(input.rpcUrl);
129
139
  const resolvedFees = fees as BroadcastFeeEstimate;
130
140
  const maxFeePerGasWei = input.maxFeePerGasWei ?? (resolvedFees.maxFeePerGas ?? resolvedFees.gasPrice);
131
- const maxPriorityFeePerGasWei =
132
- input.maxPriorityFeePerGasWei
133
- ?? (resolvedFees.maxPriorityFeePerGas ?? resolvedFees.gasPrice ?? 0n);
134
141
 
135
142
  if (maxFeePerGasWei === null || maxFeePerGasWei <= 0n) {
136
143
  throw new Error('Could not determine maxFeePerGas; pass --max-fee-per-gas-wei');
137
144
  }
138
145
 
146
+ const maxPriorityFeePerGasWei =
147
+ input.maxPriorityFeePerGasWei
148
+ ?? resolveEstimatedPriorityFeePerGasWei(resolvedFees);
149
+
139
150
  return {
140
151
  rpcUrl: input.rpcUrl,
141
152
  chainId: input.chainId,
@@ -166,7 +177,7 @@ export async function completeAssetBroadcast(
166
177
  to: plan.to,
167
178
  chainId: plan.chainId,
168
179
  nonce: plan.nonce,
169
- allowHigherNonce: true,
180
+ allowHigherNonce: false,
170
181
  value: plan.valueWei,
171
182
  data: plan.dataHex,
172
183
  gasLimit: plan.gasLimit,
@@ -181,9 +181,11 @@ export function rewriteAmountPolicyErrorMessage(
181
181
  minAmountLiteral: string,
182
182
  minAmountWei: string | undefined,
183
183
  maxAmountWei: string,
184
- ) =>
185
- `requires manual approval for requested amount ${formatAmount(requestedAmountWei)} within range ${
186
- minAmountLiteral === 'None' ? 'None' : `Some(${formatAmount(minAmountWei ?? '0')})`
187
- }..=${formatAmount(maxAmountWei)}`,
184
+ ) => {
185
+ const minAmountDisplay =
186
+ /* c8 ignore next -- both None and Some(...) paths are exercised, but c8 misattributes this ternary under --experimental-strip-types */
187
+ minAmountLiteral === 'None' ? 'None' : `Some(${formatAmount(minAmountWei ?? '0')})`;
188
+ return `requires manual approval for requested amount ${formatAmount(requestedAmountWei)} within range ${minAmountDisplay}..=${formatAmount(maxAmountWei)}`;
189
+ },
188
190
  );
189
191
  }
@@ -0,0 +1 @@
1
+ export * from './hidden-tty-prompt.ts';
@@ -0,0 +1,105 @@
1
+ interface HiddenPromptInput {
2
+ isRaw?: boolean;
3
+ isTTY?: boolean;
4
+ on(event: 'data', listener: (chunk: Buffer | string) => void): this;
5
+ on(event: 'error', listener: (error: Error) => void): this;
6
+ removeListener(event: 'data', listener: (chunk: Buffer | string) => void): this;
7
+ removeListener(event: 'error', listener: (error: Error) => void): this;
8
+ pause?(): void;
9
+ resume?(): void;
10
+ setRawMode?(mode: boolean): void;
11
+ }
12
+
13
+ interface HiddenPromptOutput {
14
+ isTTY?: boolean;
15
+ write(chunk: string): boolean;
16
+ }
17
+
18
+ interface HiddenPromptDeps {
19
+ input?: HiddenPromptInput;
20
+ output?: HiddenPromptOutput;
21
+ }
22
+
23
+ export async function promptHiddenTty(
24
+ query: string,
25
+ nonInteractiveError: string,
26
+ deps: HiddenPromptDeps = {},
27
+ ): Promise<string> {
28
+ const input = deps.input ?? (process.stdin as unknown as HiddenPromptInput);
29
+ const output = deps.output ?? (process.stderr as unknown as HiddenPromptOutput);
30
+
31
+ if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== 'function') {
32
+ throw new Error(nonInteractiveError);
33
+ }
34
+
35
+ output.write('\r\u001b[2K');
36
+ output.write(query);
37
+ const wasRaw = Boolean(input.isRaw);
38
+ if (!wasRaw) {
39
+ input.setRawMode(true);
40
+ }
41
+ input.resume?.();
42
+
43
+ return await new Promise<string>((resolve, reject) => {
44
+ let answer = '';
45
+ let settled = false;
46
+
47
+ const cleanup = () => {
48
+ input.removeListener('data', onData);
49
+ input.removeListener('error', onError);
50
+ if (!wasRaw) {
51
+ input.setRawMode?.(false);
52
+ }
53
+ input.pause?.();
54
+ output.write('\n');
55
+ };
56
+
57
+ const finish = (callback: () => void) => {
58
+ if (settled) {
59
+ return;
60
+ }
61
+ settled = true;
62
+ cleanup();
63
+ callback();
64
+ };
65
+
66
+ const onError = (error: Error) => {
67
+ finish(() => reject(error));
68
+ };
69
+
70
+ const onData = (chunk: Buffer | string) => {
71
+ const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
72
+ if (text.includes('\u001b')) {
73
+ return;
74
+ }
75
+
76
+ for (const char of text) {
77
+ if (char === '\r' || char === '\n') {
78
+ finish(() => resolve(answer));
79
+ return;
80
+ }
81
+ if (char === '\u0003') {
82
+ finish(() => reject(new Error('prompt canceled')));
83
+ return;
84
+ }
85
+ if (char === '\u007f' || char === '\b') {
86
+ const chars = Array.from(answer);
87
+ chars.pop();
88
+ answer = chars.join('');
89
+ continue;
90
+ }
91
+ if (char === '\u0015') {
92
+ answer = '';
93
+ continue;
94
+ }
95
+ if (/[\u0000-\u001f\u007f]/u.test(char)) {
96
+ continue;
97
+ }
98
+ answer += char;
99
+ }
100
+ };
101
+
102
+ input.on('data', onData);
103
+ input.on('error', onError);
104
+ });
105
+ }
@@ -89,6 +89,7 @@ function assertValidKeychainAccount(account: string): string {
89
89
 
90
90
  function assertValidKeychainService(service: string): string {
91
91
  const normalized = service.trim();
92
+ /* c8 ignore next 6 -- public APIs use fixed non-empty service identifiers, so these guards are unreachable through exported entry points */
92
93
  if (!normalized) {
93
94
  throw new Error('keychain service is required');
94
95
  }