Skip to content

Commit 1f417e3

Browse files
committed
fix(wallet): type key-resolution failures, and validate refs at init
Finding 6 and the validation trio. **Key resolution failures are typed at the throw.** `resolveKeyRef` and the keystore decrypt run at *use* time, from inside the client construction every signing command performs before its try block — so a plain Error reached incur's top-level handler and rendered as `{ code: 'UNKNOWN' }`, no code and no `retryable`. That is what an agent saw for the most common failure a vault-backed wallet has: installed, but not logged in. The obvious fix — move construction inside each command's try — would have made it worse. Those catches end in `out.fail('UPLOAD_FAILED', …)`, `'COSTS_FAILED'`, `'DATASET_LIST_FAILED'`, so a key that could not be fetched would be reported as an upload that failed: typed, and wrong. Throwing `Errors.IncurError` instead fixes all 15 commands at once, plus any command that does not exist yet, and needs no command edits. Codes are shared with the ones `walletPreflight` already emits for the same conditions — whether the guard caught it or the resolver did is an implementation detail, and the fix is identical either way. New at use time: `KEY_REF_RESOLUTION_FAILED` (provider ran and refused — explicitly not retryable, since every cause needs a deliberate act), `KEY_REF_NOT_A_KEY`, `KEY_REF_AMBIGUOUS`, `KEYSTORE_DECRYPT_FAILED`, `KEYSTORE_NOT_A_KEY`, `KEYSTORE_AMBIGUOUS`. **A leading `-` is no longer a legal reference.** `clawdi:--project` passed SAFE_REF and reached argv as `clawdi vault resolve --project`, so config steered the helper's own option parsing rather than naming a secret. Not arbitrary execution, but more than "only the reference comes from config" allows. A dash anywhere else stays legal — `FILECOIN-PRIVATE-KEY` still resolves. **`wallet init` refuses what no command could resolve.** It validated the `<provider>:<ref>` shape and nothing else, so `clawdi:MY KEY&touch x` returned `configured`, cleared the previous wallet, and left every later command failing with "re-run `foc-cli wallet init`" — pointing back at the command that had just accepted it. The check is shared with the resolver via `unsafeRefReason`, and the preflight applies it too so a config already holding one gets a CTA instead of a bare throw mid-command. **`--keyProject` on its own now does what both docs promise.** No branch consumed it: the command fell through to `already_configured`, wrote nothing, and reported success, so the caller believed a scope was pinned that never was and every command kept resolving against the provider's default project. It now re-scopes the configured reference. Without one it fails `KEY_PROJECT_WITHOUT_KEY_REF`, and combined with `--auto`/`--privateKey`/`--keystore` it is refused rather than shadowing them — answering a contradiction by quietly picking one is the same silent-ignore this removes.
1 parent ee5309d commit 1f417e3

7 files changed

Lines changed: 479 additions & 63 deletions

File tree

cli/src/client.ts

Lines changed: 61 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { execFileSync } from 'node:child_process'
22
import { basename, dirname } from 'node:path'
33
import { getChain } from '@filoz/synapse-core/chains'
4+
import { Errors } from 'incur'
45
import { createPublicClient, createWalletClient, type Hex, http } from 'viem'
56
import { privateKeyToAccount } from 'viem/accounts'
67
import config from './config.ts'
@@ -15,6 +16,7 @@ import {
1516
providerNames,
1617
redactKeyLike,
1718
resolveKeyRef,
19+
unsafeRefReason,
1820
} from './key-ref.ts'
1921
import type { OutputContext } from './output.ts'
2022
import { canPrompt, expandHome } from './utils.ts'
@@ -26,6 +28,21 @@ type Problem = {
2628
retryable?: boolean
2729
}
2830

