@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 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
- # Unmanaged: one command, ready to sign
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. Stores session secret in OS credential store. `--count > 1` bulk-creates N unmanaged wallets under a required `--tag` (max 500/call, batched DPAPI seal on Windows). `--solana` / `--base` materializes only one chain. |
413
- | `palmyr wallet import --mnemonic "..." [--name N] [--managed] [--solana\|--base] [--tag T]` | local | Restore from BIP-39. Same chain / tag flags as `create`. |
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` | Optional BIP-39 passphrase for legacy import/export. |
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. The `--chain` flag passed to `wallet use`, persisted as `defaultPayChain`.
789
- 2. `PALMYR_PAY_CHAIN` environment variable.
790
- 3. `defaultChain` in config.
791
- 4. `solana` as the final default.
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
- storeSecretsBatch(results.map(r => ({ account: r.id, secret: r.sessionSecret })));
2222
- log(`wallet create: ${count} wallets under tag "${tagRaw}" (chains=${chains.join(',')})`);
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
- storeSecret(w.id, w.sessionSecret);
2258
- log(`wallet create: ${w.id} (${mode}${tagRaw ? `, tag=${tagRaw}` : ''}, chains=${chains.join(',')})`);
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
- storeSecret(w.id, w.sessionSecret);
2323
- log(`wallet import: ${w.id}`);
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
- defaultPayChain: cfg.defaultPayChain || 'solana',
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. Session secrets present for wallets
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
- let secretsOk = 0, secretsMissing = 0;
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
- secretsOk++;
6569
- else
6570
- secretsMissing++;
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
- checks.push({
6574
- name: 'Session secrets',
6575
- status: secretsMissing === 0 ? 'pass' : 'fail',
6576
- detail: secretsMissing === 0 ? `All ${secretsOk} wallet(s) have secrets stored` : `${secretsMissing} wallet(s) missing session secret`,
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 {