Stop the snapshot retry being an unconfirmed revoke, and unbrick legacy configs
Three things, all reachable from a single credential-handling round.
**The "Retry snapshot cleanup" button deleted the token.** `clear_claude_token`
called `secure::delete_claude_oauth_token()` unconditionally; the `mode`
argument only changed the toast wording, so there was no sweep-only path on the
wire. The leftover panel rendered on `leftover !== null` alone and
`onAuthenticated` never cleared it, so the sequence revoke -> snapshot skipped
-> re-authenticate from the button directly above -> press the retry the panel
is still offering threw away the token acquired seconds earlier, announced by a
message about images. The deliberate Revoke needs a confirmation modal; this
needed nothing.
`sweep_claude_token_snapshots` is the honest primitive: it rewrites the images
and never touches the keychain. The images are the durable record, so the retry
re-derives its work from Docker and needs no stored token. Re-authenticating
now clears the panel, and the Authenticate button is disabled while a cleanup
runs — a sweep is a per-image inspect/create/commit/rmi over the Docker socket
and takes minutes.
**Sweep-first left the token live for that whole window.** The keychain delete
sat behind `list_images` plus the per-image loop, at bollard's 120s-per-request
default, while the UI said "Revoking...". A quit or crash in there and nothing
was revoked at all; worse, `has_claude_token` stayed true and
`shared_claude_auth` reads the keychain at container-*create* time, so a
project whose `SecretScrub` guard had already released could be started later
in the same sweep and be handed a fresh copy of the credential in its env.
Keychain-first now, and the comment that claimed "no window in which a scrubbed
image is re-poisoned" — true of images, silent about containers — is corrected.
The reorder's original justification (crash-mid-sweep recoverability) is what
the sweep-only command covers.
A keychain refusal no longer discards a scrub report, because nothing has been
swept yet, and both remedies stay on screen: Revoke, and the image sweep, which
is now offered in every state rather than only when nothing is stored.
**`update_project` validating every folder list bricked existing projects.** It
validated nothing until recently while `WorkspaceSection` saved `{paths}` on
every blur, so `projects.json` can hold a half-filled row, a mount name with a
space, a duplicate, or `/` as a host path. Any such project became entirely
unsavable — every Config toggle, every permission-mode change and
`useTerminal.ts`'s tab rename came back with a message about folders — and
refusing the save did not unmount anything. `validate_project_paths_update`
admits a row carried over verbatim from what is stored and holds a new or
edited row to every rule, which keeps the escalation closed: introducing a bad
value through this command is exactly what a non-carried-over row is. The
`/workspace/../tmp/claude-x` chain is the one exception and runs on every row
regardless, because a stored one is live data loss rather than untidy data.
`ssh_key_path` and `ca_cert_path` had no check at all; a filesystem root there
read-only bind-mounts the whole host at /tmp/.host-ssh. Refused on change, with
the same grandfathering.
Tests are mutation-checked: reverting to sweep-first fails all four new Rust
ordering tests, pointing the retry back at `clear_claude_token` fails five
frontend tests, and validating an update in isolation fails the legacy-data
tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
@@ -1214,9 +1214,11 @@ fn is_project_busy_refusal(reason: &str) -> bool {
|
||||
reason.contains(PROJECT_BUSY_MARKER)
|
||||
}
|
||||
|
||||
/// What [`clear_claude_token`] managed to reach. The keychain entry is gone by
|
||||
/// the time this is returned — the rest is about copies of the token that live
|
||||
/// outside it.
|
||||
/// What a cleanup managed to reach. Every field is about copies of the token
|
||||
/// that live *outside* the keychain — snapshot images — so the same shape
|
||||
/// serves [`clear_claude_token`], where the keychain entry is already gone by
|
||||
/// the time this is returned, and [`sweep_claude_token_snapshots`], where the
|
||||
/// keychain was never touched.
|
||||
///
|
||||
/// Three lists rather than one, because "we rewrote it", "we could not rewrite
|
||||
/// it" and "we did not try" are three different things to tell somebody who
|
||||
@@ -1277,6 +1279,93 @@ fn summarise_scrub(report: crate::docker::container::SnapshotScrubReport) -> Cle
|
||||
outcome
|
||||
}
|
||||
|
||||
/// Which halves of a cleanup to run.
|
||||
///
|
||||
/// The distinction has to exist **on the wire**, not in a toast string. The UI
|
||||
/// offers a "Retry snapshot cleanup" button after an incomplete revocation, and
|
||||
/// while [`clear_claude_token`] was the only command behind it that button was
|
||||
/// a *second revoke* wearing a retry's label: it deleted the keychain entry
|
||||
/// unconditionally, with no confirmation, in a panel that survived the user
|
||||
/// re-authenticating from the button directly above it. Pressing it then threw
|
||||
/// away the token they had just acquired and said only that some images had
|
||||
/// been checked.
|
||||
///
|
||||
/// [`Cleanup::ImagesOnly`] is the honest primitive the retry actually wanted:
|
||||
/// the images are the durable record of what is left to do, so re-deriving the
|
||||
/// work from Docker needs no keychain entry and must not consume one.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Cleanup {
|
||||
/// Delete the keychain entry, then rewrite the images. What "Revoke" does,
|
||||
/// behind its confirmation modal.
|
||||
KeychainThenImages,
|
||||
/// Rewrite the images and leave the keychain entirely alone. What "Retry
|
||||
/// snapshot cleanup" and "Check snapshot images" do.
|
||||
ImagesOnly,
|
||||
}
|
||||
|
||||
/// The body of both cleanup commands, with its two halves injected so the
|
||||
/// *order* — and the fact that [`Cleanup::ImagesOnly`] never reaches the
|
||||
/// keychain at all — can be tested without a keychain or a Docker daemon.
|
||||
async fn run_cleanup<K, S, F>(
|
||||
what: Cleanup,
|
||||
delete_keychain: K,
|
||||
sweep: S,
|
||||
) -> Result<ClearTokenOutcome, String>
|
||||
where
|
||||
K: FnOnce() -> Result<(), String>,
|
||||
S: FnOnce() -> F,
|
||||
F: std::future::Future<Output = crate::docker::container::SnapshotScrubReport>,
|
||||
{
|
||||
let revoking = what == Cleanup::KeychainThenImages;
|
||||
|
||||
if revoking {
|
||||
// First, and before anything slow — see "Why the keychain goes first".
|
||||
// Nothing has been touched if this fails, so the error is the whole
|
||||
// answer: the caller still has its Revoke button, and the standalone
|
||||
// sweep is available for the images regardless.
|
||||
if let Err(e) = delete_keychain() {
|
||||
log::error!(
|
||||
"Could not delete the shared Claude token from the keychain; no snapshot image \
|
||||
was touched: {}",
|
||||
e
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
log::info!("Cleared the shared Claude authentication token from the keychain");
|
||||
}
|
||||
|
||||
let report = sweep().await;
|
||||
let swept_clean = !report.left_something_behind();
|
||||
let outcome = summarise_scrub(report);
|
||||
|
||||
let lead = if revoking {
|
||||
"Revoked the shared Claude token but"
|
||||
} else {
|
||||
"Swept the snapshot images but"
|
||||
};
|
||||
for image in &outcome.snapshots_failed {
|
||||
log::warn!("{} could not clear it from {}", lead, image);
|
||||
}
|
||||
for image in &outcome.snapshots_skipped {
|
||||
log::warn!(
|
||||
"{} left it in {} — the project was busy; running the snapshot sweep again will retry",
|
||||
lead,
|
||||
image
|
||||
);
|
||||
}
|
||||
if let Some(ref reason) = outcome.docker_unavailable {
|
||||
log::warn!("{} checked no snapshot image at all: {}", lead, reason);
|
||||
}
|
||||
if swept_clean && !outcome.needs_another_pass() {
|
||||
log::info!(
|
||||
"No snapshot image is still holding the shared Claude token ({} rewritten)",
|
||||
outcome.snapshots_scrubbed.len()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Forget the shared Claude token, and remove the copies of it that outlive the
|
||||
/// keychain entry.
|
||||
///
|
||||
@@ -1296,81 +1385,79 @@ fn summarise_scrub(report: crate::docker::container::SnapshotScrubReport) -> Cle
|
||||
/// committed by earlier builds have to be rewritten, which is what
|
||||
/// [`crate::docker::container::scrub_secrets_from_snapshots`] does here.
|
||||
///
|
||||
/// ## Why the snapshots go first
|
||||
/// ## Why the keychain goes first
|
||||
///
|
||||
/// They used to go second, and that ordering had no recovery path. The scrub
|
||||
/// runs **once** and skips any project another operation holds; the keychain
|
||||
/// entry, already deleted, made `has_claude_token` false; and the frontend only
|
||||
/// rendered Revoke while a token was stored. So a project that happened to be
|
||||
/// starting during a revoke kept a live ~1-year OAuth token in its snapshot's
|
||||
/// `Config.Env` permanently, and the only remedy the UI still offered was Reset,
|
||||
/// which destroys both volumes.
|
||||
/// The sweep is not quick. It lists every `triple-c-snapshot-*` image and then
|
||||
/// inspects, creates, commits and removes *per image*, over bollard's Docker
|
||||
/// socket with its 120-second-per-request default. Deferring the keychain
|
||||
/// delete behind all of that leaves the credential live for the whole window
|
||||
/// while the UI says "Revoking…", and two separate things go wrong in it:
|
||||
///
|
||||
/// Scrubbing first closes the crash window in that story: the app can be killed
|
||||
/// at any point during the sweep and the *next* launch still says
|
||||
/// "authenticated", so the same button is still there and still does the same
|
||||
/// thing. Nothing is lost by the reorder — `rewrite_image_without_secrets`
|
||||
/// never reads the keychain, and `commit_container_snapshot` no longer bakes
|
||||
/// the token in, so there is no window in which a scrubbed image is re-poisoned
|
||||
/// by the entry we have not deleted yet.
|
||||
/// * A quit, a crash or a kill mid-sweep and the entry was never deleted at
|
||||
/// all. The token the user believes they revoked is still in the keychain,
|
||||
/// still ~1-year valid, and still injected into every container start.
|
||||
/// * [`has_claude_token`] stays true throughout, and
|
||||
/// [`crate::docker::container::create_container`] reads the keychain at
|
||||
/// container-**create** time rather than at app start. The per-project
|
||||
/// [`crate::project_lock::ProjectOp::SecretScrub`] guard is released as soon
|
||||
/// as that one project's image has been rewritten — so a project scrubbed
|
||||
/// early in the sweep can be started again later in the *same* sweep and be
|
||||
/// handed a fresh copy of the credential in its env. The images end up clean
|
||||
/// and the running fleet does not.
|
||||
///
|
||||
/// ## This command is the retry
|
||||
/// An earlier version ran the sweep first, on the argument that a crash
|
||||
/// mid-sweep would otherwise leave the token in an image with the keychain
|
||||
/// entry — and therefore the Revoke button — already gone. That argument was
|
||||
/// about *recoverability*, and [`sweep_claude_token_snapshots`] answers it
|
||||
/// directly: the images are the durable record, so the retry needs no keychain
|
||||
/// entry to exist and no persisted to-do list. The comment that ordering
|
||||
/// carried ("no window in which a scrubbed image is re-poisoned") was true of
|
||||
/// images and silent about containers, which is where the leak was, and silent
|
||||
/// about the minutes the token stayed live.
|
||||
///
|
||||
/// It is idempotent and safe to call with no token stored: `delete_entry`
|
||||
/// treats a missing entry as success, and the sweep re-derives what is left
|
||||
/// from Docker rather than from any record we would have to keep. So "try the
|
||||
/// snapshot cleanup again" needs no second command and no persisted to-do
|
||||
/// list — the images themselves are the durable record, and calling this again
|
||||
/// is how the user acts on [`ClearTokenOutcome::needs_another_pass`].
|
||||
///
|
||||
/// The keychain deletion is never rolled back if the scrub fails; a partially
|
||||
/// completed revocation is still better than none, and the outcome is reported
|
||||
/// so the UI can be explicit about what is left.
|
||||
/// The keychain deletion is never rolled back if the scrub then fails; a
|
||||
/// partially completed revocation is still better than none, and the outcome is
|
||||
/// reported so the UI can be explicit about what is left.
|
||||
#[tauri::command]
|
||||
pub async fn clear_claude_token() -> Result<ClearTokenOutcome, String> {
|
||||
let report = crate::docker::container::scrub_secrets_from_snapshots().await;
|
||||
let swept_clean = !report.left_something_behind();
|
||||
let outcome = summarise_scrub(report);
|
||||
run_cleanup(
|
||||
Cleanup::KeychainThenImages,
|
||||
secure::delete_claude_oauth_token,
|
||||
crate::docker::container::scrub_secrets_from_snapshots,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// The keychain second — see "Why the snapshots go first". An error here is
|
||||
// the loud case (the token may still be usable), so it wins over the
|
||||
// report, which is logged rather than dropped on the floor.
|
||||
if let Err(e) = secure::delete_claude_oauth_token() {
|
||||
log::error!(
|
||||
"Could not delete the shared Claude token from the keychain; the snapshot sweep had \
|
||||
already scrubbed {} image(s), failed on {}, skipped {}",
|
||||
outcome.snapshots_scrubbed.len(),
|
||||
outcome.snapshots_failed.len(),
|
||||
outcome.snapshots_skipped.len()
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
log::info!("Cleared the shared Claude authentication token");
|
||||
|
||||
for image in &outcome.snapshots_failed {
|
||||
log::warn!("Revoked the shared Claude token but could not clear it from {}", image);
|
||||
}
|
||||
for image in &outcome.snapshots_skipped {
|
||||
log::warn!(
|
||||
"Revoked the shared Claude token but left it in {} — the project was busy; \
|
||||
revoking again will retry",
|
||||
image
|
||||
);
|
||||
}
|
||||
if let Some(ref reason) = outcome.docker_unavailable {
|
||||
log::warn!(
|
||||
"Revoked the shared Claude token without checking any snapshot image: {}",
|
||||
reason
|
||||
);
|
||||
}
|
||||
if swept_clean && !outcome.needs_another_pass() {
|
||||
log::info!(
|
||||
"No snapshot image is still holding the shared Claude token ({} rewritten)",
|
||||
outcome.snapshots_scrubbed.len()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(outcome)
|
||||
/// Rewrite every snapshot image that still carries a credential, **without
|
||||
/// touching the keychain**.
|
||||
///
|
||||
/// This is the retry, and it is its own command because the retry is its own
|
||||
/// act. `docker commit` copied the token into each project's snapshot image;
|
||||
/// rewriting those images is a cleanup that has nothing to do with whether a
|
||||
/// token is stored today, and folding it into [`clear_claude_token`] made every
|
||||
/// press of "Retry snapshot cleanup" an unconfirmed credential deletion.
|
||||
///
|
||||
/// Safe to call at any time and in any state:
|
||||
///
|
||||
/// * with a token stored — a snapshot committed by an older build carries the
|
||||
/// *current* token, and clearing it out of the image does not stop the
|
||||
/// keychain entry being injected on the next container start;
|
||||
/// * with nothing stored — images committed by earlier builds still carry
|
||||
/// whatever token was live when they were committed, which is exactly the
|
||||
/// case the old sweep-first ordering could strand;
|
||||
/// * repeatedly — the work is re-derived from Docker each time, so an image
|
||||
/// whose project was busy on the last pass is simply picked up on this one.
|
||||
#[tauri::command]
|
||||
pub async fn sweep_claude_token_snapshots() -> Result<ClearTokenOutcome, String> {
|
||||
run_cleanup(
|
||||
Cleanup::ImagesOnly,
|
||||
// Never called; the `ImagesOnly` branch is the entire point of this
|
||||
// command, and a change that made it reachable must fail loudly rather
|
||||
// than delete a credential quietly.
|
||||
|| -> Result<(), String> { unreachable!("an images-only sweep must never touch the keychain") },
|
||||
crate::docker::container::scrub_secrets_from_snapshots,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -2012,4 +2099,134 @@ mod tests {
|
||||
assert!(object.contains_key(key), "missing {} in {:?}", key, object);
|
||||
}
|
||||
}
|
||||
|
||||
// ── The order of a revocation, and what a retry may touch ─────────────
|
||||
//
|
||||
// `run_cleanup` takes both halves as arguments precisely so this can be
|
||||
// asserted with no keychain and no Docker daemon: the recorded order *is*
|
||||
// the subject. Sweep-first put a live ~1-year credential behind a
|
||||
// per-image inspect/create/commit/rmi loop — minutes, at bollard's
|
||||
// 120s-per-request default — during which `has_claude_token` stayed true
|
||||
// and `create_container` kept handing the token to anything started.
|
||||
|
||||
/// Records which half ran, in order.
|
||||
type Trace = std::sync::Arc<std::sync::Mutex<Vec<&'static str>>>;
|
||||
|
||||
fn trace() -> Trace {
|
||||
std::sync::Arc::new(std::sync::Mutex::new(Vec::new()))
|
||||
}
|
||||
|
||||
fn scrubbed_one() -> SnapshotScrubReport {
|
||||
SnapshotScrubReport {
|
||||
scrubbed: vec!["triple-c-snapshot-a:latest".into()],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_keychain_entry_is_gone_before_the_first_image_is_touched() {
|
||||
let t = trace();
|
||||
let (tk, ts) = (t.clone(), t.clone());
|
||||
|
||||
let outcome = run_cleanup(
|
||||
Cleanup::KeychainThenImages,
|
||||
move || {
|
||||
tk.lock().unwrap().push("keychain");
|
||||
Ok(())
|
||||
},
|
||||
move || async move {
|
||||
ts.lock().unwrap().push("sweep");
|
||||
scrubbed_one()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("a cleanup whose halves both succeed is not an error");
|
||||
|
||||
assert_eq!(
|
||||
*t.lock().unwrap(),
|
||||
["keychain", "sweep"],
|
||||
"the token stayed in the keychain — and therefore in every container created — \
|
||||
for the whole length of the image sweep"
|
||||
);
|
||||
assert_eq!(outcome.snapshots_scrubbed, vec!["triple-c-snapshot-a:latest"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_keychain_failure_leaves_the_images_untouched_and_is_reported() {
|
||||
let t = trace();
|
||||
let ts = t.clone();
|
||||
|
||||
let err = run_cleanup(
|
||||
Cleanup::KeychainThenImages,
|
||||
|| Err("the keychain is locked".to_string()),
|
||||
move || async move {
|
||||
ts.lock().unwrap().push("sweep");
|
||||
SnapshotScrubReport::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("a keychain that refused the delete must be reported, not swallowed");
|
||||
|
||||
assert_eq!(err, "the keychain is locked");
|
||||
assert!(
|
||||
t.lock().unwrap().is_empty(),
|
||||
"images were rewritten for a revocation that never happened; the report is then \
|
||||
discarded with the error and the user is told nothing they can act on"
|
||||
);
|
||||
}
|
||||
|
||||
/// The bug the images-only primitive exists to close: the "Retry snapshot
|
||||
/// cleanup" button used to run `clear_claude_token`, so pressing it after
|
||||
/// re-authenticating deleted the brand-new token with no confirmation.
|
||||
#[tokio::test]
|
||||
async fn an_images_only_cleanup_never_reaches_the_keychain() {
|
||||
let t = trace();
|
||||
let (tk, ts) = (t.clone(), t.clone());
|
||||
|
||||
let outcome = run_cleanup(
|
||||
Cleanup::ImagesOnly,
|
||||
move || {
|
||||
tk.lock().unwrap().push("keychain");
|
||||
Ok(())
|
||||
},
|
||||
move || async move {
|
||||
ts.lock().unwrap().push("sweep");
|
||||
scrubbed_one()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("a sweep-only cleanup is not an error");
|
||||
|
||||
assert_eq!(
|
||||
*t.lock().unwrap(),
|
||||
["sweep"],
|
||||
"the retry deleted a credential nobody confirmed deleting"
|
||||
);
|
||||
assert_eq!(outcome.snapshots_scrubbed, vec!["triple-c-snapshot-a:latest"]);
|
||||
}
|
||||
|
||||
/// …and it still has to report what it could not finish, because "run it
|
||||
/// again once that project is idle" is the whole affordance.
|
||||
#[tokio::test]
|
||||
async fn an_images_only_cleanup_still_reports_what_it_could_not_finish() {
|
||||
let outcome = run_cleanup(
|
||||
Cleanup::ImagesOnly,
|
||||
|| -> Result<(), String> { unreachable!("images only") },
|
||||
|| async {
|
||||
SnapshotScrubReport {
|
||||
failed: vec![(
|
||||
"triple-c-snapshot-b:latest".into(),
|
||||
format!("This project is being started. {}removing a credential from its snapshot.", PROJECT_BUSY_MARKER),
|
||||
)],
|
||||
..Default::default()
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("an image left for the next pass is not a command failure");
|
||||
|
||||
assert!(outcome.needs_another_pass());
|
||||
assert_eq!(outcome.snapshots_skipped.len(), 1, "{:?}", outcome);
|
||||
assert!(outcome.snapshots_failed.is_empty(), "{:?}", outcome);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user