Nothing asks the keychain until a key is genuinely needed - #191
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
installed, not authharness_list_modelsresolves 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 takeswith_authlikeharness_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_existinganswers "nothing stored" without minting, whereloadminted 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_existingcannot distinguish "never minted" from "key lost" and does not have to: both mean it has nothing to hand over.loaded_for_writestill 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: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:
3f1f180c…73e3df9b…4e55cf89…68f900fa…ai.latentic.composeai.latentic.composeThe reader launched cold, opened Settings → OpenRouter, and rendered
Saved key sk-or…beef — OpenRouter is ready to use.No prompt. NoSecurityAgentprocess. Nothing in the log.The reason is the designated requirement the ACL stores:
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::Resolveand 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 testraised a keychain prompt and then deleted the developer'sai.latentic.composeentry, with every saved API key in it. This is onmaintoday, shipped in #188.keyring_service()undercfg(test)fell back to the live service when no guard was installed:Two of eleven store tests installed a guard. The rest ran against the real entry — and one calls
clear(), which callsdelete_master_key(). The fileclearremoves 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:
There is now no route back to the real service from a test:
keyring_service()panics and names the fixTestKeychain::new()takes no argument and derives a service unique to the caller, so guards cannot collide and no test invents a namecleartest gets the guard it always neededTwo 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.composeitem and no throwaway leftovers.