@wlfi-agent/cli 1.4.16 → 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.
- package/Cargo.lock +26 -20
- package/Cargo.toml +1 -1
- package/README.md +61 -28
- package/crates/vault-cli-admin/src/io_utils.rs +149 -1
- package/crates/vault-cli-admin/src/main.rs +639 -16
- package/crates/vault-cli-admin/src/shared_config.rs +18 -18
- package/crates/vault-cli-admin/src/tui/token_rpc.rs +190 -3
- package/crates/vault-cli-admin/src/tui/utils.rs +59 -0
- package/crates/vault-cli-admin/src/tui.rs +1205 -120
- package/crates/vault-cli-agent/Cargo.toml +1 -0
- package/crates/vault-cli-agent/src/io_utils.rs +163 -2
- package/crates/vault-cli-agent/src/main.rs +648 -32
- package/crates/vault-cli-daemon/Cargo.toml +4 -0
- package/crates/vault-cli-daemon/src/main.rs +617 -67
- package/crates/vault-cli-daemon/src/relay_sync.rs +776 -4
- package/crates/vault-cli-daemon/tests/system_keychain_helper_acl.rs +5 -0
- package/crates/vault-daemon/src/daemon_parts/api_impl_and_utils.rs +32 -1
- package/crates/vault-daemon/src/persistence.rs +637 -100
- package/crates/vault-daemon/src/tests.rs +1013 -3
- package/crates/vault-daemon/src/tests_parts/part2.rs +99 -0
- package/crates/vault-daemon/src/tests_parts/part4.rs +11 -7
- package/crates/vault-domain/src/nonce.rs +4 -0
- package/crates/vault-domain/src/tests.rs +616 -0
- package/crates/vault-policy/src/engine.rs +55 -32
- package/crates/vault-policy/src/tests.rs +195 -0
- package/crates/vault-sdk-agent/src/lib.rs +415 -22
- package/crates/vault-signer/Cargo.toml +3 -0
- package/crates/vault-signer/src/lib.rs +266 -40
- package/crates/vault-transport-unix/src/lib.rs +653 -5
- package/crates/vault-transport-xpc/src/tests.rs +531 -3
- package/crates/vault-transport-xpc/tests/e2e_flow.rs +3 -0
- package/dist/cli.cjs +663 -190
- package/dist/cli.cjs.map +1 -1
- package/package.json +5 -2
- package/packages/cache/.turbo/turbo-build.log +53 -52
- package/packages/cache/coverage/clover.xml +529 -394
- package/packages/cache/coverage/coverage-final.json +2 -2
- package/packages/cache/coverage/index.html +21 -21
- package/packages/cache/coverage/src/client/index.html +1 -1
- package/packages/cache/coverage/src/client/index.ts.html +1 -1
- package/packages/cache/coverage/src/errors/index.html +1 -1
- package/packages/cache/coverage/src/errors/index.ts.html +12 -12
- package/packages/cache/coverage/src/index.html +1 -1
- package/packages/cache/coverage/src/index.ts.html +1 -1
- package/packages/cache/coverage/src/service/index.html +21 -21
- package/packages/cache/coverage/src/service/index.ts.html +769 -313
- package/packages/cache/dist/{chunk-QNK6GOTI.js → chunk-KC53LH5Z.js} +35 -2
- package/packages/cache/dist/chunk-KC53LH5Z.js.map +1 -0
- package/packages/cache/dist/{chunk-QF4XKEIA.cjs → chunk-UVU7VFE3.cjs} +35 -2
- package/packages/cache/dist/chunk-UVU7VFE3.cjs.map +1 -0
- package/packages/cache/dist/index.cjs +2 -2
- package/packages/cache/dist/index.js +1 -1
- package/packages/cache/dist/service/index.cjs +2 -2
- package/packages/cache/dist/service/index.js +1 -1
- package/packages/cache/node_modules/.bin/tsc +2 -2
- package/packages/cache/node_modules/.bin/tsserver +2 -2
- package/packages/cache/node_modules/.bin/tsup +2 -2
- package/packages/cache/node_modules/.bin/tsup-node +2 -2
- package/packages/cache/node_modules/.bin/vitest +4 -4
- package/packages/cache/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/packages/cache/src/service/index.test.ts +165 -19
- package/packages/cache/src/service/index.ts +38 -1
- package/packages/config/.turbo/turbo-build.log +18 -17
- package/packages/config/dist/index.cjs +0 -17
- package/packages/config/dist/index.cjs.map +1 -1
- package/packages/config/src/index.ts +0 -17
- package/packages/rpc/.turbo/turbo-build.log +32 -31
- package/packages/rpc/dist/index.cjs +0 -17
- package/packages/rpc/dist/index.cjs.map +1 -1
- package/packages/rpc/src/index.js +1 -0
- package/packages/ui/.turbo/turbo-build.log +44 -43
- package/packages/ui/dist/components/badge.d.ts +1 -1
- package/packages/ui/dist/components/button.d.ts +1 -1
- package/packages/ui/node_modules/.bin/tsc +2 -2
- package/packages/ui/node_modules/.bin/tsserver +2 -2
- package/packages/ui/node_modules/.bin/tsup +2 -2
- package/packages/ui/node_modules/.bin/tsup-node +2 -2
- package/scripts/install-cli-launcher.mjs +37 -0
- package/scripts/install-rust-binaries.mjs +112 -0
- package/scripts/run-tests-isolated.mjs +210 -0
- package/src/cli.ts +310 -50
- package/src/lib/admin-reset.ts +15 -30
- package/src/lib/admin-setup.ts +246 -55
- package/src/lib/agent-auth-migrate.ts +5 -1
- package/src/lib/asset-broadcast.ts +15 -4
- package/src/lib/config-amounts.ts +6 -4
- package/src/lib/hidden-tty-prompt.js +1 -0
- package/src/lib/hidden-tty-prompt.ts +105 -0
- package/src/lib/keychain.ts +1 -0
- package/src/lib/local-admin-access.ts +4 -29
- package/src/lib/rust.ts +129 -33
- package/src/lib/signed-tx.ts +1 -0
- package/src/lib/sudo.ts +15 -5
- package/src/lib/wallet-profile.ts +3 -0
- package/src/lib/wallet-setup.ts +52 -0
- package/packages/cache/dist/chunk-QF4XKEIA.cjs.map +0 -1
- package/packages/cache/dist/chunk-QNK6GOTI.js.map +0 -1
package/src/lib/admin-setup.ts
CHANGED
|
@@ -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(
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
)(
|
|
324
|
-
if (confirmation !==
|
|
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(
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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
|
|
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
|
|
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
|
|
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
|
-
'
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
+
}
|
package/src/lib/keychain.ts
CHANGED
|
@@ -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
|
}
|