Skip to content

Launching the app no longer opens the keychain - #190

Merged
tosinamuda merged 1 commit into
mainfrom
perf/keychain-free-launch
Aug 26, 2026
Merged

Launching the app no longer opens the keychain#190
tosinamuda merged 1 commit into
mainfrom
perf/keychain-free-launch

Conversation

@tosinamuda

Copy link
Copy Markdown
Contributor

Reported as a macOS password prompt on every launch. #188 deferred the read in setup, and that part worked — but two things reached for a key moments later, which from the user's seat is the same thing.

Listing agents read every agent's key

harness_list builds the whole registry to enumerate it, and building OpenRouter resolves its stored key. Nothing about listing needs one: credentialRequired is a capability"does this agent take a key" — and the picker asks for stored state separately.

The boot readiness probe asked for auth state

It wants installed, which is what gates the composer (workspaceModel.ts:421). Auth state rides in the same struct and, for a hosted provider, costs a keychain read to fill in.

Measured before deciding rather than assumed:

Resolve: installed=true ready=false
Skip:    installed=true ready=false

installed is unaffected — and nothing in the front end reads authConfigured at all.

So harness_readiness takes with_auth, defaulting to false, and the intent sits at the call site: Settings displays agent status and opts in; boot and the default-agent probe do not.

Migration too

every_spec() takes Skip. It wants each spec's names; resolving keys there was both a reason to open the keychain and the source of the deadlock fixed in #188 — whose guard is now defence in depth rather than load-bearing.

Verified with a control

An earlier claim of mine rested on a probe I had not shown could fire, so this one has a positive control:

CONTROL:       1   ← boot reaches credential setup, stderr works
KEYCHAIN OPEN: 0   ← the store is never opened during launch

253 Rust tests, 558 frontend, clippy clean.

Also

compose_registry() is gone. Every caller now states which mode it wants, so the convenient default that hid the question no longer exists.

Note on the prompt itself

The dialog is the ACL prompt — macOS asking because the binary reading the item is not the one that created it. This machine has run many ad-hoc-signed debug builds alongside two Developer ID releases against the same item. A user who only ever runs signed releases keeps a stable identity and would not see it. This change is worth making regardless: launching an editor should not touch the keychain.

Reported as a macOS password prompt on every launch. Deferring the read
in `setup` was not enough, because two things reached for a key moments
later — which from the user's seat is the same thing.

**Listing agents read every agent's key.** `harness_list` builds the whole
registry to enumerate it, and building OpenRouter resolves its stored key.
Nothing about listing needs one: `credentialRequired` is a CAPABILITY —
"does this agent take a key" — and the picker asks for stored state
separately. `Secrets::Skip` builds identity and capability only.

**The boot readiness probe asked for auth state.** It wants `installed`,
which is what gates the composer (`workspaceModel.rs:421`); auth state is
carried in the same struct and, for a hosted provider, costs a keychain
read to fill in. Measured before deciding: `installed` is true either
way, and nothing in the front end reads `authConfigured` at all.

So `harness_readiness` now takes `with_auth`, default false. The intent
sits at the call site: Settings, which displays agent status, opts in;
boot and the default-agent probe do not.

Migration takes `Skip` too. It wants each spec's NAMES, and resolving keys
there was both a reason to open the keychain and the source of the
deadlock fixed in #188 — the guard for which is now defence in depth
rather than load-bearing.

Verified with a CONTROLLED measurement, because an earlier claim of mine
rested on a probe I had not shown could fire:

    CONTROL: 1        (boot reaches credential setup; stderr works)
    KEYCHAIN OPEN: 0  (the store is never opened during launch)

`compose_registry()` went with it: every caller now states which mode it
wants, so the convenient default that hid the question is gone.
@tosinamuda
tosinamuda merged commit 5ff373b into main Aug 26, 2026
3 checks passed
@tosinamuda
tosinamuda deleted the perf/keychain-free-launch branch August 26, 2026 06:09
tosinamuda added a commit that referenced this pull request Aug 26, 2026
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.
tosinamuda added a commit that referenced this pull request Aug 26, 2026
* fix(credentials): asking about a key no longer creates one

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

* fix(credentials): nothing asks the keychain at launch

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.

* fix(secret-store): a test can no longer delete the real master key

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.

* fix(secret-store): a guard restores the one it nested inside

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.
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