Skip to content

Nothing asks the keychain until a key is genuinely needed - #191

Merged
tosinamuda merged 4 commits into
mainfrom
fix/reads-never-mint
Aug 26, 2026
Merged

Nothing asks the keychain until a key is genuinely needed#191
tosinamuda merged 4 commits into
mainfrom
fix/reads-never-mint

Conversation

@tosinamuda

@tosinamuda tosinamuda commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

A user who never uses an agent that needs an API key should never see a keychain prompt. Someone who does should see it when they save a key or send a message — not when they open the app.

That was not true. Installing Compose and launching it put an item in the keychain before the user had typed anything.

Four boot paths reached the credential store

Found one at a time, because each fix revealed the next:

# Path What it actually wants Fixed in
1 agent catalog the list of agents — not keys #190
2 readiness probe installed, not auth #190
3 migration spec names #190
4 model listing models — usually public here

harness_list_models resolves the harness to ask it for models, and resolving a hosted harness reads its stored key. The composer footer and the default-model pick both call it at boot. It now takes with_auth like harness_readiness: off by default, and Settings — where someone is choosing a model and an authenticated endpoint has to list — opts in. Most endpoints list publicly, and a listing that fails leaves the picker on free-text, which it already handles.

Chasing callers was losing to a general rule

So reads no longer create anything. SecretStore::open_existing answers "nothing stored" without minting, where load minted on an empty vault.

Minting is correct for a write — saving needs somewhere to save to — and wrong for a read. On a fresh install the answer to "is a key configured?" is no, and answering it should not create a vault. open_existing cannot distinguish "never minted" from "key lost" and does not have to: both mean it has nothing to hand over. loaded_for_write still mints, which is where minting belongs.

Measured, on a bundled binary

An earlier measurement of mine was wrong — it used cargo build's bare binary, which does not embed the front end, so the UI never ran and the number was meaningless. This one checks that the front end is embedded and that the run created the same state a real launch does:

frontend embedded:        yes
UI ran:                   app.db  trash  vaults  workspaces.json
KEYCHAIN READS at launch: 0

The update path, tested rather than assumed

The remaining worry was upgrades: the binary that reads the master key on alpha.15 is not the binary that created it on alpha.14. So two Developer-ID-signed builds were used, deliberately as different as possible:

creator reader
kind release debug — entirely different code
md5 3f1f180c… 73e3df9b…
CDHash 4e55cf89… 68f900fa…
identifier ai.latentic.compose ai.latentic.compose
identity Developer ID (94SW7AUBMX) same

The reader launched cold, opened Settings → OpenRouter, and rendered Saved key sk-or…beef — OpenRouter is ready to use. No prompt. No SecurityAgent process. Nothing in the log.

The reason is the designated requirement the ACL stores:

identifier "ai.latentic.compose" and anchor apple generic
  and certificate 1[field.1.2.840.113635.100.6.2.6]
  and certificate leaf[field.1.2.840.113635.100.6.1.13]
  and certificate leaf[subject.OU] = "94SW7AUBMX"

No CDHash, no version — identity and team only. Every build signed with that certificate satisfies it, and the delta tested here is larger than any real version bump. Ad-hoc dev builds do not satisfy it, which is why they prompted throughout development; that was never the shipped path.

Unchanged

Sending resolves with Secrets::Resolve and reads the key. Saving mints one. Those are the moments a key is genuinely needed.

254 Rust tests and 558 front-end tests pass.


Also here: a test could delete your real master key

Found while verifying the above — cargo test raised a keychain prompt and then deleted the developer's ai.latentic.compose entry, with every saved API key in it. This is on main today, shipped in #188.

keyring_service() under cfg(test) fell back to the live service when no guard was installed:

TEST_SERVICE.with(...).unwrap_or_else(|| KEYRING_SERVICE.to_owned())

Two of eleven store tests installed a guard. The rest ran against the real entry — and one calls clear(), which calls delete_master_key(). The file clear removes is scoped to a temp dir; the keychain entry it deletes is not. The prompt came first because the test binary is not the signed app, so it is not in that item's ACL.

The doc comment immediately above the fallback already stated the rule it broke:

a test must never write to the developer's own ai.latentic.compose entry.

There is now no route back to the real service from a test:

  • absent a guard, keyring_service() panics and names the fix
  • TestKeychain::new() takes no argument and derives a service unique to the caller, so guards cannot collide and no test invents a name
  • the clear test gets the guard it always needed

Two tests hold the invariant — one asserts the unguarded call panics, one asserts a guard never names the service the app uses. After the fix a full run leaves no ai.latentic.compose item and no throwaway leftovers.

Installing Compose and launching it put an item in the keychain before
the user had typed anything.

