From 492be5d1ef000cb311cd706fbd2024ce1ff8fba5 Mon Sep 17 00:00:00 2001 From: Anders Robstad Date: Fri, 4 Sep 2026 14:00:53 +0200 Subject: [PATCH] Say so when a container has no credential for a collection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The credentials file landed in 0.15.0, but only for people who migrated onto it. A workload created before it goes on reading its frozen FIBER_SECRETS snapshot, and a collection authenticated after that workload started is not stale in the snapshot — it is absent. There is no 401, so nothing refreshes and nothing retries; the send fails before it is made, with "not signed in — open Section settings and sign in". In a container there are no Section settings, no window to sign in through, and usually a user who is signed in and whose token simply never reached the process. The 401 path has had source-aware advice since the file landed; the failure that happens first had none. It does now. `send_failure` names the collection and the reference, says which source the server reads, and — for a snapshot — that the source was frozen when the workload started and cannot pick a later sign-in up. It asks `secrets::has` rather than matching on the error text, so a credential that exists and is being rejected still gets the message it had. The desktop app has neither variable set and is unchanged. Two more places notice. The server reports its credential source at startup and warns there about every shared collection whose credential it cannot see — a warning, not a refusal to start, because under a file the value legitimately arrives later. And `list_sections` marks those collections `"credential": "missing"`, so an agent finds out before spending a call rather than after. Silently re-captured browser credentials reach the file too. On a 401 the app lifts a fresh one out of a hidden webview and deliberately does not write it to the keychain, because writing one costs a password prompt on a build that cannot hold an ACL. None of that reasoning carries over to the sealed file — the key is already cached for the life of the process and the write is a file write — but it was being skipped all the same, so a container only ever saw a browser credential change on an explicit sign-in. That is the same failure the file was added to end, left open for the one auth kind that cannot refresh itself. Rerunning scripts/toolhive.sh migrates a pre-0.15 workload, which nothing prompted anyone to do. It now says when it has, and that the fiber-secrets snapshot left behind is unread and holds whatever the credentials were the day it was taken. --- .changeset/missing-credentials-say-so.md | 32 ++++++ README.md | 4 + deploy/toolhive.md | 38 +++++++ scripts/toolhive.sh | 28 +++++ src-tauri/src/browser.rs | 29 ++++- src-tauri/src/lib.rs | 23 +++- src-tauri/src/mcp.rs | 134 ++++++++++++++++++++++- src-tauri/src/secrets.rs | 101 ++++++++++++++++- src-tauri/src/send.rs | 5 + 9 files changed, 381 insertions(+), 13 deletions(-) create mode 100644 .changeset/missing-credentials-say-so.md diff --git a/.changeset/missing-credentials-say-so.md b/.changeset/missing-credentials-say-so.md new file mode 100644 index 0000000..828d54e --- /dev/null +++ b/.changeset/missing-credentials-say-so.md @@ -0,0 +1,32 @@ +--- +"fiber": minor +--- + +A containerised MCP server now says when it has no credential for a collection, +instead of giving desktop advice. + +The credentials file landed in 0.15.0, but only for people who migrated onto it. +A workload created before it goes on reading its frozen `FIBER_SECRETS` snapshot, +and a collection authenticated after that workload started is not stale in the +snapshot — it is absent. There is no 401, so nothing refreshes and nothing +retries; the send fails before it is made, with "not signed in — open Section +settings and sign in". In a container there are no Section settings, no window +to sign in through, and usually a user who *is* signed in. + +That failure now explains itself. It names the collection and the reference, +says which source the server reads, and — for a snapshot — that the source was +frozen when the workload started and cannot pick a later sign-in up. The server +also reports its credential source at startup and warns there about every shared +collection whose credential it cannot see, and `list_sections` marks those with +`"credential": "missing"` so an agent finds out before spending a call. + +Silently re-captured browser credentials reach the file too. On a 401 the app +lifts a fresh one out of a hidden webview and deliberately does not write it to +the keychain, because writing costs a password prompt. None of that reasoning +applies to the credentials file — the sealing key is already cached and the +write is a file write — but it was being skipped all the same, so a container +only ever saw a browser credential change on an explicit sign-in. + +Rerunning `scripts/toolhive.sh` migrates a pre-0.15 workload, and now says so +when it does, including that the old snapshot is left behind holding stale +credentials. diff --git a/README.md b/README.md index dce0358..da096fb 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,10 @@ It finds your collections, copies the credentials for the collections you have shared straight from the keychain into ToolHive's encrypted store, and starts the server. Nothing is typed twice and nothing is pasted. +Rerun it to migrate a server set up before Fiber 0.15, which reads a snapshot of +your credentials frozen when it started — see +[Migrating from `FIBER_SECRETS`](deploy/toolhive.md#migrating-from-fiber_secrets). + See [`deploy/toolhive.md`](deploy/toolhive.md) for what each step does, and for running it by hand. diff --git a/deploy/toolhive.md b/deploy/toolhive.md index bb0ff1f..9c78099 100644 --- a/deploy/toolhive.md +++ b/deploy/toolhive.md @@ -147,6 +147,12 @@ presenting the old credential until you re-export and replace the workload — rerunning `toolhive.sh` does both. That is the behaviour the file above exists to avoid. +Worth being precise about what "old credential" covers, because the sharper case +is easy to miss: a collection you authenticated *after* the workload started is +not in the snapshot at all. It fails with no credential rather than a rejected +one, so there is no 401 and nothing to refresh — see +[Migrating from `FIBER_SECRETS`](#migrating-from-fiber_secrets). + If you have no app on the machine, the same map typed by hand does the same job: ```sh @@ -163,6 +169,38 @@ app would have put in the keychain. you manage yourself; setting the key and pointing it at a plaintext file is an error rather than a silent downgrade, and so is an encrypted file with no key. +### Migrating from `FIBER_SECRETS` + +A workload created before the credentials file existed goes on reading its +snapshot, and nothing announces it: the container is healthy, the collections it +knew about still work, and only a collection you authenticated *after* the +workload started fails — with the credential simply absent rather than expired. +The symptom is "it works in the app but not over MCP". + +Rerunning the install migrates it, because the script replaces the workload: + +```sh +curl -fsSL https://raw.githubusercontent.com/MathiasWP/fiber/main/scripts/toolhive.sh | bash +``` + +Afterwards the old snapshot is unused, and it is a stale copy of every +credential you had the day it was taken, so take it away: + +```sh +thv secret delete fiber-secrets +``` + +Two things will tell you which scheme a running server is on. It logs its source +at startup — + +``` +credentials: FIBER_SECRETS, a snapshot frozen at startup +``` + +— and warns there about any shared collection whose credential it cannot see. +`list_sections` marks the same collections with `"credential": "missing"`, so an +agent finds out before spending a call rather than after. + ### Why this is not as good as the keychain Inside a container, injected secrets live in the process environment or a diff --git a/scripts/toolhive.sh b/scripts/toolhive.sh index 98281ee..23d49f1 100755 --- a/scripts/toolhive.sh +++ b/scripts/toolhive.sh @@ -22,6 +22,10 @@ NAME="fiber" # the mounted collections directory, so the app can keep them current. SECRET_KEY="fiber-key" SECRETS_FILE="mcp-secrets.enc" +# What the pre-0.15 setup used: a JSON snapshot of every credential, injected +# as FIBER_SECRETS. Replaced by the file above, and named here only so a rerun +# can tell you it is now dead weight. +LEGACY_SECRET="fiber-secrets" die() { echo "$*" >&2 @@ -65,8 +69,16 @@ fi # A rerun should replace the workload rather than collide with it. `thv rm` # takes the container away; the collections and the secret both outlive it. +# +# This is also the only migration path off the pre-0.15 setup, and nothing else +# will prompt anyone to take it: a workload created back then goes on reading a +# frozen FIBER_SECRETS snapshot, quietly, for as long as it runs. Saying so here +# is the difference between "my new collection 401s over MCP but works in the +# app" and knowing why. +replaced="" if thv list --all 2> /dev/null | grep -q "^$NAME[[:space:]]"; then echo "Replacing the existing '$NAME' workload..." + replaced="yes" thv stop "$NAME" > /dev/null 2>&1 || true thv rm "$NAME" > /dev/null 2>&1 || true fi @@ -129,5 +141,21 @@ fi echo echo "Done. '$NAME' is serving $data." + +# The snapshot outlives the workload that used it, and ToolHive gives no sign +# that nothing reads it any more. Left in place it is a stale copy of every +# credential you had the day it was taken, so it is worth saying out loud. +if [ -n "$replaced" ] && [ "${#secret_args[@]}" -ne 0 ] && + thv secret list 2> /dev/null | + grep -q -- "^[[:space:]]*-[[:space:]]*$LEGACY_SECRET\$"; then + echo + echo "This workload reads the credentials file, so signing in again in Fiber" + echo "reaches it without a rerun. The '$LEGACY_SECRET' snapshot it used before is" + echo "no longer read by anything, and holds whatever your credentials were the" + echo "day it was taken. Remove it with:" + echo " thv secret delete $LEGACY_SECRET" +fi + +echo echo "Check it with: thv list" echo "Point a client at it with: thv client setup" diff --git a/src-tauri/src/browser.rs b/src-tauri/src/browser.rs index 74775f5..b2747b8 100644 --- a/src-tauri/src/browser.rs +++ b/src-tauri/src/browser.rs @@ -20,6 +20,7 @@ //! credential (it just can't capture a new one, having no UI). use std::future::Future; +use std::path::PathBuf; use std::pin::Pin; use std::sync::Mutex; use std::time::Duration; @@ -747,11 +748,14 @@ pub async fn silent_recapture(app: &AppHandle, section: &Section) -> Result Self { - Self { app } + pub fn new(app: AppHandle, data: PathBuf) -> Self { + Self { app, data } } } @@ -761,9 +765,26 @@ impl Recapturer for BrowserRecapture { section: &'a Section, ) -> Pin> + Send + 'a>> { Box::pin(async move { - silent_recapture(&self.app, section) + let value = silent_recapture(&self.app, section) .await - .map_err(|err| err.to_string()) + .map_err(|err| err.to_string())?; + + // The keychain is skipped for a silent re-capture — writing one + // costs a password prompt on a build that cannot hold an ACL, and + // `send` explains why that trade is not worth making here. None of + // it carries over to the credentials file: the sealing key is + // already cached for the life of the process and the write is a + // file write, so the cost is nothing, and the file is the only + // channel a containerised server has. + // + // Without this, the token a container holds only ever moved on an + // explicit sign-in: the app could refresh a browser credential + // silently all day and the server would go on presenting the one it + // started with — the very failure the file was added to end. + if let Some(reference) = section.auth.secret_ref() { + crate::mcp::sync_secrets_file(&self.data, reference, Some(&value)); + } + Ok(value) }) } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8552cf0..956d15c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -240,7 +240,7 @@ mod gui { let at = history::now_millis(); let url = spec.url.clone(); - let recapture = BrowserRecapture::new(app.clone()); + let recapture = BrowserRecapture::new(app.clone(), paths.data.clone()); // The channel is the only reason the body is on the bridge at all // mid-flight. A send that fails means the window has gone; the request @@ -390,17 +390,20 @@ mod gui { app: &AppHandle, http_state: &Arc, auth_state: &Arc, + data: &Path, section: &Section, ) -> loader::Fetcher { let http = http_state.clone(); let auth = auth_state.clone(); let app = app.clone(); + let data = data.to_path_buf(); let section = section.clone(); Arc::new(move |request: loader::LoaderRequest| { let http = http.clone(); let auth = auth.clone(); let app = app.clone(); + let data = data.clone(); let section = section.clone(); Box::pin(async move { @@ -425,7 +428,7 @@ mod gui { ..Default::default() }; - let recapture = BrowserRecapture::new(app); + let recapture = BrowserRecapture::new(app, data); let response = send_authenticated( &http, &auth, @@ -471,7 +474,13 @@ mod gui { ) -> Result { let section = section_for_loader(&paths, §ions, §ion_id)?; let config = section.loader.clone().ok_or(LoaderError::NoUrl)?; - let fetcher = loader_fetcher(&app, http_state.inner(), auth_state.inner(), §ion); + let fetcher = loader_fetcher( + &app, + http_state.inner(), + auth_state.inner(), + &paths.data, + §ion, + ); let (endpoints, schemas, response_schemas, pages) = loader::run(&config, fetcher).await?; @@ -517,7 +526,13 @@ mod gui { return Err(LoaderError::NoUrl); } - let fetcher = loader_fetcher(&app, http_state.inner(), auth_state.inner(), §ion); + let fetcher = loader_fetcher( + &app, + http_state.inner(), + auth_state.inner(), + &paths.data, + §ion, + ); let method = match config.method.trim() { "" => "GET".to_string(), method => method.to_string(), diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index f96bc15..491fce5 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -33,7 +33,7 @@ use crate::auth::AuthState; use crate::history::HistoryStore; // `redact` lives in `http` because history persistence needs the same list; // two lists would drift, and the one that drifted would be the one that leaks. -use crate::http::{redact_with, BodyKind, FormField, Header, HttpState, RequestSpec}; +use crate::http::{redact_with, BodyKind, FormField, Header, HttpError, HttpState, RequestSpec}; use crate::loader; use crate::secrets; use crate::store::{self, Section}; @@ -48,6 +48,41 @@ const MANIFEST_CACHE_TTL: Duration = Duration::from_secs(30); const MAX_CONCURRENT_REQUESTS: usize = 16; const MAX_CONCURRENT_LOADERS: usize = 2; +/// The message for a send that failed, with the container context the error +/// itself cannot carry. +/// +/// One failure needs this and the rest do not. `AuthError`'s text is written for +/// the desktop app — "not signed in — open Section settings and sign in" — and +/// here there are no Section settings, no window to sign in through, and very +/// often a user who *is* signed in and whose credential simply never reached +/// this process. The 401 path has had source-aware advice since the credentials +/// file landed; this is the same courtesy for the failure that happens before a +/// request is ever sent, which until now was the more confusing of the two. +/// +/// The check is `secrets::has` rather than a match on the error text: it is the +/// same question the send asked, it raises no keychain prompt, and it answers +/// from the injected map first, so under a container it is a lookup in memory. +fn send_failure(section: &Section, err: &HttpError) -> String { + let missing = matches!(err, HttpError::Auth(_)) + && section + .auth + .secret_ref() + .is_some_and(|reference| !secrets::has(reference)); + + let advice = missing + .then(secrets::injected_source) + .and_then(secrets::InjectedSource::missing_credential_advice); + + match advice { + Some(advice) => format!( + "No credential for collection \"{}\" ({}). {advice}", + section.name, + section.auth.secret_ref().unwrap_or_default(), + ), + None => err.to_string(), + } +} + /// One pass over the body: find the byte where character `limit + 1` would /// start and cut there. Counting the characters first and *then* collecting /// walked a possibly-32MB body twice to keep 8KB of it. @@ -426,7 +461,7 @@ impl FiberMcp { None, ) .await - .map_err(|err| err.to_string())?; + .map_err(|err| send_failure(§ion, &err))?; Ok(loader::LoaderResponse { status: response.status, @@ -512,6 +547,13 @@ impl FiberMcp { endpoints: usize, allows_writes: bool, has_loader: bool, + /// `"missing"` when the collection is authenticated and this + /// process cannot see its credential — a call to it would fail + /// before it was sent. Absent otherwise, so the ordinary listing is + /// unchanged. Better here than at the first failed call: it costs + /// the caller nothing to know before spending one. + #[serde(skip_serializing_if = "Option::is_none")] + credential: Option<&'static str>, } let (all, warnings) = self.all_sections()?; @@ -525,12 +567,28 @@ impl FiberMcp { endpoints: self.endpoints_of(section).len(), allows_writes: section.mcp.allow_writes, has_loader: section.loader.is_some(), + // `has`, not `get`: presence is the question, and on the + // desktop the difference between the two is a password prompt. + credential: section + .auth + .secret_ref() + .is_some_and(|reference| !secrets::has(reference)) + .then_some("missing"), }) .collect(); + // The per-collection flag says what is wrong; this says why, once, + // rather than repeating a paragraph on every entry. + let mut warnings = warnings.as_ref().clone(); + if summaries.iter().any(|summary| summary.credential.is_some()) { + if let Some(advice) = secrets::injected_source().missing_credential_advice() { + warnings.push(advice.to_string()); + } + } + ok_json(&serde_json::json!({ "sections": summaries, - "warnings": warnings.as_ref(), + "warnings": warnings, })) } @@ -748,7 +806,8 @@ impl FiberMcp { McpError::internal_error(format!("response persistence task failed: {err}"), None) })?; - let response = outcome.map_err(|err| McpError::internal_error(err.to_string(), None))?; + let response = + outcome.map_err(|err| McpError::internal_error(send_failure(§ion, &err), None))?; let (body, context_truncated) = truncate(&response.body, MAX_BODY_CHARS); let query_available = recorded.is_ok(); let mut hints = Vec::new(); @@ -1277,11 +1336,78 @@ pub fn sync_section_sharing(data: &std::path::Path, section: &Section) { } } +/// What this process can see of the credentials its collections need, logged +/// once at startup. +/// +/// The failure this exists for is silent by construction: a shared collection +/// whose credential never reached this process behaves exactly like one that +/// was never authenticated, right up until the first call — and the call is +/// where anyone finds out. Naming the source and the gaps costs one line in +/// `docker logs` and is usually the whole explanation. +/// +/// A warning rather than a refusal to start, because under a credentials file +/// the missing value legitimately arrives later: the app writes it on the next +/// sign-in and the server picks it up without restarting. Refusing to serve the +/// collections that *are* authenticated until every one of them is would be +/// worse than the problem being guarded against. +fn report_credentials(sections: &[Section]) { + let source = secrets::injected_source(); + match source { + secrets::InjectedSource::None => return, + secrets::InjectedSource::Snapshot => { + log::info!("credentials: FIBER_SECRETS, a snapshot frozen at startup") + } + secrets::InjectedSource::File => { + log::info!("credentials: FIBER_SECRETS_FILE, re-read on every lookup") + } + secrets::InjectedSource::Both => log::info!( + "credentials: FIBER_SECRETS and FIBER_SECRETS_FILE; the snapshot wins where it \ + holds a reference, so a sign-in cannot reach those" + ), + } + + // `has` rather than `get`: the question is presence, and on the desktop the + // difference is a keychain prompt. Nothing injected ever reaches the + // keychain anyway, so under a container this is a map lookup. + let missing: Vec<&Section> = sections + .iter() + .filter(|section| section.mcp.enabled) + .filter(|section| { + section + .auth + .secret_ref() + .is_some_and(|reference| !secrets::has(reference)) + }) + .collect(); + + if missing.is_empty() { + return; + } + + for section in &missing { + log::warn!( + "no credential for shared collection \"{}\" ({}) — calls to it will fail", + section.name, + section.auth.secret_ref().unwrap_or_default(), + ); + } + if let Some(advice) = source.missing_credential_advice() { + log::warn!("{advice}"); + } +} + /// Serves MCP over stdio until the client disconnects. pub async fn serve() -> Result<(), Box> { let data = app_data_dir(); secrets::validate_injected() .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err))?; + // Best effort: a collections directory that cannot be read is the next + // line's problem, and reporting nothing is better than failing to start + // over a report. + match store::load_all(&store::sections_dir(&data)) { + Ok(sections) => report_credentials(§ions), + Err(err) => log::warn!("could not check credentials at startup: {err}"), + } let server = FiberMcp { sections_dir: store::sections_dir(&data), loaders_dir: loader::loaders_dir(&data), diff --git a/src-tauri/src/secrets.rs b/src-tauri/src/secrets.rs index 5121c2a..5020617 100644 --- a/src-tauri/src/secrets.rs +++ b/src-tauri/src/secrets.rs @@ -276,7 +276,74 @@ fn injected(reference: &str) -> Option { /// dropping a cached token on a 401 costs a container one file read and costs /// the app a keychain prompt. pub fn has_injected_source() -> bool { - std::env::var_os("FIBER_SECRETS").is_some() || std::env::var_os("FIBER_SECRETS_FILE").is_some() + injected_source() != InjectedSource::None +} + +/// Where a headless server's credentials come from. +/// +/// Knowing *which* is the difference between an accurate explanation and a +/// misleading one when a credential is missing, so this is deliberately finer +/// than [`has_injected_source`]. `Snapshot` is the case worth naming: a +/// process's environment cannot change under it, so a collection signed into +/// after the workload started is not stale in `FIBER_SECRETS` — it is absent, +/// and nothing the server does will make it appear. The same absence under +/// `File` means the app has never written that reference, which is a different +/// problem with a different fix. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InjectedSource { + /// The desktop app: the keychain, and nothing injected. + None, + /// `FIBER_SECRETS`, frozen when the process started. + Snapshot, + /// `FIBER_SECRETS_FILE`, re-read on every lookup. + File, + /// Both. `injected` resolves the snapshot first, so it behaves as + /// `Snapshot` for anything the snapshot happens to hold. + Both, +} + +pub fn injected_source() -> InjectedSource { + source_of( + std::env::var_os("FIBER_SECRETS").is_some(), + std::env::var_os("FIBER_SECRETS_FILE").is_some(), + ) +} + +/// Split from the environment read so the mapping — and the advice that hangs +/// off it — is testable without setting process-wide variables, which the tests +/// here deliberately never do. +fn source_of(snapshot: bool, file: bool) -> InjectedSource { + match (snapshot, file) { + (true, true) => InjectedSource::Both, + (true, false) => InjectedSource::Snapshot, + (false, true) => InjectedSource::File, + (false, false) => InjectedSource::None, + } +} + +impl InjectedSource { + /// How a missing credential should be explained under this source. + /// + /// `None` returns nothing: the desktop app's own message already fits, and + /// appending container advice to it would be noise in the one place the + /// keychain really is the answer. + pub fn missing_credential_advice(self) -> Option<&'static str> { + match self { + InjectedSource::None => Option::None, + InjectedSource::Snapshot | InjectedSource::Both => Some( + "This server reads credentials from FIBER_SECRETS, a snapshot taken when the \ + workload started, so a collection signed into afterwards is not in it. \ + Re-export and replace the workload, or switch to the credentials file the \ + app keeps current — see deploy/toolhive.md.", + ), + InjectedSource::File => Some( + "This server reads credentials from FIBER_SECRETS_FILE, which the app rewrites \ + whenever one changes. Signing in to this collection in Fiber will put it \ + there; if you already have, check that the app writes to the file this \ + server reads.", + ), + } + } } /// `None` when absent, which is not an error — an unconfigured section is a @@ -350,6 +417,38 @@ mod tests { ); } + #[test] + fn the_credential_source_is_named_from_the_two_variables() { + assert_eq!(source_of(false, false), InjectedSource::None); + assert_eq!(source_of(true, false), InjectedSource::Snapshot); + assert_eq!(source_of(false, true), InjectedSource::File); + assert_eq!(source_of(true, true), InjectedSource::Both); + } + + /// The desktop app gets no container advice appended to its message: there + /// the keychain really is the answer, and "re-export the workload" would be + /// nonsense. + #[test] + fn only_an_injected_source_explains_a_missing_credential() { + assert!(source_of(false, false) + .missing_credential_advice() + .is_none()); + assert!(source_of(true, false).missing_credential_advice().is_some()); + assert!(source_of(false, true).missing_credential_advice().is_some()); + } + + /// Both set resolves snapshot-first in `injected`, so the advice has to be + /// the snapshot's. Telling someone to sign in again — the file's answer — + /// would be wrong precisely when the snapshot is what holds the reference, + /// because the sign-in cannot displace it. + #[test] + fn both_sources_give_the_snapshot_advice() { + assert_eq!( + source_of(true, true).missing_credential_advice(), + source_of(true, false).missing_credential_advice() + ); + } + #[test] fn malformed_injected_secrets_are_rejected() { assert!(parse_injected("not json").is_err()); diff --git a/src-tauri/src/send.rs b/src-tauri/src/send.rs index c837ca2..13f560b 100644 --- a/src-tauri/src/send.rs +++ b/src-tauri/src/send.rs @@ -125,6 +125,11 @@ where // Setting one up explicitly, through Pick credential, still // writes — that is a credential chosen on purpose, and worth // keeping. + // + // None of that applies to the credentials file a containerised + // server reads, which is not the keychain and costs no prompt, + // so the recapturer mirrors the new value into it before + // returning. See `browser::BrowserRecapture`. auth_state.store(§ion.id, value, 0); } // The window is now visible for the user to sign in; the original