synod keeps its own accounts, in the computer’s credential manager
A key reaches exarch through the environment, because exarch is started
from a shell by someone who has one. Synod is double-clicked, inherits the
desktop’s environment, and faces someone with no .zshrc to export from — so
it asks for keys in a window and keeps them where the computer already keeps
passwords. The two products now have two credential stories, and what they
share is the library, not the policy: exarch’s own paths are untouched, and
every piece synod needed was added beside the machinery it generalises.
Context
Until now synod’s session::prepare called exarch::config::load() and
CredentialStore::resolve_and_scrub, which is exactly exarch’s startup: read
$XDG_CONFIG_HOME/exarch/config.ral, sweep the conventional key variables,
scrub them from the environment. For a developer running synod out of a
terminal this works. For the person synod is for it does not work at all —
a double-clicked application inherits the desktop session’s environment, and
there is no step in which a key could have got into it. Synod could reach
providers only if someone had first made it a shell program.
Two smaller frictions rode along. Synod read exarch’s config file, so a
declaration made for one product silently governed the other. And the sweep
is one-shot by construction: resolve_and_scrub mutates the environment and
so may only run while the process is single-threaded, which means a key
arriving mid-session had nowhere to go.
Decision
-
The credential manager first, the environment as fallback.
synod::accounts::prepareruns the environment sweep exactly as before — it is still the step that must happen single-threaded — and then lays the vault over the top, so a key typed into the window outranks a stale variable in the launching environment. A developer’s terminal keeps working with no ceremony; a key that was never typed into the window is never silently written into the vault either. -
One door to the vault, in the library.
exarch::provider::keychainreaches the macOS Keychain, the Windows Credential Manager, or a Linux desktop’s Secret Service through onekeyringentry named(app, provider-label)— so synod’s Anthropic key and exarch’s are two entries, exactly as their config directories are two directories. Exarch calls none of it. The module is shared machinery the second product drives, not a change to the first. -
A computer with no credential manager is told about, not lied to.
Entry::store_status()is asked first; where there is none the fallback is an owner-only file beside the app’s own configuration, andKeychain::vaultanswers where secrets on this computer actually land in a sentence the window prints verbatim. A headless box gets an honest sentence rather than an implied protection that is not there. The file is born owner-private throughprovider::secret_file::write_private— theChatGPTtoken store’s existing care, extracted so the two callers share one implementation rather than two copies of a Windows DACL. -
Endpoints are synod’s own file, and hold no secrets.
$XDG_CONFIG_HOME/synod/providers.ralis read and written by the same decoder as exarch’s config (config::load_declared/save_declared, the oldload()body over a caller-named path). It carries addresses and protocols, never keys, so it needs no special permissions. A name or address carrying a quote is asked about, not escaped: these are things a person typed into a window, and a service whose name contains a quote is a typo to raise, not a string to encode. -
Provenance is recorded at the binding.
CredentialStoreremembers which of its two doors each key came through (admit_keysets it,forget/retireclear it,was_admittedreads it). The window needs this to know whether a key is its to take back — and deriving it instead by asking the vault, entry by entry, would cost a round trip per provider every time the accounts list was drawn, with an unlock prompt apiece on a locked keychain. The store is the only thing that knows; it should say.
Consequences
-
Synod gains five commands (
shell/keys.rs), each returning the fresh list and ending in the samemodels-refreshedevent a sign-in ends in, so a key typed into the window is usable on the very next conversation with no restart. This isadd_oauth’s bargain extended to API keys, and it is only available becauseadmit_keysidesteps the un-repeatable sweep. -
A key that came from the environment cannot be removed from the window, and the accounts screen says so, names the variable, and offers the one remedy there is: type another. Withdrawing a declared endpoint removes the service; forgetting a key leaves the service known and unbound. These are
retireandforget, and the distinction is the user’s, not an implementation detail. -
exarch is unchanged in behaviour: it still reads env keys and its own
config.ral, and calls neitherkeychainnor the four new store mutators. The risk accepted is the ordinary one of a shared library — a change made for synod’s sake now sits in exarch’s crate, and must keep earning its place there rather than drifting into exarch’s paths. -
keyring 4.1is a new dependency (+188 lines ofCargo.lock), reaching three platform APIs behind one type.
Not settled here
The real Keychain, Credential Manager, and Secret Service round trips are untested: no GUI runs in the development sandbox. The first run on a Mac or a Windows box is the evidence, and until then the fallback arm is the only one exercised.