31+
/**
32+
* "Reconfigure the reference", for the several ways a stored one can be broken.
33+
* A wallet is configured (badly), so every suggestion carries --force or it
34+
* would bounce off WALLET_ALREADY_CONFIGURED.
35+
*/
36+
function reconfigureRefCta() {
37+
return {
38+
description: 'Reconfigure the reference:',
39+
commands: keyRefCtaCommands().map((cmd) => ({
40+
...cmd,
41+
options: { ...cmd.options, force: true },
42+
})),
43+
}
44+
}
45+
2946
/**
3047
* Cheap checks that must pass before a command can sign anything.
3148
*
@@ -76,14 +93,7 @@ export function walletPreflight(c: { agent?: boolean }): Problem | null {
7693
// passed to --key-ref, and this message is the one place that mistake
7794
// would be echoed into a log. The command to fix it says the shape.
7895
message: `The configured key reference (${redactKeyLike(raw)}) is not of the form <provider>:<reference> — e.g. clawdi:FILECOIN_PRIVATE_KEY. Reconfigure it with \`foc-cli wallet init --key-ref <provider>:<reference> --force\`.`,
79-
cta: {
80-
description: 'Reconfigure the reference:',
81-
commands: keyRefCtaCommands().map((cmd) => ({
82-
...cmd,
83-
// A wallet is configured (badly), so replacing it needs --force.
84-
options: { ...cmd.options, force: true },
85-
})),
86-
},
96+
cta: reconfigureRefCta(),
8797
}
8898
}
8999
// Before the PATH probe, which cannot tell "this provider does not exist"
@@ -95,13 +105,24 @@ export function walletPreflight(c: { agent?: boolean }): Problem | null {
95105
return {
96106
code: 'UNKNOWN_KEY_REF_PROVIDER',
97107
message: `The configured key reference names an unknown provider "${parsed.provider}". Supported: ${providerNames().join(', ')}. Reconfigure it with \`foc-cli wallet init --key-ref <provider>:<reference> --force\`.`,
98-
cta: {
99-
description: 'Reconfigure the reference:',
100-
commands: keyRefCtaCommands().map((cmd) => ({
101-
...cmd,
102-
options: { ...cmd.options, force: true },
103-
})),
104-
},
108+
cta: reconfigureRefCta(),
109+
}
110+
}
111+
// Cheap and config-only, so it belongs with the other guard checks rather
112+
// than at use time: a reference or scope holding characters the resolver
113+
// refuses is a permanent misconfiguration, and catching it here is what
114+
// gives it a call to action instead of a bare throw mid-command.
115+
for (const [what, value] of [
116+
['reference', parsed.ref],
117+
['project', config.get('keyRefProject')],
118+
] as const) {
119+
const reason = value === undefined ? null : unsafeRefReason(value)
120+
if (reason) {
121+
return {
122+
code: 'MALFORMED_KEY_REF',
123+
message: `Malformed key ${what} in config: it ${reason}. Reconfigure the wallet with \`foc-cli wallet init --key-ref <provider>:<reference> --force\`.`,
124+
cta: reconfigureRefCta(),
125+
}
105126
}
106127
}
107128
if (!isProviderAvailable(parsed.provider)) {
@@ -220,9 +241,11 @@ function privateKeyFromConfig() {
220241
if (!keystore) {
221242
const privateKey = config.get('privateKey')
222243
if (!privateKey) {
223-
throw new Error(
224-
'Private key not found. Please run `foc-cli wallet init` to initialize the CLI'
225-
)
244+
throw new Errors.IncurError({
245+
code: 'WALLET_NOT_CONFIGURED',
246+
message:
247+
'Private key not found. Please run `foc-cli wallet init` to initialize the CLI',
248+
})
226249
}
227250
return privateKey
228251
}
@@ -249,13 +272,18 @@ function privateKeyFromConfig() {
249272
// through to the terminal; this message decodes what that output means
250273
// rather than re-reading it.
251274
if ((error as { code?: string }).code === 'ENOENT') {
252-
throw new Error(
253-
'Failed to access keystore: Foundry `cast` is not on PATH. Install Foundry (https://getfoundry.sh), or switch to a private-key wallet with `foc-cli wallet init`.'
254-
)
275+
throw new Errors.IncurError({
276+
code: 'KEYSTORE_TOOL_MISSING',
277+
retryable: true,
278+
message:
279+
'Failed to access keystore: Foundry `cast` is not on PATH. Install Foundry (https://getfoundry.sh), or switch to a private-key wallet with `foc-cli wallet init`.',
280+
})
255281
}
256-
throw new Error(
257-
'Failed to access keystore. "Mac Mismatch" above means the password was wrong. Other causes: an invalid keystore file, or a session with no terminal for the password prompt — keystore mode is interactive-only, so MCP/CI must use a private-key wallet.'
258-
)
282+
throw new Errors.IncurError({
283+
code: 'KEYSTORE_DECRYPT_FAILED',
284+
message:
285+
'Failed to access keystore. "Mac Mismatch" above means the password was wrong. Other causes: an invalid keystore file, or a session with no terminal for the password prompt — keystore mode is interactive-only, so MCP/CI must use a private-key wallet.',
286+
})
259287
}
260288

261289
// The same bounded matcher the key-reference path uses, for the same reason:
@@ -265,14 +293,17 @@ function privateKeyFromConfig() {
265293
// same shape, one matcher.
266294
const found = findPrivateKeys(extraction)
267295
if (found.length === 0) {
268-
throw new Error(
269-
"Keystore decrypted, but no private key (0x + 64 hex, on its own rather than inside a longer value) was found in cast's output. Check `cast wallet decrypt-keystore` works on this file directly — the output is not shown here on purpose."
270-
)
296+
throw new Errors.IncurError({
297+
code: 'KEYSTORE_NOT_A_KEY',
298+
message:
299+
"Keystore decrypted, but no private key (0x + 64 hex, on its own rather than inside a longer value) was found in cast's output. Check `cast wallet decrypt-keystore` works on this file directly — the output is not shown here on purpose.",
300+
})
271301
}
272302
if (found.length > 1) {
273-
throw new Error(
274-
`Keystore decrypted to output containing ${found.length} different 0x + 64 hex values, so which one is the key is ambiguous. Use a keystore that holds a single key — the values are not shown here on purpose.`
275-
)
303+
throw new Errors.IncurError({
304+
code: 'KEYSTORE_AMBIGUOUS',
305+
message: `Keystore decrypted to output containing ${found.length} different 0x + 64 hex values, so which one is the key is ambiguous. Use a keystore that holds a single key — the values are not shown here on purpose.`,
306+
})
276307
}
277308
return found[0]
278309
}

cli/src/commands/wallet/init.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
parseKeyRef,
1212
providerNames,
1313
redactKeyLike,
14+
unsafeRefReason,
1415
} from '../../key-ref.ts'
1516
import { commandOutput, OutputContext } from '../../output.ts'
1617
import { canPrompt, expandHome, isAgent } from '../../utils.ts'
@@ -326,6 +327,79 @@ export const initCommand = {
326327
config.set('source', c.options.source)
327328
}
328329

330+
// --keyProject on its own re-scopes the reference already configured. Both
331+
// reference docs tell the reader to do exactly this, and describe it as
332+
// never blocked — which was true, but only because nothing consumed it:
333+
// no branch matched, so the command fell through to `already_configured`,
334+
// wrote nothing, and reported success. The caller then believed a scope was
335+
// pinned that never was, and every command kept resolving against the
336+
// provider's default project.
337+
if (c.options.keyProject !== undefined && !c.options.keyRef) {
338+
// Paired with a method that configures a different custody mode there is
339+
// nothing for a scope to apply to, and this branch runs before those
340+
// methods — so say so rather than either shadowing them or repeating the
341+
// silent-ignore this whole branch exists to remove.
342+
if (c.options.auto || c.options.privateKey || c.options.keystore) {
343+
return out.fail(
344+
'KEY_PROJECT_WITHOUT_KEY_REF',
345+
'--key-project scopes a key reference, and --auto, --private-key and --keystore all configure a wallet that uses none. Drop --key-project, or configure a reference with --key-ref instead.'
346+
)
347+
}
348+
const currentRef = config.get('keyRef')
349+
if (!currentRef) {
350+
return out.fail(
351+
'KEY_PROJECT_WITHOUT_KEY_REF',
352+
'--key-project scopes a key reference, and this wallet does not use one. Pass --key-ref <provider>:<reference> alongside it, or drop --key-project.',
353+
{
354+
cta: {
355+
description: 'Configure a reference and its scope together:',
356+
commands: keyRefCtaCommands().map((cmd) => ({
357+
...cmd,
358+
options: { ...cmd.options, keyProject: c.options.keyProject },
359+
})),
360+
},
361+
}
362+
)
363+
}
364+
const badProject = c.options.keyProject
365+
? unsafeRefReason(c.options.keyProject)
366+
: null
367+
if (badProject) {
368+
return out.fail(
369+
'INVALID_KEY_PROJECT',
370+
`Invalid key project: it ${badProject}.`
371+
)
372+
}
373+
374+
out.step('Scoping key reference')
375+
// An empty value is how a scope is dropped deliberately — the same
376+
// convention the --keyRef branch below uses.
377+
if (c.options.keyProject) {
378+
config.set('keyRefProject', c.options.keyProject)
379+
} else {
380+
config.delete('keyRefProject')
381+
}
382+
const parsed = parseKeyRef(currentRef)
383+
const providerAvailable = parsed
384+
? isProviderAvailable(parsed.provider)
385+
: false
386+
if (!agent) {
387+
out.success(
388+
c.options.keyProject
389+
? `Key reference ${currentRef} is now scoped to project ${c.options.keyProject}.`
390+
: `Key reference ${currentRef} is no longer scoped to a project.`
391+
)
392+
p.outro("You're all set!")
393+
}
394+
return out.done({
395+
status: 'configured',
396+
method: 'keyRef',
397+
keyRef: currentRef,
398+
keyProject: config.get('keyRefProject'),
399+
providerAvailable,
400+
})
401+
}
402+
329403
// Before --keystore and --privateKey so an explicit method always wins, and
330404
// deliberately allowed in agent mode: unlike a keystore there is no prompt,
331405
// so this is the one custody mode that works from MCP with no key at rest.
@@ -349,6 +423,28 @@ export const initCommand = {
349423
`Unknown key-reference provider "${parsed.provider}". Supported: ${providerNames().join(', ')}.`
350424
)
351425
}
426+
// Init is the only moment this is cheap to catch, and the file says so
427+
// about keystore paths a few lines up. Without it, a reference the
428+
// resolver will always refuse was stored anyway — `wallet init` reported
429+
// `configured` and cleared the previous wallet, and every command after
430+
// it failed with "re-run `foc-cli wallet init`", pointing back at the
431+
// command that had just accepted the value.
432+
const badRef = unsafeRefReason(parsed.ref)
433+
if (badRef) {
434+
return out.fail(
435+
'INVALID_KEY_REF',
436+
`Invalid key reference: it ${badRef}.`
437+
)
438+
}
439+
const badProject = c.options.keyProject
440+
? unsafeRefReason(c.options.keyProject)
441+
: null
442+
if (badProject) {
443+
return out.fail(
444+
'INVALID_KEY_PROJECT',
445+
`Invalid key project: it ${badProject}.`
446+
)
447+
}
352448
// Validate the shape only, not that it resolves. Resolution needs the
353449
// provider to be installed and authenticated, which is a different
354450
// failure with a different fix — and init must stay usable while setting

0 commit comments

Comments
 (0)