Reading went through `SecretStore::load`, which mints when there is
neither a key nor a file — the correct behaviour for a WRITE, since
saving needs somewhere to save to, and the wrong one for a read. On a
fresh install the answer to "is a key configured?" is no, and answering
it should not create a vault.

`open_existing` answers without writing. No master key means nothing is
stored, which is all a reader needs to know; it cannot distinguish "never
minted" from "key lost" and does not have to, because both mean it has
nothing to hand over. `loaded_for_write` still mints, which is where
minting belongs.

Found because the machine was reset to a genuine first-run state and the
app was watched. `harness_list_models` — which the front end calls at
boot to fill the model picker — resolves the harness, and resolving
OpenRouter reads its key. That was the FOURTH boot path into the store
after the catalog, the readiness probe and migration. Fixing callers one
at a time was losing to a general rule: a read creates nothing.

The earlier measurement claiming a keychain-free launch was WRONG, and
wrong in a way worth recording. It ran `./target/debug/compose` built by
`cargo build`, which does not embed the front end — so the UI never ran
and could not make the calls being looked for. The control marker printed
because it is in Rust setup, which runs either way, and that made a dead
probe look alive. The measurement now runs the bundled binary and checks
the front end is embedded and that the run created the same state a real
launch does before believing a zero.

    frontend embedded: yes
    state created: app.db trash vaults workspaces.json
    mints: 0
    master key: none
The invariant, stated plainly: a user who never uses an agent that needs
an API key should never see a keychain prompt. Someone who does should
see it when they save a key or send a message — not when they open the
app.

Four boot paths reached the credential store, found one at a time
because each fix revealed the next:

  1. the agent catalog          — listing does not need keys      (#190)
  2. the readiness probe        — wants `installed`, not auth     (#190)
  3. migration                  — wants spec NAMES                (#190)
  4. model listing              — this commit

`harness_list_models` resolves the harness to ask it for models, and
resolving a hosted harness reads its stored key. The composer footer and
the default-model pick both call it at boot. It now takes `with_auth`
like `harness_readiness`: off by default, and Settings — where someone is
choosing a model and an authenticated endpoint has to list — opts in.
Most endpoints list publicly, and a listing that fails leaves the picker
on free-text, which it already handles.

Chasing callers was losing to a general rule, so this also stops READS
creating anything: `open_existing` answers "nothing stored" without
minting, where `load` minted on an empty vault. That is correct for a
write and wrong for a read, and it is why installing Compose and opening
it put an item in the keychain before the user had typed.

Measured on a BUNDLED binary, first-run state, with the front end
confirmed embedded and the run confirmed to have created the same state a
real launch does — the check my earlier, wrong, measurement lacked:

    frontend embedded: yes
    UI ran: app.db trash vaults workspaces.json
    KEYCHAIN READS at launch: 0

Unchanged: sending resolves with `Secrets::Resolve` and reads the key,
and saving mints one. Those are the moments a key is genuinely needed.
Running `cargo test` raised a keychain prompt and then deleted the
developer's `ai.latentic.compose` master key, taking every API key they
had saved with it.

`keyring_service()` under `cfg(test)` fell back to the REAL service name
when no `TestKeychain` was installed:

    TEST_SERVICE.with(...).unwrap_or_else(|| KEYRING_SERVICE.to_owned())

Two of eleven store tests installed a guard. The rest ran against the
live entry, and one of them calls `clear()`, which calls
`delete_master_key()`. The file `clear` removes is scoped to a temp dir;
the keychain entry it deletes is not. The prompt came first, because the
test binary is not the signed app and so is not in that item's ACL.

The doc comment directly above the fallback already stated the rule it
broke: "a test must never write to the developer's own
`ai.latentic.compose` entry."

There is now no route back to the real service from a test. Absent a
guard, `keyring_service()` panics and names the fix. `TestKeychain::new()`
takes no argument and derives a service unique to the caller, so guards
cannot collide and no test has to invent a name. The `clear` test gets
the guard it always needed.

Two tests hold the invariant: one asserts the unguarded call panics, one
asserts a guard never names the service the app uses.

Found the hard way — it deleted a key mid-session, mine to lose that
time. Confirmed after the fix: a full run leaves no `ai.latentic.compose`
item and no throwaway leftovers.

256 tests pass.
Dropping an inner `TestKeychain` cleared the service outright, so an
outer guard was left pointing at nothing and the next call to
`keyring_service()` panicked somewhere unrelated to the mistake. It now
restores what it replaced, and a test covers the nesting.
@tosinamuda
tosinamuda merged commit 991a6ec into main Aug 26, 2026
3 checks passed
@tosinamuda
tosinamuda deleted the fix/reads-never-mint branch August 26, 2026 07:58
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