Skip to content

feat: report the agentic wallet in doctor - #92

Merged
joelorzet merged 4 commits into
mainfrom
feat/KEEP-1149-doctor-agentic-wallet
Aug 7, 2026
Merged

feat: report the agentic wallet in doctor#92
joelorzet merged 4 commits into
mainfrom
feat/KEEP-1149-doctor-agentic-wallet

Conversation

@joelorzet

Copy link
Copy Markdown
Contributor

Stacked on #91. Base that PR first, or retarget this to main after it merges.

kh doctor covered the organization wallet only. The agentic wallet is a separate object with its own local config and its own credential, so an agent running on one had no way to learn whether it was configured, still accepted, or funded.

Why HMAC and not a bearer token

The check signs with the HMAC secret already in ~/.keeperhub/wallet.json. That is the security property, not an implementation detail: the signature proves possession of one specific wallet's secret, so the check can only ever report the wallet held on this machine.

The alternative was a bearer-authenticated lookup route such as GET /api/agentic-wallet/me. That would make wallets enumerable by any account with platform access, so it was rejected. No platform change was needed; GET /api/agentic-wallet/credit already exists and already verifies the signature.

Reported states

State Output
No local config not configured (kh wallet add)
200 address plus remaining credit
401 rejected by the platform (secret may have been rotated)
other could not check agentic wallet (HTTP n)

The secret

Never printed, logged, or included in an error. Two tests hold that: one over the rendered error from a malformed config, one over the command's stdout, which is the surface an agent captures.

Verification

The signing string is pinned against a vector produced by the platform's own computeSignature, so a divergence fails here rather than as a 401 in the field:

sig(secret, GET, /api/agentic-wallet/credit, sub_abc123, "", 1786000000)
  = 3becc7ba14cd84066fc6194588db62471841ccfc012d4ae9e8332f1fed68c592

A second test asserts every signed field (method, path, subOrgId, body, timestamp, secret) actually changes the output, so nothing silently drops out of the signature.

At the doctor level the fake server recomputes the signature the way the platform does and rejects a mismatch, so a client signing the wrong string fails the test rather than passing on the presence of some headers. It also enforces the five minute replay window.

Full suite: 29 packages ok. go vet clean. go generate ./docs/ committed alongside the help text change.

doctor covered the organization wallet only. The agentic wallet is a separate
object with its own local config and its own credential, so an agent running
on one had no way to learn whether it was configured, still accepted, or
funded.

The check reads GET /api/agentic-wallet/credit, signing with the HMAC secret
already in ~/.keeperhub/wallet.json rather than the user's bearer token.
Signing as the wallet is the security property, not an implementation
detail: the signature proves possession of one specific wallet's secret, so
the check can only ever report the wallet held on this machine, and no
endpoint has to answer "describe wallet X" for an account that merely has
platform access. That is also why this does not add a bearer-authenticated
lookup route, which would make wallets enumerable.

Reported states: not configured, configured with its address and remaining
credit, rejected (which usually means the secret was rotated), and
unreachable.

The secret is never printed, logged, or included in an error. Two tests hold
that line, one over the rendered error and one over the command's stdout,
which is the surface an agent captures.

internal/agentic carries the config reader and the signer. Its signature is
pinned against a vector produced by the platform's own computeSignature, so a
divergence in the signing string fails here rather than as a 401 in the
field, and a second test asserts every signed field actually changes the
output.
Base automatically changed from fix/KEEP-1148-doctor-wallet-check to main August 7, 2026 15:50
@joelorzet
joelorzet requested review from a team, OleksandrUA, eskp and suisuss and removed request for a team August 7, 2026 15:50
The message named POST /api/integrations/wallet, which does not exist. The
real creation route is POST /api/user/wallet, but wallet provisioning is
session-only, so no endpoint is worth naming to a caller holding an API key.
Settings is the only path that actually works.
The credit amount is a decimal string, not a number: the platform builds it
with toFixed(2). Decoding it into a float64 failed on every real response, so
the check reported "could not parse credit response" for a wallet that was
working. The fake server had been written to emit a JSON number, which is why
the tests passed against a shape the platform never sends. It now sends what
the platform sends, and two tests fail if the float decode returns.

The signed path now comes from the request URL instead of a package constant.
A host carrying a path prefix signed one path and requested another, and the
resulting 401 was reported as a rotated secret. Signing moved into
internal/agentic alongside Sign, so the request and the signature cannot
drift and a second caller does not have to copy the header sequence.

A 404 is now distinguished from an unspecified failure. It is the documented
answer when the platform holds no secrets for the sub-org, which happens after
rotation past the grace window or when pointed at the wrong host, and the
message says so.

Absent is now pass rather than warn. Most installs never provision an agentic
wallet, and a permanent warning on nearly every run devalues the level for
checks that mean something. This matches how the spend-cap check treats
billing not being enabled. A config that exists but lacks a required field is
now its own state, so someone who just ran `kh wallet add` is not told to run
it again.

A 200 missing the expected fields no longer renders as a funded wallet with
zero credit, which read as "out of funds" rather than "that was not the credit
endpoint".

The tests isolated XDG_CONFIG_HOME but not HOME, so on any machine with a
provisioned wallet the suite read the developer's real config and signed a
live request with their secret. Every test now gets its own HOME.

TestConfig_SecretIsNotInTheJSONRoundTrip asserted nothing about the secret and
its name described a property that was false. Config now has a String method
that redacts the secret, which is a guarantee worth having on a credential,
and the tests assert it in both directions.

Dropped the unused HeaderKeyVersion. Not sending it is deliberate, so the
reason is recorded where the constants are.
@joelorzet
joelorzet merged commit 968fbda into main Aug 7, 2026
4 checks passed
@joelorzet
joelorzet deleted the feat/KEEP-1149-doctor-agentic-wallet branch August 7, 2026 16:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant