@palmyr/cli 1.8.1 → 1.8.3
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/README.md +45 -13
- package/dist/cli.js +256 -29
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +13 -1
- package/dist/config.js +15 -1
- package/dist/config.js.map +1 -1
- package/dist/passphrase-prompt.d.ts +4 -1
- package/dist/passphrase-prompt.js +5 -2
- package/dist/passphrase-prompt.js.map +1 -1
- package/dist/pay-preflight.js +14 -2
- package/dist/pay-preflight.js.map +1 -1
- package/dist/vault.d.ts +41 -2
- package/dist/vault.js +68 -3
- package/dist/vault.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -112,18 +112,28 @@ from a single 12-word mnemonic. Wallets are created locally; the seed never leav
|
|
|
112
112
|
|
|
113
113
|
### Creating a wallet
|
|
114
114
|
|
|
115
|
+
Wallet creation **requires** a recoverable passphrase fallback (the env var keeps the phrase out of shell history) — or an explicit `--session-only` opt-out for ephemeral wallets you're OK losing on reboot / keyring change / host migration.
|
|
116
|
+
|
|
115
117
|
```bash
|
|
116
|
-
#
|
|
117
|
-
palmyr wallet create --name agent-prod
|
|
118
|
+
# Recommended — env-var passphrase fallback (durable across reboots and machines)
|
|
119
|
+
PALMYR_WALLET_PASSPHRASE="your-passphrase" palmyr wallet create --name agent-prod
|
|
120
|
+
|
|
121
|
+
# Equivalent — flag form (less safe, ends up in shell history)
|
|
122
|
+
palmyr wallet create --name agent-prod --passphrase "your-passphrase"
|
|
118
123
|
|
|
119
|
-
# Managed: prints a setup link to send to a human
|
|
120
|
-
palmyr wallet create --name treasury --managed
|
|
124
|
+
# Managed: same passphrase rules; also prints a setup link to send to a human
|
|
125
|
+
PALMYR_WALLET_PASSPHRASE="..." palmyr wallet create --name treasury --managed
|
|
121
126
|
|
|
122
127
|
# Single-chain: skip the other chain's account
|
|
123
|
-
palmyr wallet create --name sol-only --solana
|
|
124
|
-
palmyr wallet create --name base-only --base
|
|
128
|
+
PALMYR_WALLET_PASSPHRASE="..." palmyr wallet create --name sol-only --solana
|
|
129
|
+
PALMYR_WALLET_PASSPHRASE="..." palmyr wallet create --name base-only --base
|
|
130
|
+
|
|
131
|
+
# OPT OUT — bound to this machine's OS keychain, NOT recoverable from the JSON file alone
|
|
132
|
+
palmyr wallet create --name throwaway --session-only
|
|
125
133
|
```
|
|
126
134
|
|
|
135
|
+
On a TTY without env or flag, the CLI prompts twice with confirmation; non-TTY callers (CI, agents) must provide one of the three knobs above or get a clear error.
|
|
136
|
+
|
|
127
137
|
By default a wallet derives both Solana and Base/EVM accounts. Pass `--solana` or `--base` (not both) to materialize only one side. The mnemonic always derives both — `--solana` / `--base` controls *which addresses are surfaced and stored*, not which keys exist cryptographically.
|
|
128
138
|
|
|
129
139
|
The managed flow returns a one-time URL. The recipient opens it in a browser, registers a WebAuthn passkey, and sets spending limits. From that point on, transactions inside the limit sign instantly; transactions over the limit emit an `approvalUrl` that the human visits to authenticate and approve.
|
|
@@ -160,6 +170,25 @@ palmyr wallet import --mnemonic "twelve word seed phrase ..." --name imported
|
|
|
160
170
|
palmyr wallet import --mnemonic "..." --name from-backup --tag restored --solana
|
|
161
171
|
```
|
|
162
172
|
|
|
173
|
+
### Durable recovery — passphrase fallback
|
|
174
|
+
|
|
175
|
+
Wallets created with a passphrase store **two** decryption blobs: one keyed by the OS-keychain session secret (fast, local), one keyed by scrypt(passphrase, salt) (durable). Decryption tries the keychain first, then falls back to `PALMYR_WALLET_PASSPHRASE`. That second blob is what lets the wallet survive a reboot, OS-keychain password change, fresh OS install, or copy to another machine.
|
|
176
|
+
|
|
177
|
+
Session-only wallets (`--session-only`) **only** have the keychain blob and are NOT recoverable from the JSON file alone. If you have legacy session-only wallets and still have access to the original machine, add a fallback retroactively:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
# At create time — env var preferred (keeps the phrase out of shell history)
|
|
181
|
+
PALMYR_WALLET_PASSPHRASE="your-passphrase" palmyr wallet create --name agent-prod
|
|
182
|
+
|
|
183
|
+
# Migrate an existing wallet — run on the original machine while the OS session secret still works
|
|
184
|
+
palmyr wallet rekey <WALLET_ID> --passphrase "your-passphrase"
|
|
185
|
+
|
|
186
|
+
# Recover on a new machine — copy ~/.palmyr/wallet/wallets/<id>.json over, then:
|
|
187
|
+
PALMYR_WALLET_PASSPHRASE="your-passphrase" palmyr wallet info <WALLET_ID>
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
When set, the wallet file gets a second AES-256-GCM blob (`owner_crypto`, scrypt KDF on the passphrase + random salt). Decryption tries the OS session secret first, then falls back to `PALMYR_WALLET_PASSPHRASE` / `--passphrase`. Minimum length 8 chars. Re-running `wallet rekey` rotates — old passphrase stops working.
|
|
191
|
+
|
|
163
192
|
### Exporting a seed
|
|
164
193
|
|
|
165
194
|
```bash
|
|
@@ -409,8 +438,9 @@ All wallet operations except `addresses`, `api-key`, `config`, and `request-appr
|
|
|
409
438
|
|
|
410
439
|
| Command | Network | Notes |
|
|
411
440
|
|---|---|---|
|
|
412
|
-
| `palmyr wallet create [--name N] [--managed] [--solana\|--base] [--tag T] [--count N] [--name-prefix P]` | local *(server only if `--managed`)* | New wallet.
|
|
413
|
-
| `palmyr wallet import --mnemonic "..." [--name N] [--managed] [--solana\|--base] [--tag T]` | local | Restore from BIP-39. Same
|
|
441
|
+
| `palmyr wallet create [--name N] [--managed] [--solana\|--base] [--tag T] [--count N] [--name-prefix P] (--passphrase P \| PALMYR_WALLET_PASSPHRASE env \| --session-only)` | local *(server only if `--managed`)* | New wallet. **Requires** either a passphrase (recoverable across reboot / OS-keychain loss / host migration) or explicit `--session-only` opt-out. On TTY without env/flag, prompts twice. Stores keychain secret + (with passphrase) a scrypt-sealed `owner_crypto` blob. `--count > 1` bulk-creates N unmanaged wallets under a required `--tag` (max 500/call, batched DPAPI seal on Windows). |
|
|
442
|
+
| `palmyr wallet import --mnemonic "..." [--name N] [--managed] [--solana\|--base] [--tag T] (--passphrase P \| PALMYR_WALLET_PASSPHRASE env \| --session-only)` | local | Restore from BIP-39. Same passphrase rules as `create` — re-importing on a new machine to recover from keychain loss should always set a passphrase so you don't get trapped again. |
|
|
443
|
+
| `palmyr wallet rekey <ID> --passphrase P` | local | Add (or rotate) the scrypt passphrase fallback on an existing wallet. Wallet must be decryptable right now (session secret still works, or `--current-passphrase` provided). Run on the original machine, then `PALMYR_WALLET_PASSPHRASE` decrypts the wallet anywhere. |
|
|
414
444
|
| `palmyr wallet list [--tag T]` | local | Lists wallets in the local vault. `--tag` filters to one folder. |
|
|
415
445
|
| `palmyr wallet info <ID>` | local | Show one wallet (id, name, addresses, mode, tag). |
|
|
416
446
|
| `palmyr wallet tags` | local | List all tags with wallet count, chains, and date range. |
|
|
@@ -779,16 +809,17 @@ Config is stored in `~/.palmyr/config.json`. Environment variables override file
|
|
|
779
809
|
| `PALMYR_PAY_WALLET` | Force a specific wallet ID for x402 payment. |
|
|
780
810
|
| `PALMYR_WALLET_PATH` | Override vault directory (default `~/.palmyr/wallet`). |
|
|
781
811
|
| `PALMYR_KEYFILE` | Solana keyfile path (legacy single-key flow). |
|
|
782
|
-
| `PALMYR_WALLET_PASSPHRASE` |
|
|
812
|
+
| `PALMYR_WALLET_PASSPHRASE` | Vault decryption passphrase. Used when the wallet was created with `--passphrase` (or env) or migrated via `wallet rekey`. Falls back to the OS-keychain session secret when unset. |
|
|
783
813
|
|
|
784
814
|
### Chain selection during payment
|
|
785
815
|
|
|
786
816
|
The payment chain is resolved in this order:
|
|
787
817
|
|
|
788
|
-
1.
|
|
789
|
-
2. `
|
|
790
|
-
3. `
|
|
791
|
-
|
|
818
|
+
1. `--chain` flag on the command itself (e.g. `wallet pay-preflight --chain base`).
|
|
819
|
+
2. `defaultPayChain` in `~/.palmyr/config.json` — set by `palmyr wallet use <ID> --chain <chain>`.
|
|
820
|
+
3. `solana` as the final default.
|
|
821
|
+
|
|
822
|
+
(Earlier versions of this doc listed `PALMYR_PAY_CHAIN` and `defaultChain` as fallbacks — neither is read by the current pay path. `defaultChain` is legacy keyfile-flow state and `saveConfig` strips it from vault-only configs.)
|
|
792
823
|
|
|
793
824
|
If the server doesn't offer the chosen chain for an endpoint, the CLI errors loudly. There is no silent fallback — the assumption is that an agent should know which chain it pays from.
|
|
794
825
|
|
|
@@ -838,6 +869,7 @@ The CLI uses distinct exit codes so scripts and agents can branch on failure mod
|
|
|
838
869
|
- **Keys never leave the machine.** `wallet create` and `wallet import` never transmit seed material to the server. Even managed wallets register only the wallet ID and the derived public addresses with the server.
|
|
839
870
|
- **Encryption at rest.** The wallet file is AES-256-GCM with the session secret as the key. The session secret is generated on wallet creation and never stored on disk in plaintext.
|
|
840
871
|
- **OS credential store.** Session secrets live in DPAPI (Windows), Keychain (macOS), or `secret-tool` (libsecret on Linux). If none is available the CLI errors rather than falling back to plaintext.
|
|
872
|
+
- **Recoverable by default.** `wallet create` and `wallet import` require a scrypt passphrase fallback (via `--passphrase` or `PALMYR_WALLET_PASSPHRASE`) or an explicit `--session-only` opt-out. The passphrase blob is a second AES-256-GCM ciphertext keyed by scrypt(passphrase, random salt) and lets the wallet decrypt on a different machine / user / headless box where the OS keychain isn't reachable. Decryption tries the OS session secret first, then falls back to the env/flag passphrase. Session-only wallets are bound to this machine's OS keychain — explicit opt-in for ephemeral use only.
|
|
841
873
|
- **Bidirectional vault integrity.** On every load, the CLI checks that every account stored in the wallet file is still derivable from the seed, **and** that every account derivable from the seed is still in the file. Either direction failing returns exit code `7`.
|
|
842
874
|
- **Pre-flight validation.** Endpoints that are easy to fail (bad pubkey for an inbox, malformed E.164 for SMS, unsupported destination country) are validated **before** the x402 paywall, so you don't pay for requests that were never going to succeed.
|
|
843
875
|
- **No `--no-verify`.** Hooks, signatures, and webhooks are always verified.
|
package/dist/cli.js
CHANGED
|
@@ -209,6 +209,22 @@ function subcommandHelp(command, subcommand, options) {
|
|
|
209
209
|
}
|
|
210
210
|
console.log();
|
|
211
211
|
}
|
|
212
|
+
/**
|
|
213
|
+
* Loud, single-shot warning printed when `--session-only` was chosen. Goes to
|
|
214
|
+
* stderr so JSON on stdout stays clean. Caller supplies the write fn so we
|
|
215
|
+
* route through the right stream in agent vs TTY mode.
|
|
216
|
+
*
|
|
217
|
+
* Why this is here: 1.8.2 and earlier defaulted to session-only without
|
|
218
|
+
* warning. A user lost three wallets to a routine keyring change on a
|
|
219
|
+
* headless box because the JSON file alone is mathematically useless without
|
|
220
|
+
* the keychain secret. 1.8.3 makes the choice explicit; this warning is the
|
|
221
|
+
* reminder for anyone who picks the foot-gun anyway.
|
|
222
|
+
*/
|
|
223
|
+
function emitSessionOnlyWarning(write) {
|
|
224
|
+
write(`\n ${t.warn}⚠ session-only wallet — NOT recoverable from the JSON file alone.${t.reset}\n`);
|
|
225
|
+
write(` Reboot, OS-keychain password change, or host copy permanently breaks decryption.\n`);
|
|
226
|
+
write(` Back up the mnemonic externally, or run \`palmyr wallet rekey <id> --passphrase <p>\` later.\n\n`);
|
|
227
|
+
}
|
|
212
228
|
// ─── Subcommand help definitions ───
|
|
213
229
|
const WALLET_HELP = {
|
|
214
230
|
create: [
|
|
@@ -219,6 +235,8 @@ const WALLET_HELP = {
|
|
|
219
235
|
{ flag: '--tag <name>', desc: 'Folder-like grouping tag', hint: 'e.g. palmyr-demo — required with --count' },
|
|
220
236
|
{ flag: '--count <N>', desc: 'Bulk-create N wallets in one call (1-500)', hint: 'unmanaged only; requires --tag' },
|
|
221
237
|
{ flag: '--name-prefix <p>', desc: 'Bulk name prefix; suffixed `-001..-N`', hint: 'default: same as --tag' },
|
|
238
|
+
{ flag: '--passphrase <p>', desc: 'Seal the mnemonic with this passphrase (≥8 chars) for durable recovery across reboot / OS-keychain loss / host migration', hint: 'or PALMYR_WALLET_PASSPHRASE env (env preferred — keeps phrase out of shell history). Interactive prompt on TTY when neither set.' },
|
|
239
|
+
{ flag: '--session-only', desc: 'OPT OUT of the passphrase fallback. Wallet is bound to this machine\'s OS keychain — dies on reboot/keyring loss/migration.', hint: 'use only for ephemeral / throwaway wallets where loss is acceptable' },
|
|
222
240
|
],
|
|
223
241
|
import: [
|
|
224
242
|
{ flag: '--mnemonic <words>', desc: 'BIP-39 mnemonic phrase (required)' },
|
|
@@ -227,6 +245,14 @@ const WALLET_HELP = {
|
|
|
227
245
|
{ flag: '--solana', desc: 'Materialize the Solana account only' },
|
|
228
246
|
{ flag: '--base', desc: 'Materialize the Base/EVM account only' },
|
|
229
247
|
{ flag: '--tag <name>', desc: 'Assign a tag at import time' },
|
|
248
|
+
{ flag: '--passphrase <p>', desc: 'Seal the mnemonic with this passphrase (≥8 chars) for durable recovery', hint: 'or PALMYR_WALLET_PASSPHRASE env (env preferred). Interactive prompt on TTY when neither set.' },
|
|
249
|
+
{ flag: '--session-only', desc: 'OPT OUT of the passphrase fallback. Wallet is bound to this machine\'s OS keychain.', hint: 'use only for ephemeral / throwaway wallets' },
|
|
250
|
+
],
|
|
251
|
+
rekey: [
|
|
252
|
+
{ flag: '<WALLET_ID>', desc: 'Wallet ID or name (positional or --id)' },
|
|
253
|
+
{ flag: '--passphrase <p>', desc: 'New passphrase to seal the mnemonic with (≥8 chars)', hint: 'or PALMYR_WALLET_PASSPHRASE env; interactive prompt if neither set' },
|
|
254
|
+
{ flag: '--current-passphrase <p>', desc: 'Existing passphrase, if the wallet was already rekeyed and the OS session secret is gone', hint: 'or PALMYR_WALLET_PASSPHRASE_CURRENT env' },
|
|
255
|
+
{ flag: '(note)', desc: 'Run on the original machine while the OS session secret is still resolvable. Becomes the durable recovery path on any other host.' },
|
|
230
256
|
],
|
|
231
257
|
tags: [
|
|
232
258
|
{ flag: '(no args)', desc: 'List all tags with wallet count, chains, and date range' },
|
|
@@ -2153,6 +2179,7 @@ async function main() {
|
|
|
2153
2179
|
{ name: 'tag-delete', description: 'Cascade-delete every wallet under a tag', hint: 'TAG --confirm' },
|
|
2154
2180
|
{ name: 'sign-message', description: 'Sign a message', hint: 'WALLET_ID --chain evm --msg "hello"' },
|
|
2155
2181
|
{ name: 'export', description: 'Export mnemonic for backup', hint: 'WALLET_ID --confirm' },
|
|
2182
|
+
{ name: 'rekey', description: 'Add or rotate the passphrase fallback (durable across OS-keychain loss)', hint: 'WALLET_ID --passphrase <p>' },
|
|
2156
2183
|
{ name: 'api-key', description: 'Create agent API key', hint: 'WALLET_ID --name my-agent' },
|
|
2157
2184
|
{ name: 'config', description: 'Get agent config', hint: 'WALLET_ID' },
|
|
2158
2185
|
{ name: 'use', description: 'Set default pay wallet', hint: 'WALLET_ID' },
|
|
@@ -2201,6 +2228,37 @@ async function main() {
|
|
|
2201
2228
|
const chains = (wantSol && !wantBase) ? ['solana']
|
|
2202
2229
|
: (wantBase && !wantSol) ? ['base']
|
|
2203
2230
|
: ['solana', 'base'];
|
|
2231
|
+
// Passphrase resolution — recoverable-by-default.
|
|
2232
|
+
// Three paths:
|
|
2233
|
+
// 1. `--passphrase <p>` or `PALMYR_WALLET_PASSPHRASE` env → seal with scrypt
|
|
2234
|
+
// 2. `--session-only` → explicit opt-out, OS-keychain-only (warned)
|
|
2235
|
+
// 3. nothing + TTY → interactive prompt
|
|
2236
|
+
// 4. nothing + non-TTY → error with the three options
|
|
2237
|
+
// Session-only wallets are recoverable ONLY from this machine's OS
|
|
2238
|
+
// keychain; reboot / keyring change / host migration breaks them
|
|
2239
|
+
// permanently. We pushed agents into that foot-gun in 1.8.2 and
|
|
2240
|
+
// earlier; 1.8.3 forces the choice up front.
|
|
2241
|
+
const sessionOnly = !!flags['session-only'];
|
|
2242
|
+
let passphrase = flags.passphrase || process.env.PALMYR_WALLET_PASSPHRASE || undefined;
|
|
2243
|
+
if (passphrase && sessionOnly) {
|
|
2244
|
+
err('Pass either --passphrase / PALMYR_WALLET_PASSPHRASE OR --session-only, not both.', EXIT.BAD_INPUT);
|
|
2245
|
+
}
|
|
2246
|
+
if (!passphrase && !sessionOnly) {
|
|
2247
|
+
if (process.stdin.isTTY) {
|
|
2248
|
+
const { promptNewPassphrase } = await import('./passphrase-prompt.js');
|
|
2249
|
+
if (!AGENT_MODE)
|
|
2250
|
+
process.stderr.write('\n Wallet creation needs a passphrase fallback so the wallet survives a reboot / OS-keychain change / host migration.\n' +
|
|
2251
|
+
' (Re-run with --session-only to opt out — ephemeral wallets only.)\n\n');
|
|
2252
|
+
passphrase = await promptNewPassphrase('vault wallet');
|
|
2253
|
+
}
|
|
2254
|
+
else {
|
|
2255
|
+
err('Wallet creation requires a recoverable passphrase fallback OR an explicit opt-out:\n\n' +
|
|
2256
|
+
' PALMYR_WALLET_PASSPHRASE="<phrase>" palmyr wallet create [...] # recommended (env keeps phrase out of shell history)\n' +
|
|
2257
|
+
' palmyr wallet create --passphrase "<phrase>" [...] # equivalent\n' +
|
|
2258
|
+
' palmyr wallet create --session-only [...] # OPT OUT — wallet dies with this machine\'s OS keychain\n\n' +
|
|
2259
|
+
'Session-only wallets are NOT recoverable from the JSON file alone — reboot, keyring change, or host copy renders them unusable.', EXIT.BAD_INPUT);
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2204
2262
|
// ─── Bulk path ───
|
|
2205
2263
|
if (count > 1) {
|
|
2206
2264
|
if (isManaged)
|
|
@@ -2214,17 +2272,36 @@ async function main() {
|
|
|
2214
2272
|
const { storeSecretsBatch } = await import('./credential-store.js');
|
|
2215
2273
|
// Progress to stderr so JSON on stdout stays clean
|
|
2216
2274
|
if (!AGENT_MODE)
|
|
2217
|
-
process.stderr.write(`creating ${count} wallets under tag "${tagRaw}"...\n`);
|
|
2218
|
-
const results = createLocalWalletsBatch(prefix, count, 'unmanaged', { tag: tagRaw, chains });
|
|
2275
|
+
process.stderr.write(`creating ${count} wallets under tag "${tagRaw}"${passphrase ? ' (+ passphrase fallback)' : ' (session-only)'}...\n`);
|
|
2276
|
+
const results = createLocalWalletsBatch(prefix, count, 'unmanaged', { tag: tagRaw, chains, passphrase });
|
|
2219
2277
|
if (!AGENT_MODE)
|
|
2220
2278
|
process.stderr.write(`sealing ${count} session secrets in OS credential store...\n`);
|
|
2221
|
-
|
|
2222
|
-
|
|
2279
|
+
// Keychain failure is non-fatal IFF a passphrase fallback was
|
|
2280
|
+
// written — the wallets are still recoverable via the env var.
|
|
2281
|
+
let keychainStoreWarning = null;
|
|
2282
|
+
try {
|
|
2283
|
+
storeSecretsBatch(results.map(r => ({ account: r.id, secret: r.sessionSecret })));
|
|
2284
|
+
}
|
|
2285
|
+
catch (e) {
|
|
2286
|
+
if (passphrase) {
|
|
2287
|
+
keychainStoreWarning = e?.message || 'keychain store failed';
|
|
2288
|
+
if (!AGENT_MODE)
|
|
2289
|
+
process.stderr.write(` warning: OS keychain unavailable (${keychainStoreWarning}); wallets remain decryptable via PALMYR_WALLET_PASSPHRASE\n`);
|
|
2290
|
+
}
|
|
2291
|
+
else {
|
|
2292
|
+
throw e;
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
if (sessionOnly && !AGENT_MODE)
|
|
2296
|
+
emitSessionOnlyWarning(process.stderr.write.bind(process.stderr));
|
|
2297
|
+
log(`wallet create: ${count} wallets under tag "${tagRaw}" (chains=${chains.join(',')}, mode=${passphrase ? 'passphrase' : 'session-only'}${keychainStoreWarning ? ', keychain=failed' : ''})`);
|
|
2223
2298
|
if (AGENT_MODE) {
|
|
2224
2299
|
print({
|
|
2225
2300
|
count: results.length,
|
|
2226
2301
|
tag: tagRaw,
|
|
2227
2302
|
chains,
|
|
2303
|
+
recoverable: !!passphrase,
|
|
2304
|
+
...(keychainStoreWarning ? { keychainWarning: keychainStoreWarning } : {}),
|
|
2228
2305
|
wallets: results.map(r => ({
|
|
2229
2306
|
id: r.id,
|
|
2230
2307
|
name: r.name,
|
|
@@ -2240,6 +2317,7 @@ async function main() {
|
|
|
2240
2317
|
console.log(`\n ${t.success}✔${t.reset} Created ${count} wallets under tag ${t.accent}${tagRaw}${t.reset}`);
|
|
2241
2318
|
console.log(` ${t.muted}chains:${t.reset} ${chains.join(', ')}`);
|
|
2242
2319
|
console.log(` ${t.muted}names: ${t.reset}${results[0].name} … ${results[results.length - 1].name}`);
|
|
2320
|
+
console.log(` ${t.muted}recoverable:${t.reset} ${passphrase ? 'yes (passphrase fallback set)' : 'NO — session-only'}`);
|
|
2243
2321
|
console.log(`\n ${t.muted}List them: ${t.reset}palmyr wallet list --tag ${tagRaw}`);
|
|
2244
2322
|
console.log(` ${t.muted}Delete all: ${t.reset}palmyr wallet tag-delete ${tagRaw} --confirm\n`);
|
|
2245
2323
|
}
|
|
@@ -2251,11 +2329,27 @@ async function main() {
|
|
|
2251
2329
|
const mode = isManaged ? 'managed' : 'unmanaged';
|
|
2252
2330
|
// Create locally — no server needed for the key material
|
|
2253
2331
|
const { createLocalWallet } = await import('./vault.js');
|
|
2254
|
-
const w = createLocalWallet(name, mode, { tag: tagRaw, chains });
|
|
2255
|
-
// Store session secret in OS credential store
|
|
2332
|
+
const w = createLocalWallet(name, mode, { tag: tagRaw, chains, passphrase });
|
|
2333
|
+
// Store session secret in OS credential store. Keychain failure is
|
|
2334
|
+
// non-fatal when a passphrase fallback was written.
|
|
2256
2335
|
const { storeSecret } = await import('./credential-store.js');
|
|
2257
|
-
|
|
2258
|
-
|
|
2336
|
+
let keychainStoreWarning = null;
|
|
2337
|
+
try {
|
|
2338
|
+
storeSecret(w.id, w.sessionSecret);
|
|
2339
|
+
}
|
|
2340
|
+
catch (e) {
|
|
2341
|
+
if (passphrase) {
|
|
2342
|
+
keychainStoreWarning = e?.message || 'keychain store failed';
|
|
2343
|
+
if (!AGENT_MODE)
|
|
2344
|
+
process.stderr.write(` warning: OS keychain unavailable (${keychainStoreWarning}); wallet remains decryptable via PALMYR_WALLET_PASSPHRASE\n`);
|
|
2345
|
+
}
|
|
2346
|
+
else {
|
|
2347
|
+
throw e;
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
if (sessionOnly && !AGENT_MODE)
|
|
2351
|
+
emitSessionOnlyWarning(process.stderr.write.bind(process.stderr));
|
|
2352
|
+
log(`wallet create: ${w.id} (${mode}${tagRaw ? `, tag=${tagRaw}` : ''}, chains=${chains.join(',')}, mode=${passphrase ? 'passphrase' : 'session-only'}${keychainStoreWarning ? ', keychain=failed' : ''})`);
|
|
2259
2353
|
// For managed wallets, register metadata with the server to get a setup link
|
|
2260
2354
|
let setupLink;
|
|
2261
2355
|
if (isManaged) {
|
|
@@ -2299,7 +2393,7 @@ async function main() {
|
|
|
2299
2393
|
}
|
|
2300
2394
|
}
|
|
2301
2395
|
else {
|
|
2302
|
-
print({ ...w, setupLink });
|
|
2396
|
+
print({ ...w, setupLink, recoverable: !!passphrase, ...(keychainStoreWarning ? { keychainWarning: keychainStoreWarning } : {}) });
|
|
2303
2397
|
}
|
|
2304
2398
|
break;
|
|
2305
2399
|
}
|
|
@@ -2315,12 +2409,52 @@ async function main() {
|
|
|
2315
2409
|
const chains = (wantSol && !wantBase) ? ['solana']
|
|
2316
2410
|
: (wantBase && !wantSol) ? ['base']
|
|
2317
2411
|
: ['solana', 'base'];
|
|
2412
|
+
// Same recoverability gate as `create`. Import is even more
|
|
2413
|
+
// commonly run on a "new machine" after losing access on the
|
|
2414
|
+
// original — going session-only here would re-trap the user in
|
|
2415
|
+
// the same hole they're recovering from.
|
|
2416
|
+
const importSessionOnly = !!flags['session-only'];
|
|
2417
|
+
let importPassphrase = flags.passphrase || process.env.PALMYR_WALLET_PASSPHRASE || undefined;
|
|
2418
|
+
if (importPassphrase && importSessionOnly) {
|
|
2419
|
+
err('Pass either --passphrase / PALMYR_WALLET_PASSPHRASE OR --session-only, not both.', EXIT.BAD_INPUT);
|
|
2420
|
+
}
|
|
2421
|
+
if (!importPassphrase && !importSessionOnly) {
|
|
2422
|
+
if (process.stdin.isTTY) {
|
|
2423
|
+
const { promptNewPassphrase } = await import('./passphrase-prompt.js');
|
|
2424
|
+
if (!AGENT_MODE)
|
|
2425
|
+
process.stderr.write('\n Import needs a passphrase fallback so the wallet survives a reboot / OS-keychain change / host migration.\n' +
|
|
2426
|
+
' (Re-run with --session-only to opt out — ephemeral wallets only.)\n\n');
|
|
2427
|
+
importPassphrase = await promptNewPassphrase('vault wallet');
|
|
2428
|
+
}
|
|
2429
|
+
else {
|
|
2430
|
+
err('Wallet import requires a recoverable passphrase fallback OR an explicit opt-out:\n\n' +
|
|
2431
|
+
' PALMYR_WALLET_PASSPHRASE="<phrase>" palmyr wallet import --mnemonic "..." # recommended\n' +
|
|
2432
|
+
' palmyr wallet import --mnemonic "..." --passphrase "<phrase>" # equivalent\n' +
|
|
2433
|
+
' palmyr wallet import --mnemonic "..." --session-only # OPT OUT — wallet dies with this machine\'s OS keychain', EXIT.BAD_INPUT);
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2318
2436
|
const { importLocalWallet } = await import('./vault.js');
|
|
2319
|
-
const w = importLocalWallet(name, mnemonic, mode, { tag: tagRaw, chains });
|
|
2320
|
-
// Store session secret
|
|
2437
|
+
const w = importLocalWallet(name, mnemonic, mode, { tag: tagRaw, chains, passphrase: importPassphrase });
|
|
2438
|
+
// Store session secret. Keychain failure is non-fatal when a
|
|
2439
|
+
// passphrase fallback was written.
|
|
2321
2440
|
const { storeSecret } = await import('./credential-store.js');
|
|
2322
|
-
|
|
2323
|
-
|
|
2441
|
+
let importKeychainWarning = null;
|
|
2442
|
+
try {
|
|
2443
|
+
storeSecret(w.id, w.sessionSecret);
|
|
2444
|
+
}
|
|
2445
|
+
catch (e) {
|
|
2446
|
+
if (importPassphrase) {
|
|
2447
|
+
importKeychainWarning = e?.message || 'keychain store failed';
|
|
2448
|
+
if (!AGENT_MODE)
|
|
2449
|
+
process.stderr.write(` warning: OS keychain unavailable (${importKeychainWarning}); wallet remains decryptable via PALMYR_WALLET_PASSPHRASE\n`);
|
|
2450
|
+
}
|
|
2451
|
+
else {
|
|
2452
|
+
throw e;
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
if (importSessionOnly && !AGENT_MODE)
|
|
2456
|
+
emitSessionOnlyWarning(process.stderr.write.bind(process.stderr));
|
|
2457
|
+
log(`wallet import: ${w.id} (mode=${importPassphrase ? 'passphrase' : 'session-only'}${importKeychainWarning ? ', keychain=failed' : ''})`);
|
|
2324
2458
|
if (!AGENT_MODE) {
|
|
2325
2459
|
render(React.createElement(WalletCreateScreen, {
|
|
2326
2460
|
version: VERSION,
|
|
@@ -2333,7 +2467,7 @@ async function main() {
|
|
|
2333
2467
|
}));
|
|
2334
2468
|
}
|
|
2335
2469
|
else {
|
|
2336
|
-
print(w);
|
|
2470
|
+
print({ ...w, recoverable: !!importPassphrase, ...(importKeychainWarning ? { keychainWarning: importKeychainWarning } : {}) });
|
|
2337
2471
|
}
|
|
2338
2472
|
break;
|
|
2339
2473
|
}
|
|
@@ -2503,9 +2637,13 @@ async function main() {
|
|
|
2503
2637
|
const msg = flags.msg || flags.message;
|
|
2504
2638
|
if (!chain || !msg)
|
|
2505
2639
|
err('--chain and --msg required');
|
|
2506
|
-
// Sign locally — no server needed
|
|
2640
|
+
// Sign locally — no server needed.
|
|
2641
|
+
// Read the same passphrase channel as pay / export / rekey so a
|
|
2642
|
+
// passphrase-backed wallet signs from any machine the env var
|
|
2643
|
+
// reaches (was missing in 1.8.2 — inconsistent with other commands).
|
|
2644
|
+
const signPass = flags.passphrase || process.env.PALMYR_WALLET_PASSPHRASE || undefined;
|
|
2507
2645
|
const { signMessageLocal } = await import('./vault.js');
|
|
2508
|
-
const data = signMessageLocal(walletId, chain, msg);
|
|
2646
|
+
const data = signMessageLocal(walletId, chain, msg, signPass);
|
|
2509
2647
|
return print({ success: true, ...data });
|
|
2510
2648
|
render(React.createElement(SuccessScreen, {
|
|
2511
2649
|
version: VERSION,
|
|
@@ -2629,6 +2767,43 @@ async function main() {
|
|
|
2629
2767
|
}
|
|
2630
2768
|
break;
|
|
2631
2769
|
}
|
|
2770
|
+
case 'rekey': {
|
|
2771
|
+
const walletId = positional[0] || flags.id;
|
|
2772
|
+
if (!walletId)
|
|
2773
|
+
err('Wallet ID required: palmyr wallet rekey <WALLET_ID> --passphrase <p>', EXIT.BAD_INPUT);
|
|
2774
|
+
// New passphrase: flag → env → interactive prompt (TTY only).
|
|
2775
|
+
let newPass = flags.passphrase || process.env.PALMYR_WALLET_PASSPHRASE || undefined;
|
|
2776
|
+
if (!newPass) {
|
|
2777
|
+
if (!process.stdin.isTTY)
|
|
2778
|
+
err('--passphrase or PALMYR_WALLET_PASSPHRASE required (no TTY for interactive prompt)', EXIT.BAD_INPUT);
|
|
2779
|
+
const { promptNewPassphrase } = await import('./passphrase-prompt.js');
|
|
2780
|
+
newPass = await promptNewPassphrase();
|
|
2781
|
+
}
|
|
2782
|
+
// Current passphrase is only needed if the wallet was already
|
|
2783
|
+
// passphrase-sealed and the OS session secret is gone (rare —
|
|
2784
|
+
// typically the rekey runs on the original machine where the
|
|
2785
|
+
// session secret still resolves).
|
|
2786
|
+
const currentPass = flags['current-passphrase'] || process.env.PALMYR_WALLET_PASSPHRASE_CURRENT || undefined;
|
|
2787
|
+
const { rekeyWallet } = await import('./vault.js');
|
|
2788
|
+
let result;
|
|
2789
|
+
try {
|
|
2790
|
+
result = rekeyWallet(walletId, newPass, currentPass);
|
|
2791
|
+
}
|
|
2792
|
+
catch (e) {
|
|
2793
|
+
const code = e.message?.includes('SECURITY') ? EXIT.SECURITY : EXIT.GENERAL;
|
|
2794
|
+
err(e.message, code);
|
|
2795
|
+
}
|
|
2796
|
+
log(`wallet rekey: ${result.id} (${result.rotated ? 'rotated' : 'added'})`);
|
|
2797
|
+
if (!AGENT_MODE) {
|
|
2798
|
+
const verb = result.rotated ? 'Rotated' : 'Added';
|
|
2799
|
+
console.log(`\n ${t.success}✔${t.reset} ${verb} passphrase fallback on ${t.accent}${result.name}${t.reset}`);
|
|
2800
|
+
console.log(` ${t.muted}Wallet now decrypts with PALMYR_WALLET_PASSPHRASE on any machine (in addition to the OS keychain on this one).${t.reset}\n`);
|
|
2801
|
+
}
|
|
2802
|
+
else {
|
|
2803
|
+
print({ success: true, ...result });
|
|
2804
|
+
}
|
|
2805
|
+
break;
|
|
2806
|
+
}
|
|
2632
2807
|
case 'buy': {
|
|
2633
2808
|
const chain = (positional[0] || 'solana').toLowerCase();
|
|
2634
2809
|
if (chain !== 'solana' && chain !== 'base')
|
|
@@ -6524,12 +6699,18 @@ async function main() {
|
|
|
6524
6699
|
const { join } = await import('path');
|
|
6525
6700
|
const vaultDir = process.env.PALMYR_WALLET_PATH || join(homedir(), '.palmyr', 'wallet');
|
|
6526
6701
|
const { isCredentialStoreAvailable } = await import('./credential-store.js');
|
|
6702
|
+
const { hasLegacyKeyfileWallet } = await import('./config.js');
|
|
6703
|
+
// `defaultChain` is legacy keyfile-flow state — only show it when a
|
|
6704
|
+
// keyfile wallet is actually configured. The functional field is
|
|
6705
|
+
// `payChain` (renamed from the misleadingly-similar `defaultPayChain`
|
|
6706
|
+
// disk key), which the x402 pay path reads.
|
|
6707
|
+
const showLegacyChain = hasLegacyKeyfileWallet(cfg);
|
|
6527
6708
|
const configData = {
|
|
6528
6709
|
api: cfg.api,
|
|
6529
|
-
defaultChain: cfg.defaultChain,
|
|
6530
6710
|
setupDone: cfg.setupDone,
|
|
6711
|
+
...(showLegacyChain ? { legacyKeyfileChain: cfg.defaultChain || 'solana' } : {}),
|
|
6531
6712
|
defaultPayWalletId: cfg.defaultPayWalletId || null,
|
|
6532
|
-
|
|
6713
|
+
payChain: cfg.defaultPayChain || 'solana',
|
|
6533
6714
|
vaultPath: vaultDir,
|
|
6534
6715
|
credentialStore: isCredentialStoreAvailable() ? 'available' : 'unavailable',
|
|
6535
6716
|
configPath: join(homedir(), '.palmyr', 'config.json'),
|
|
@@ -6560,21 +6741,67 @@ async function main() {
|
|
|
6560
6741
|
const { listVaultWallets } = await import('./vault.js');
|
|
6561
6742
|
const wallets = listVaultWallets();
|
|
6562
6743
|
checks.push({ name: 'Local wallets', status: wallets.length > 0 ? 'pass' : 'warn', detail: `${wallets.length} wallet(s) found` });
|
|
6563
|
-
// 4.
|
|
6744
|
+
// 4. Decryption readiness for each wallet — tri-state.
|
|
6745
|
+
//
|
|
6746
|
+
// pass → wallet has keychain secret, OR has a passphrase fallback
|
|
6747
|
+
// AND PALMYR_WALLET_PASSPHRASE is set
|
|
6748
|
+
// warn → has passphrase fallback but env unset (recoverable, but
|
|
6749
|
+
// the next command will fail until env is set)
|
|
6750
|
+
// fail → session-only AND keychain secret is gone (unrecoverable
|
|
6751
|
+
// from this machine — needs mnemonic re-import or
|
|
6752
|
+
// rekey-on-original)
|
|
6564
6753
|
const { retrieveSecret } = await import('./credential-store.js');
|
|
6565
|
-
|
|
6754
|
+
const { hasPassphraseFallback } = await import('./vault.js');
|
|
6755
|
+
const envSet = !!process.env.PALMYR_WALLET_PASSPHRASE;
|
|
6756
|
+
let keychainOk = 0;
|
|
6757
|
+
let needsEnv = 0;
|
|
6758
|
+
let unrecoverable = [];
|
|
6566
6759
|
for (const w of wallets) {
|
|
6567
|
-
if (retrieveSecret(w.id))
|
|
6568
|
-
|
|
6569
|
-
|
|
6570
|
-
|
|
6760
|
+
if (retrieveSecret(w.id)) {
|
|
6761
|
+
keychainOk++;
|
|
6762
|
+
continue;
|
|
6763
|
+
}
|
|
6764
|
+
let hasFallback = false;
|
|
6765
|
+
try {
|
|
6766
|
+
hasFallback = hasPassphraseFallback(w.id);
|
|
6767
|
+
}
|
|
6768
|
+
catch { }
|
|
6769
|
+
if (hasFallback) {
|
|
6770
|
+
needsEnv++;
|
|
6771
|
+
}
|
|
6772
|
+
else {
|
|
6773
|
+
unrecoverable.push(w.name || w.id.slice(0, 8));
|
|
6774
|
+
}
|
|
6571
6775
|
}
|
|
6572
6776
|
if (wallets.length > 0) {
|
|
6573
|
-
|
|
6574
|
-
|
|
6575
|
-
|
|
6576
|
-
|
|
6577
|
-
|
|
6777
|
+
if (unrecoverable.length > 0) {
|
|
6778
|
+
checks.push({
|
|
6779
|
+
name: 'Wallet decryption',
|
|
6780
|
+
status: 'fail',
|
|
6781
|
+
detail: `${unrecoverable.length} session-only wallet(s) UNRECOVERABLE from this machine — keychain secret missing and no passphrase fallback (${unrecoverable.slice(0, 3).join(', ')}${unrecoverable.length > 3 ? ', …' : ''}). Recover by importing the mnemonic here, or running \`palmyr wallet rekey <id> --passphrase <p>\` on the original machine.`,
|
|
6782
|
+
});
|
|
6783
|
+
}
|
|
6784
|
+
else if (needsEnv > 0 && !envSet) {
|
|
6785
|
+
checks.push({
|
|
6786
|
+
name: 'Wallet decryption',
|
|
6787
|
+
status: 'warn',
|
|
6788
|
+
detail: `${needsEnv} wallet(s) need PALMYR_WALLET_PASSPHRASE to decrypt (keychain secret missing, passphrase fallback present). Set the env var to unblock pay / sign / export.`,
|
|
6789
|
+
});
|
|
6790
|
+
}
|
|
6791
|
+
else if (needsEnv > 0) {
|
|
6792
|
+
checks.push({
|
|
6793
|
+
name: 'Wallet decryption',
|
|
6794
|
+
status: 'pass',
|
|
6795
|
+
detail: `${keychainOk} via OS keychain, ${needsEnv} via PALMYR_WALLET_PASSPHRASE (env set)`,
|
|
6796
|
+
});
|
|
6797
|
+
}
|
|
6798
|
+
else {
|
|
6799
|
+
checks.push({
|
|
6800
|
+
name: 'Wallet decryption',
|
|
6801
|
+
status: 'pass',
|
|
6802
|
+
detail: `All ${keychainOk} wallet(s) have keychain secrets`,
|
|
6803
|
+
});
|
|
6804
|
+
}
|
|
6578
6805
|
}
|
|
6579
6806
|
// 5. API connectivity
|
|
6580
6807
|
try {
|