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)
|
reason.contains(PROJECT_BUSY_MARKER)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What [`clear_claude_token`] managed to reach. The keychain entry is gone by
|
/// What a cleanup managed to reach. Every field is about copies of the token
|
||||||
/// the time this is returned — the rest is about copies of the token that live
|
/// that live *outside* the keychain — snapshot images — so the same shape
|
||||||
/// outside it.
|
/// 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
|
/// 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
|
/// 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
|
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
|
/// Forget the shared Claude token, and remove the copies of it that outlive the
|
||||||
/// keychain entry.
|
/// 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
|
/// committed by earlier builds have to be rewritten, which is what
|
||||||
/// [`crate::docker::container::scrub_secrets_from_snapshots`] does here.
|
/// [`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
|
/// The sweep is not quick. It lists every `triple-c-snapshot-*` image and then
|
||||||
/// runs **once** and skips any project another operation holds; the keychain
|
/// inspects, creates, commits and removes *per image*, over bollard's Docker
|
||||||
/// entry, already deleted, made `has_claude_token` false; and the frontend only
|
/// socket with its 120-second-per-request default. Deferring the keychain
|
||||||
/// rendered Revoke while a token was stored. So a project that happened to be
|
/// delete behind all of that leaves the credential live for the whole window
|
||||||
/// starting during a revoke kept a live ~1-year OAuth token in its snapshot's
|
/// while the UI says "Revoking…", and two separate things go wrong in it:
|
||||||
/// `Config.Env` permanently, and the only remedy the UI still offered was Reset,
|
|
||||||
/// which destroys both volumes.
|
|
||||||
///
|
///
|
||||||
/// Scrubbing first closes the crash window in that story: the app can be killed
|
/// * A quit, a crash or a kill mid-sweep and the entry was never deleted at
|
||||||
/// at any point during the sweep and the *next* launch still says
|
/// all. The token the user believes they revoked is still in the keychain,
|
||||||
/// "authenticated", so the same button is still there and still does the same
|
/// still ~1-year valid, and still injected into every container start.
|
||||||
/// thing. Nothing is lost by the reorder — `rewrite_image_without_secrets`
|
/// * [`has_claude_token`] stays true throughout, and
|
||||||
/// never reads the keychain, and `commit_container_snapshot` no longer bakes
|
/// [`crate::docker::container::create_container`] reads the keychain at
|
||||||
/// the token in, so there is no window in which a scrubbed image is re-poisoned
|
/// container-**create** time rather than at app start. The per-project
|
||||||
/// by the entry we have not deleted yet.
|
/// [`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`
|
/// The keychain deletion is never rolled back if the scrub then fails; a
|
||||||
/// treats a missing entry as success, and the sweep re-derives what is left
|
/// partially completed revocation is still better than none, and the outcome is
|
||||||
/// from Docker rather than from any record we would have to keep. So "try the
|
/// reported so the UI can be explicit about what is left.
|
||||||
/// 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.
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn clear_claude_token() -> Result<ClearTokenOutcome, String> {
|
pub async fn clear_claude_token() -> Result<ClearTokenOutcome, String> {
|
||||||
let report = crate::docker::container::scrub_secrets_from_snapshots().await;
|
run_cleanup(
|
||||||
let swept_clean = !report.left_something_behind();
|
Cleanup::KeychainThenImages,
|
||||||
let outcome = summarise_scrub(report);
|
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
|
/// Rewrite every snapshot image that still carries a credential, **without
|
||||||
// the loud case (the token may still be usable), so it wins over the
|
/// touching the keychain**.
|
||||||
// report, which is logged rather than dropped on the floor.
|
///
|
||||||
if let Err(e) = secure::delete_claude_oauth_token() {
|
/// This is the retry, and it is its own command because the retry is its own
|
||||||
log::error!(
|
/// act. `docker commit` copied the token into each project's snapshot image;
|
||||||
"Could not delete the shared Claude token from the keychain; the snapshot sweep had \
|
/// rewriting those images is a cleanup that has nothing to do with whether a
|
||||||
already scrubbed {} image(s), failed on {}, skipped {}",
|
/// token is stored today, and folding it into [`clear_claude_token`] made every
|
||||||
outcome.snapshots_scrubbed.len(),
|
/// press of "Retry snapshot cleanup" an unconfirmed credential deletion.
|
||||||
outcome.snapshots_failed.len(),
|
///
|
||||||
outcome.snapshots_skipped.len()
|
/// Safe to call at any time and in any state:
|
||||||
);
|
///
|
||||||
return Err(e);
|
/// * 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
|
||||||
log::info!("Cleared the shared Claude authentication token");
|
/// keychain entry being injected on the next container start;
|
||||||
|
/// * with nothing stored — images committed by earlier builds still carry
|
||||||
for image in &outcome.snapshots_failed {
|
/// whatever token was live when they were committed, which is exactly the
|
||||||
log::warn!("Revoked the shared Claude token but could not clear it from {}", image);
|
/// case the old sweep-first ordering could strand;
|
||||||
}
|
/// * repeatedly — the work is re-derived from Docker each time, so an image
|
||||||
for image in &outcome.snapshots_skipped {
|
/// whose project was busy on the last pass is simply picked up on this one.
|
||||||
log::warn!(
|
#[tauri::command]
|
||||||
"Revoked the shared Claude token but left it in {} — the project was busy; \
|
pub async fn sweep_claude_token_snapshots() -> Result<ClearTokenOutcome, String> {
|
||||||
revoking again will retry",
|
run_cleanup(
|
||||||
image
|
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
|
||||||
if let Some(ref reason) = outcome.docker_unavailable {
|
// than delete a credential quietly.
|
||||||
log::warn!(
|
|| -> Result<(), String> { unreachable!("an images-only sweep must never touch the keychain") },
|
||||||
"Revoked the shared Claude token without checking any snapshot image: {}",
|
crate::docker::container::scrub_secrets_from_snapshots,
|
||||||
reason
|
)
|
||||||
);
|
.await
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -2012,4 +2099,134 @@ mod tests {
|
|||||||
assert!(object.contains_key(key), "missing {} in {:?}", key, object);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,18 +187,20 @@ pub(crate) fn load_secrets_for_project(project: &mut Project) {
|
|||||||
/// destination of `/tmp/claude-x`, i.e. the host directory is mounted straight
|
/// destination of `/tmp/claude-x`, i.e. the host directory is mounted straight
|
||||||
/// on top of one of the paths the pre-commit scrub owns. The next recreate then
|
/// on top of one of the paths the pre-commit scrub owns. The next recreate then
|
||||||
/// runs the scrub, as root, over the user's own project directory. That is the
|
/// runs the scrub, as root, over the user's own project directory. That is the
|
||||||
/// C1 data-loss chain end to end, and the character check is the half of it
|
/// C1 data-loss chain end to end, and
|
||||||
/// that stops the path ever being spelled.
|
/// [`check_mount_name_stays_under_workspace`] is the half of it that stops the
|
||||||
///
|
/// path ever being spelled.
|
||||||
/// `..` on its own passes a check for "alphanumeric, dash, underscore or dot",
|
|
||||||
/// which is why it is called out separately: it is the only single component
|
|
||||||
/// that walks *up*, and `/workspace/..` is `/`.
|
|
||||||
///
|
///
|
||||||
/// `host_path` is the other side of the same mount. `/` there bind-mounts the
|
/// `host_path` is the other side of the same mount. `/` there bind-mounts the
|
||||||
/// entire host filesystem read-write into a container whose agent has
|
/// entire host filesystem read-write into a container whose agent has
|
||||||
/// passwordless sudo. Anything short of a filesystem root is the user choosing
|
/// passwordless sudo. Anything short of a filesystem root is the user choosing
|
||||||
/// a folder — the Browse button and the free-text field lead to the same place
|
/// a folder — the Browse button and the free-text field lead to the same place
|
||||||
/// — so only the roots themselves are refused.
|
/// — so only the roots themselves are refused.
|
||||||
|
///
|
||||||
|
/// This is the **whole** rule set, and it belongs to `add_project`, where every
|
||||||
|
/// row is new by definition. `update_project` runs
|
||||||
|
/// [`validate_project_paths_update`] instead: see there for why a list that is
|
||||||
|
/// already in `projects.json` cannot be held to all of it.
|
||||||
fn validate_project_paths(paths: &[ProjectPath]) -> Result<(), String> {
|
fn validate_project_paths(paths: &[ProjectPath]) -> Result<(), String> {
|
||||||
let mut seen_names = std::collections::HashSet::new();
|
let mut seen_names = std::collections::HashSet::new();
|
||||||
for p in paths {
|
for p in paths {
|
||||||
@@ -209,18 +211,23 @@ fn validate_project_paths(paths: &[ProjectPath]) -> Result<(), String> {
|
|||||||
if p.host_path.is_empty() && p.mount_name.is_empty() {
|
if p.host_path.is_empty() && p.mount_name.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
validate_one_path(p)?;
|
||||||
|
if !seen_names.insert(p.mount_name.clone()) {
|
||||||
|
return Err(format!("Duplicate mount name '{}'.", p.mount_name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every rule that applies to a single folder row, duplicates aside.
|
||||||
|
fn validate_one_path(p: &ProjectPath) -> Result<(), String> {
|
||||||
if p.mount_name.is_empty() {
|
if p.mount_name.is_empty() {
|
||||||
return Err("Mount name cannot be empty.".to_string());
|
return Err("Mount name cannot be empty.".to_string());
|
||||||
}
|
}
|
||||||
if !p.mount_name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.') {
|
if !p.mount_name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.') {
|
||||||
return Err(format!("Mount name '{}' contains invalid characters. Use alphanumeric, dash, underscore, or dot.", p.mount_name));
|
return Err(format!("Mount name '{}' contains invalid characters. Use alphanumeric, dash, underscore, or dot.", p.mount_name));
|
||||||
}
|
}
|
||||||
if p.mount_name.chars().all(|c| c == '.') {
|
check_mount_name_stays_under_workspace(&p.mount_name)?;
|
||||||
return Err(format!(
|
|
||||||
"Mount name '{}' is not a folder name — it names the directory the mount would sit in.",
|
|
||||||
p.mount_name
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if p.host_path.is_empty() {
|
if p.host_path.is_empty() {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Folder mounted at '/workspace/{}' has no host path.",
|
"Folder mounted at '/workspace/{}' has no host path.",
|
||||||
@@ -233,9 +240,151 @@ fn validate_project_paths(paths: &[ProjectPath]) -> Result<(), String> {
|
|||||||
p.host_path
|
p.host_path
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if !seen_names.insert(p.mount_name.clone()) {
|
Ok(())
|
||||||
return Err(format!("Duplicate mount name '{}'.", p.mount_name));
|
}
|
||||||
|
|
||||||
|
/// The one rule that survives every exemption: the mount has to land under
|
||||||
|
/// `/workspace`.
|
||||||
|
///
|
||||||
|
/// A name carrying a path separator, or one that is nothing but dots, does not
|
||||||
|
/// name a folder inside `/workspace` — it moves the mount. `/workspace/..` is
|
||||||
|
/// `/`, `/workspace/../tmp/claude-x` normalises to `/tmp/claude-x`, and
|
||||||
|
/// `/workspace/.` is `/workspace` itself, shadowing every other mount. The
|
||||||
|
/// first of those is the C1 chain: a host directory mounted over a path the
|
||||||
|
/// pre-commit scrub empties as root.
|
||||||
|
///
|
||||||
|
/// Everything else `validate_one_path` checks is hygiene — a space or an `@` in
|
||||||
|
/// a mount name is untidy, not an escape — which is why only this one is
|
||||||
|
/// applied to rows [`validate_project_paths_update`] otherwise grandfathers.
|
||||||
|
fn check_mount_name_stays_under_workspace(mount_name: &str) -> Result<(), String> {
|
||||||
|
if mount_name.contains('/') || mount_name.contains('\\') {
|
||||||
|
return Err(format!(
|
||||||
|
"Mount name '{}' contains a path separator, so the folder would be mounted somewhere \
|
||||||
|
/workspace/{{name}} does not reach. Use a plain folder name.",
|
||||||
|
mount_name
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
if !mount_name.is_empty() && mount_name.chars().all(|c| c == '.') {
|
||||||
|
return Err(format!(
|
||||||
|
"Mount name '{}' is not a folder name — it names the directory the mount would sit in.",
|
||||||
|
mount_name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate the folder list of a project that **already exists**, admitting the
|
||||||
|
/// rows it is already stored with.
|
||||||
|
///
|
||||||
|
/// ## Why this is not just [`validate_project_paths`]
|
||||||
|
///
|
||||||
|
/// `update_project` validated nothing at all until recently, while
|
||||||
|
/// `WorkspaceSection` saved `{paths}` on every blur — so `projects.json` files
|
||||||
|
/// in the field hold rows that the full rule set refuses: a half-filled row, a
|
||||||
|
/// mount name with a space in it, two rows sharing a name, `/` as a host path.
|
||||||
|
///
|
||||||
|
/// Running the full check on every save made every such project **entirely
|
||||||
|
/// unsavable**. Not just its folder list: `update_project` is the one command
|
||||||
|
/// behind the Config tab, so every toggle, every permission-mode change and
|
||||||
|
/// `useTerminal.ts`'s tab rename came back with a message about folders. The
|
||||||
|
/// remedy exists — fix the row in the Workspace section — but nothing about
|
||||||
|
/// "cannot save" on a sandbox switch points at it.
|
||||||
|
///
|
||||||
|
/// And blocking the save bought nothing for the rows it was blocking. They are
|
||||||
|
/// *already stored*, and already mounted on every container start; refusing to
|
||||||
|
/// persist an unrelated field does not unmount them.
|
||||||
|
///
|
||||||
|
/// So: a row carried over verbatim from what is stored is admitted, and a row
|
||||||
|
/// that is new or edited is held to every rule. That is enough to keep the
|
||||||
|
/// escalation closed, because escalation means *introducing* a bad value
|
||||||
|
/// through this command, and an introduced row is never a carried-over one.
|
||||||
|
///
|
||||||
|
/// The single exception is [`check_mount_name_stays_under_workspace`], which
|
||||||
|
/// runs on every row either way. A stored `..` is a live data-loss chain rather
|
||||||
|
/// than untidy data, its remedy is one edit in the Workspace section, and the
|
||||||
|
/// message names the mount rather than talking about folders in the abstract.
|
||||||
|
fn validate_project_paths_update(
|
||||||
|
stored: &[ProjectPath],
|
||||||
|
incoming: &[ProjectPath],
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let is_blank = |p: &ProjectPath| p.host_path.is_empty() && p.mount_name.is_empty();
|
||||||
|
|
||||||
|
// Rows carried over, counted rather than set-tested: a *second* copy of an
|
||||||
|
// existing row is a new row, and has to be checked like one.
|
||||||
|
let mut carried: std::collections::HashMap<(&str, &str), usize> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
for p in stored.iter().filter(|p| !is_blank(p)) {
|
||||||
|
*carried
|
||||||
|
.entry((p.host_path.as_str(), p.mount_name.as_str()))
|
||||||
|
.or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for p in incoming.iter().filter(|p| !is_blank(p)) {
|
||||||
|
check_mount_name_stays_under_workspace(&p.mount_name)?;
|
||||||
|
|
||||||
|
match carried.get_mut(&(p.host_path.as_str(), p.mount_name.as_str())) {
|
||||||
|
Some(remaining) if *remaining > 0 => {
|
||||||
|
*remaining -= 1;
|
||||||
|
log::debug!(
|
||||||
|
"Admitting a stored folder row unchanged: '{}' at /workspace/{}",
|
||||||
|
p.host_path,
|
||||||
|
p.mount_name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => validate_one_path(p)?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicates get the same treatment, one level up: a name may repeat as
|
||||||
|
// many times as it already did, and no more. Counting both sides keeps the
|
||||||
|
// answer independent of the order the rows arrive in.
|
||||||
|
let count_names = |rows: &[ProjectPath]| {
|
||||||
|
let mut counts: std::collections::HashMap<String, usize> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
for p in rows.iter().filter(|p| !is_blank(p)) {
|
||||||
|
*counts.entry(p.mount_name.clone()).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
counts
|
||||||
|
};
|
||||||
|
let stored_names = count_names(stored);
|
||||||
|
for (name, count) in count_names(incoming) {
|
||||||
|
let allowed = stored_names.get(&name).copied().unwrap_or(0).max(1);
|
||||||
|
if count > allowed {
|
||||||
|
return Err(format!("Duplicate mount name '{}'.", name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refuse a filesystem root newly set as `ssh_key_path` or `ca_cert_path`.
|
||||||
|
///
|
||||||
|
/// Both are bind-mounted into the container by `docker::create_container` —
|
||||||
|
/// `/tmp/.host-ssh` and `/tmp/.host-ca` — and neither had any check at all, so
|
||||||
|
/// `/` handed the whole host filesystem to the agent to read. Read-only, so
|
||||||
|
/// this is disclosure rather than the read-write hole a `/` project folder is,
|
||||||
|
/// but the fix is the same one line.
|
||||||
|
///
|
||||||
|
/// Same grandfathering as the folder list, for the same reason: a value already
|
||||||
|
/// stored is already mounted on every start, and refusing an unrelated save
|
||||||
|
/// does not unmount it. Only a *change* is held to the rule.
|
||||||
|
fn validate_mounted_host_path(
|
||||||
|
label: &str,
|
||||||
|
stored: Option<&str>,
|
||||||
|
incoming: Option<&str>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let Some(value) = incoming.map(str::trim).filter(|v| !v.is_empty()) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if stored.map(str::trim) == Some(value) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if is_filesystem_root(value) {
|
||||||
|
return Err(format!(
|
||||||
|
"'{}' is a filesystem root, so setting it as {} would mount the whole drive into the \
|
||||||
|
container. Choose the folder itself.",
|
||||||
|
value, label
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -358,14 +507,6 @@ pub async fn update_project(
|
|||||||
let mut project: Project = serde_json::from_value(project)
|
let mut project: Project = serde_json::from_value(project)
|
||||||
.map_err(|e| format!("Could not read the project being saved: {}", e))?;
|
.map_err(|e| format!("Could not read the project being saved: {}", e))?;
|
||||||
|
|
||||||
// **This takes a whole `Project` over IPC and used to store it verbatim.**
|
|
||||||
// `add_project` validated its folder list and this did not, so every check
|
|
||||||
// there was one edit away from being bypassed — and the Config tab's mount
|
|
||||||
// name is a free-text field on an existing project, saved on blur, calling
|
|
||||||
// exactly this command. See [`validate_project_paths`] for what a mount
|
|
||||||
// name of `../tmp/claude-x` does to the user's files.
|
|
||||||
validate_project_paths(&project.paths)?;
|
|
||||||
|
|
||||||
// Fields this command does not get to write, whoever is calling it.
|
// Fields this command does not get to write, whoever is calling it.
|
||||||
//
|
//
|
||||||
// `container_id` is the one that matters: it is the handle the whole file
|
// `container_id` is the one that matters: it is the handle the whole file
|
||||||
@@ -386,6 +527,30 @@ pub async fn update_project(
|
|||||||
.projects_store
|
.projects_store
|
||||||
.get(&project.id)
|
.get(&project.id)
|
||||||
.ok_or_else(|| format!("Project {} not found", project.id))?;
|
.ok_or_else(|| format!("Project {} not found", project.id))?;
|
||||||
|
|
||||||
|
// **This takes a whole `Project` over IPC and used to store it verbatim.**
|
||||||
|
// `add_project` validated its folder list and this did not, so every check
|
||||||
|
// there was one edit away from being bypassed — and the Config tab's mount
|
||||||
|
// name is a free-text field on an existing project, saved on blur, calling
|
||||||
|
// exactly this command. See [`validate_project_paths`] for what a mount
|
||||||
|
// name of `../tmp/claude-x` does to the user's files.
|
||||||
|
//
|
||||||
|
// Validated against what is *stored*, not in isolation: a rule this command
|
||||||
|
// never enforced can be violated by data already on disk, and a project
|
||||||
|
// that cannot be saved at all is a Config tab that cannot be used at all.
|
||||||
|
// See [`validate_project_paths_update`].
|
||||||
|
validate_project_paths_update(&stored.paths, &project.paths)?;
|
||||||
|
validate_mounted_host_path(
|
||||||
|
"the SSH key folder",
|
||||||
|
stored.ssh_key_path.as_deref(),
|
||||||
|
project.ssh_key_path.as_deref(),
|
||||||
|
)?;
|
||||||
|
validate_mounted_host_path(
|
||||||
|
"the CA certificate path",
|
||||||
|
stored.ca_cert_path.as_deref(),
|
||||||
|
project.ca_cert_path.as_deref(),
|
||||||
|
)?;
|
||||||
|
|
||||||
project.container_id = stored.container_id;
|
project.container_id = stored.container_id;
|
||||||
project.status = stored.status;
|
project.status = stored.status;
|
||||||
project.created_at = stored.created_at;
|
project.created_at = stored.created_at;
|
||||||
@@ -1065,10 +1230,177 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn add_and_update_cannot_disagree_about_what_a_folder_list_may_contain() {
|
fn add_and_update_cannot_disagree_about_what_a_folder_list_may_contain() {
|
||||||
// `update_project` used to validate nothing at all, so every rule in
|
// `update_project` used to validate nothing at all, so every rule in
|
||||||
// `add_project` was one save-on-blur away from being bypassed. Both go
|
// `add_project` was one save-on-blur away from being bypassed. It now
|
||||||
// through the same function now; this fails if either grows its own
|
// validates against what is stored rather than in isolation, but a row
|
||||||
// copy.
|
// it has never seen before is held to exactly the same rules — this
|
||||||
|
// fails if either side grows its own copy.
|
||||||
let bad = [path("/home/u/project", "../tmp/claude-x")];
|
let bad = [path("/home/u/project", "../tmp/claude-x")];
|
||||||
assert!(validate_project_paths(&bad).is_err());
|
assert!(validate_project_paths(&bad).is_err());
|
||||||
|
assert!(validate_project_paths_update(&[], &bad).is_err());
|
||||||
|
let good = [path("/home/u/project", "project")];
|
||||||
|
assert!(validate_project_paths(&good).is_ok());
|
||||||
|
assert!(validate_project_paths_update(&[], &good).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Folder lists that are already in `projects.json` ──────────────────
|
||||||
|
//
|
||||||
|
// `update_project` validated nothing while `WorkspaceSection` saved
|
||||||
|
// `{paths}` on every blur, so a stored list can break rules that only
|
||||||
|
// `add_project` ever enforced. Holding a save to all of them turned every
|
||||||
|
// such project into one that cannot be saved *at all* — not its folders:
|
||||||
|
// `update_project` is the single command behind the whole Config tab, so a
|
||||||
|
// sandbox toggle, a permission-mode change and `useTerminal.ts`'s tab
|
||||||
|
// rename all came back with a message about folders.
|
||||||
|
|
||||||
|
/// The shapes a real `projects.json` can be holding. None of them is an
|
||||||
|
/// escape from `/workspace`; all of them used to brick the editor.
|
||||||
|
fn legacy_rows() -> Vec<Vec<ProjectPath>> {
|
||||||
|
vec![
|
||||||
|
// Half-filled: "+ Add folder", a host path typed, no name yet, and
|
||||||
|
// an unrelated blur saved the list.
|
||||||
|
vec![path("/home/u/a", "a"), path("/home/u/b", "")],
|
||||||
|
vec![path("/home/u/a", "a"), path("", "b")],
|
||||||
|
// A mount name the character check refuses but the daemon puts
|
||||||
|
// exactly where it says: /workspace/my project.
|
||||||
|
vec![path("/home/u/a", "my project")],
|
||||||
|
vec![path("/home/u/a", "web@2")],
|
||||||
|
// Two rows sharing a name.
|
||||||
|
vec![path("/home/u/a", "same"), path("/home/u/b", "same")],
|
||||||
|
// The whole drive, from before anything refused it.
|
||||||
|
vec![path("/", "everything")],
|
||||||
|
vec![path("C:\\", "everything")],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_project_stored_with_a_bad_row_can_still_be_saved() {
|
||||||
|
for rows in legacy_rows() {
|
||||||
|
// The full rule set is what made these unsavable…
|
||||||
|
assert!(
|
||||||
|
validate_project_paths(&rows).is_err(),
|
||||||
|
"fixture {:?} is not actually a rule violation",
|
||||||
|
rows
|
||||||
|
);
|
||||||
|
// …and an unrelated Config save re-sends the list it was given.
|
||||||
|
assert!(
|
||||||
|
validate_project_paths_update(&rows, &rows).is_ok(),
|
||||||
|
"saving an unrelated setting on a project stored as {:?} is refused, so every \
|
||||||
|
toggle in the Config tab fails with a message about folders",
|
||||||
|
rows
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_same_bad_row_is_refused_when_it_is_new() {
|
||||||
|
let stored = [path("/home/u/project", "project")];
|
||||||
|
for rows in legacy_rows() {
|
||||||
|
assert!(
|
||||||
|
validate_project_paths_update(&stored, &rows).is_err(),
|
||||||
|
"{:?} was introduced through update_project, which is the escalation the \
|
||||||
|
validation exists to stop",
|
||||||
|
rows
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one rule no exemption reaches. `/workspace/../tmp/claude-x`
|
||||||
|
/// normalises to `/tmp/claude-x` — a path the pre-commit scrub owns and
|
||||||
|
/// empties as root — so a stored one is a live data-loss chain rather than
|
||||||
|
/// untidy data, and the Workspace section is one edit away.
|
||||||
|
#[test]
|
||||||
|
fn a_mount_that_leaves_workspace_is_refused_however_it_got_there() {
|
||||||
|
for escape in ["..", "../tmp/claude-x", "../../etc", "/tmp/claude-x", "a/../..", ".", "..\\x"] {
|
||||||
|
let rows = [path("/home/u/project", escape)];
|
||||||
|
assert!(
|
||||||
|
validate_project_paths_update(&rows, &rows).is_err(),
|
||||||
|
"mount name '{}' was grandfathered, so the C1 chain stays open for anyone who \
|
||||||
|
already has it stored",
|
||||||
|
escape
|
||||||
|
);
|
||||||
|
assert!(validate_project_paths_update(&[], &rows).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editing_a_grandfathered_row_holds_it_to_every_rule_again() {
|
||||||
|
let stored = [path("/", "everything")];
|
||||||
|
// Renaming the mount but keeping the root host path is a new row.
|
||||||
|
assert!(
|
||||||
|
validate_project_paths_update(&stored, &[path("/", "all")]).is_err(),
|
||||||
|
"an edited row was admitted on the strength of the row it replaced"
|
||||||
|
);
|
||||||
|
// Fixing the host path is what the message asks for, and it saves.
|
||||||
|
assert!(
|
||||||
|
validate_project_paths_update(&stored, &[path("/home/u/a", "everything")]).is_ok()
|
||||||
|
);
|
||||||
|
// Dropping the row entirely is always fine.
|
||||||
|
assert!(validate_project_paths_update(&stored, &[]).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stored_duplicate_may_be_kept_but_not_multiplied() {
|
||||||
|
let stored = [path("/home/u/a", "same"), path("/home/u/b", "same")];
|
||||||
|
assert!(validate_project_paths_update(&stored, &stored).is_ok());
|
||||||
|
// Order must not change the answer.
|
||||||
|
let reordered = [stored[1].clone(), stored[0].clone()];
|
||||||
|
assert!(validate_project_paths_update(&stored, &reordered).is_ok());
|
||||||
|
// A third row taking the same name is new, and refused.
|
||||||
|
let more = [
|
||||||
|
stored[0].clone(),
|
||||||
|
stored[1].clone(),
|
||||||
|
path("/home/u/c", "same"),
|
||||||
|
];
|
||||||
|
assert!(validate_project_paths_update(&stored, &more).is_err());
|
||||||
|
// And a second copy of a name that was unique stays refused.
|
||||||
|
let unique = [path("/home/u/a", "a")];
|
||||||
|
assert!(validate_project_paths_update(
|
||||||
|
&unique,
|
||||||
|
&[path("/home/u/a", "a"), path("/home/u/b", "a")]
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_blank_placeholder_row_is_still_not_an_error_on_either_path() {
|
||||||
|
let stored = [path("/home/u/a", "a")];
|
||||||
|
let with_placeholder = [path("/home/u/a", "a"), path("", "")];
|
||||||
|
assert!(validate_project_paths(&with_placeholder).is_ok());
|
||||||
|
assert!(validate_project_paths_update(&stored, &with_placeholder).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The two host paths that had no check at all ───────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_filesystem_root_cannot_be_newly_set_as_an_ssh_or_ca_path() {
|
||||||
|
for root in ["/", "//", "\\", "C:\\", "c:/", "D:"] {
|
||||||
|
assert!(
|
||||||
|
validate_mounted_host_path("the SSH key folder", None, Some(root)).is_err(),
|
||||||
|
"'{}' was accepted as an SSH key path, which read-only bind-mounts the whole \
|
||||||
|
host filesystem at /tmp/.host-ssh",
|
||||||
|
root
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
validate_mounted_host_path("the CA certificate path", Some("/etc/ssl"), Some(root))
|
||||||
|
.is_err(),
|
||||||
|
"'{}' was accepted as a CA certificate path",
|
||||||
|
root
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// A real folder, a cleared value and an absent one are all fine.
|
||||||
|
assert!(validate_mounted_host_path("x", None, Some("/home/u/.ssh")).is_ok());
|
||||||
|
assert!(validate_mounted_host_path("x", Some("/home/u/.ssh"), None).is_ok());
|
||||||
|
assert!(validate_mounted_host_path("x", Some("/home/u/.ssh"), Some("")).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_ssh_path_already_stored_does_not_brick_the_editor_either() {
|
||||||
|
// Nothing ever validated this field, so it can hold a root today — and
|
||||||
|
// it is mounted on every container start whether or not an unrelated
|
||||||
|
// Config save is allowed through.
|
||||||
|
assert!(validate_mounted_host_path("x", Some("/"), Some("/")).is_ok());
|
||||||
|
assert!(validate_mounted_host_path("x", Some("/"), Some(" / ")).is_ok());
|
||||||
|
// Changing it to a different root is a change, and refused.
|
||||||
|
assert!(validate_mounted_host_path("x", Some("/"), Some("C:\\")).is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -476,6 +476,7 @@ pub fn run() {
|
|||||||
commands::auth_token_commands::cancel_claude_token,
|
commands::auth_token_commands::cancel_claude_token,
|
||||||
commands::auth_token_commands::has_claude_token,
|
commands::auth_token_commands::has_claude_token,
|
||||||
commands::auth_token_commands::clear_claude_token,
|
commands::auth_token_commands::clear_claude_token,
|
||||||
|
commands::auth_token_commands::sweep_claude_token_snapshots,
|
||||||
// Settings
|
// Settings
|
||||||
commands::settings_commands::get_settings,
|
commands::settings_commands::get_settings,
|
||||||
commands::settings_commands::update_settings,
|
commands::settings_commands::update_settings,
|
||||||
|
|||||||
@@ -6,14 +6,38 @@ import type { ClearTokenOutcome, Project } from "../../lib/types";
|
|||||||
|
|
||||||
const hasClaudeToken = vi.fn();
|
const hasClaudeToken = vi.fn();
|
||||||
const clearClaudeToken = vi.fn();
|
const clearClaudeToken = vi.fn();
|
||||||
|
const sweepClaudeTokenSnapshots = vi.fn();
|
||||||
|
|
||||||
vi.mock("../../lib/tauri-commands", () => ({
|
vi.mock("../../lib/tauri-commands", () => ({
|
||||||
hasClaudeToken: () => hasClaudeToken(),
|
hasClaudeToken: () => hasClaudeToken(),
|
||||||
clearClaudeToken: () => clearClaudeToken(),
|
clearClaudeToken: () => clearClaudeToken(),
|
||||||
|
sweepClaudeTokenSnapshots: () => sweepClaudeTokenSnapshots(),
|
||||||
acquireClaudeToken: vi.fn(),
|
acquireClaudeToken: vi.fn(),
|
||||||
submitClaudeTokenCode: vi.fn(),
|
submitClaudeTokenCode: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Stood in for so a sign-in can be *completed* in a test. The panel's reaction
|
||||||
|
// to `onAuthenticated` is the subject of the H1 sequence below, and driving the
|
||||||
|
// real acquisition dialog to get there would test the dialog instead.
|
||||||
|
vi.mock("./ClaudeAuthModal", () => ({
|
||||||
|
default: ({
|
||||||
|
onAuthenticated,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
onAuthenticated: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) => (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onAuthenticated();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
finish sign-in
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(async () => vi.fn()) }));
|
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(async () => vi.fn()) }));
|
||||||
vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn() }));
|
vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn() }));
|
||||||
|
|
||||||
@@ -59,11 +83,22 @@ const running = (over: Partial<Project> = {}): Project => ({
|
|||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** A `ClearTokenOutcome` with nothing left behind, plus any overrides. */
|
||||||
|
const outcome = (over: Partial<ClearTokenOutcome> = {}): ClearTokenOutcome => ({
|
||||||
|
snapshots_scrubbed: [],
|
||||||
|
snapshots_failed: [],
|
||||||
|
snapshots_skipped: [],
|
||||||
|
snapshots_superseded: [],
|
||||||
|
docker_unavailable: null,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
describe("SharedAuthSettings", () => {
|
describe("SharedAuthSettings", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
projects = [];
|
projects = [];
|
||||||
hasClaudeToken.mockResolvedValue(false);
|
hasClaudeToken.mockResolvedValue(false);
|
||||||
|
sweepClaudeTokenSnapshots.mockResolvedValue(outcome());
|
||||||
useAppState.setState({ toasts: [] });
|
useAppState.setState({ toasts: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -251,19 +286,17 @@ describe("SharedAuthSettings", () => {
|
|||||||
fireEvent.click(await screen.findByRole("button", { name: "Revoke token" }));
|
fireEvent.click(await screen.findByRole("button", { name: "Revoke token" }));
|
||||||
const retry = await screen.findByTestId("shared-auth-retry");
|
const retry = await screen.findByTestId("shared-auth-retry");
|
||||||
|
|
||||||
clearClaudeToken.mockResolvedValueOnce({
|
sweepClaudeTokenSnapshots.mockResolvedValueOnce(
|
||||||
snapshots_scrubbed: ["triple-c-snapshot-p1:latest"],
|
outcome({ snapshots_scrubbed: ["triple-c-snapshot-p1:latest"] }),
|
||||||
snapshots_failed: [],
|
);
|
||||||
snapshots_skipped: [],
|
|
||||||
snapshots_superseded: [],
|
|
||||||
docker_unavailable: null,
|
|
||||||
});
|
|
||||||
fireEvent.click(retry);
|
fireEvent.click(retry);
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.queryByTestId("shared-auth-leftover")).not.toBeInTheDocument(),
|
expect(screen.queryByTestId("shared-auth-leftover")).not.toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
expect(clearClaudeToken).toHaveBeenCalledTimes(2);
|
// One revoke, one sweep — the retry must not be a second revoke.
|
||||||
|
expect(clearClaudeToken).toHaveBeenCalledTimes(1);
|
||||||
|
expect(sweepClaudeTokenSnapshots).toHaveBeenCalledTimes(1);
|
||||||
const toast = useAppState.getState().toasts.at(-1)!;
|
const toast = useAppState.getState().toasts.at(-1)!;
|
||||||
expect(toast.kind).toBe("success");
|
expect(toast.kind).toBe("success");
|
||||||
expect(toast.message).toMatch(/cleared from 1 snapshot image/i);
|
expect(toast.message).toMatch(/cleared from 1 snapshot image/i);
|
||||||
@@ -274,20 +307,14 @@ describe("SharedAuthSettings", () => {
|
|||||||
// anything is in the keychain today, so the sweep cannot be gated on it.
|
// anything is in the keychain today, so the sweep cannot be gated on it.
|
||||||
projects = [running()];
|
projects = [running()];
|
||||||
hasClaudeToken.mockResolvedValue(false);
|
hasClaudeToken.mockResolvedValue(false);
|
||||||
clearClaudeToken.mockResolvedValue({
|
|
||||||
snapshots_scrubbed: [],
|
|
||||||
snapshots_failed: [],
|
|
||||||
snapshots_skipped: [],
|
|
||||||
snapshots_superseded: [],
|
|
||||||
docker_unavailable: null,
|
|
||||||
});
|
|
||||||
render(<SharedAuthSettings />);
|
render(<SharedAuthSettings />);
|
||||||
|
|
||||||
const sweep = await screen.findByTestId("shared-auth-sweep");
|
const sweep = await screen.findByTestId("shared-auth-sweep");
|
||||||
expect(screen.queryByRole("button", { name: "Revoke" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Revoke" })).not.toBeInTheDocument();
|
||||||
fireEvent.click(sweep);
|
fireEvent.click(sweep);
|
||||||
|
|
||||||
await waitFor(() => expect(clearClaudeToken).toHaveBeenCalled());
|
await waitFor(() => expect(sweepClaudeTokenSnapshots).toHaveBeenCalled());
|
||||||
|
expect(clearClaudeToken).not.toHaveBeenCalled();
|
||||||
const toast = useAppState.getState().toasts.at(-1)!;
|
const toast = useAppState.getState().toasts.at(-1)!;
|
||||||
expect(toast.kind).toBe("success");
|
expect(toast.kind).toBe("success");
|
||||||
expect(toast.message).toBe("No snapshot image is holding the token.");
|
expect(toast.message).toBe("No snapshot image is holding the token.");
|
||||||
@@ -300,4 +327,116 @@ describe("SharedAuthSettings", () => {
|
|||||||
expect(toast.kind).toBe("success");
|
expect(toast.kind).toBe("success");
|
||||||
expect(screen.queryByTestId("shared-auth-leftover")).not.toBeInTheDocument();
|
expect(screen.queryByTestId("shared-auth-leftover")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── The retry must not be a revoke in disguise ───────────────────────────
|
||||||
|
// The sequence that shipped green: revoke, get a skipped image, re-authenticate
|
||||||
|
// from the button directly above the warning, then press the retry the warning
|
||||||
|
// is still offering. One command was behind both, so that press deleted the
|
||||||
|
// token acquired seconds earlier — no confirmation, and a toast that mentioned
|
||||||
|
// only images. The deliberate Revoke needs a modal; this needed nothing.
|
||||||
|
|
||||||
|
it("does not leave a stale cleanup panel over a freshly acquired token", async () => {
|
||||||
|
projects = [running()];
|
||||||
|
// Stored when the panel mounts, gone after the revoke, stored again once
|
||||||
|
// the sign-in finishes.
|
||||||
|
hasClaudeToken
|
||||||
|
.mockResolvedValueOnce(true)
|
||||||
|
.mockResolvedValueOnce(false)
|
||||||
|
.mockResolvedValue(true);
|
||||||
|
clearClaudeToken.mockResolvedValue(
|
||||||
|
outcome({ snapshots_skipped: ["triple-c-snapshot-p1:latest: busy"] }),
|
||||||
|
);
|
||||||
|
render(<SharedAuthSettings />);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Revoke" }));
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Revoke token" }));
|
||||||
|
await screen.findByTestId("shared-auth-leftover");
|
||||||
|
|
||||||
|
// Re-authenticate from the button above the warning.
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Authenticate" }));
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "finish sign-in" }));
|
||||||
|
|
||||||
|
await screen.findByRole("button", { name: "Revoke" });
|
||||||
|
expect(screen.queryByTestId("shared-auth-leftover")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("shared-auth-retry")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// The images can still be cleaned up — and doing so does not spend the new
|
||||||
|
// token.
|
||||||
|
fireEvent.click(screen.getByTestId("shared-auth-sweep"));
|
||||||
|
await waitFor(() => expect(sweepClaudeTokenSnapshots).toHaveBeenCalled());
|
||||||
|
expect(clearClaudeToken).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the retry to the sweep-only command, never to the revoke", async () => {
|
||||||
|
projects = [running()];
|
||||||
|
hasClaudeToken.mockResolvedValueOnce(true).mockResolvedValue(false);
|
||||||
|
clearClaudeToken.mockResolvedValue(
|
||||||
|
outcome({ snapshots_skipped: ["triple-c-snapshot-p1:latest: busy"] }),
|
||||||
|
);
|
||||||
|
sweepClaudeTokenSnapshots.mockResolvedValue(
|
||||||
|
outcome({ snapshots_skipped: ["triple-c-snapshot-p1:latest: busy"] }),
|
||||||
|
);
|
||||||
|
render(<SharedAuthSettings />);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Revoke" }));
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Revoke token" }));
|
||||||
|
const retry = await screen.findByTestId("shared-auth-retry");
|
||||||
|
|
||||||
|
fireEvent.click(retry);
|
||||||
|
await waitFor(() => expect(sweepClaudeTokenSnapshots).toHaveBeenCalledTimes(1));
|
||||||
|
fireEvent.click(await screen.findByTestId("shared-auth-retry"));
|
||||||
|
await waitFor(() => expect(sweepClaudeTokenSnapshots).toHaveBeenCalledTimes(2));
|
||||||
|
|
||||||
|
// However many times it is pressed, the keychain is touched exactly once —
|
||||||
|
// by the revoke the user confirmed.
|
||||||
|
expect(clearClaudeToken).toHaveBeenCalledTimes(1);
|
||||||
|
// …and a retry that still finds a busy project says so without ever
|
||||||
|
// claiming a token was removed.
|
||||||
|
const toast = useAppState.getState().toasts.at(-1)!;
|
||||||
|
expect(toast.message).not.toMatch(/keychain/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("will not start a sign-in while a cleanup is still running", async () => {
|
||||||
|
// A sweep is a per-image inspect/create/commit/rmi over the Docker socket
|
||||||
|
// and runs for minutes. Acquiring a token inside that window races the
|
||||||
|
// rewrite that is meant to be removing one.
|
||||||
|
projects = [running()];
|
||||||
|
hasClaudeToken.mockResolvedValue(false);
|
||||||
|
sweepClaudeTokenSnapshots.mockReturnValue(new Promise(() => {}));
|
||||||
|
render(<SharedAuthSettings />);
|
||||||
|
|
||||||
|
const authenticate = await screen.findByRole("button", { name: "Authenticate" });
|
||||||
|
expect(authenticate).toBeEnabled();
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByTestId("shared-auth-sweep"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole("button", { name: "Authenticate" })).toBeDisabled(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── A keychain that refuses the delete has to leave something to press ───
|
||||||
|
|
||||||
|
it("keeps both remedies on screen when the keychain refuses the delete", async () => {
|
||||||
|
projects = [running()];
|
||||||
|
hasClaudeToken.mockResolvedValue(true);
|
||||||
|
clearClaudeToken.mockRejectedValue("the keychain is locked");
|
||||||
|
render(<SharedAuthSettings />);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Revoke" }));
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Revoke token" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(useAppState.getState().toasts.length).toBeGreaterThan(0),
|
||||||
|
);
|
||||||
|
const toast = useAppState.getState().toasts[0];
|
||||||
|
expect(toast.kind).toBe("error");
|
||||||
|
expect(toast.detail).toMatch(/still stored/i);
|
||||||
|
|
||||||
|
// The token is still there, so Revoke is still the retry for it — and the
|
||||||
|
// images, which the failed delete says nothing about, have their own sweep.
|
||||||
|
await screen.findByRole("button", { name: "Revoke" });
|
||||||
|
const sweep = screen.getByTestId("shared-auth-sweep");
|
||||||
|
fireEvent.click(sweep);
|
||||||
|
await waitFor(() => expect(sweepClaudeTokenSnapshots).toHaveBeenCalled());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import Modal from "../ui/Modal";
|
|||||||
import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
|
import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
|
||||||
import { selectClass } from "../ui/Field";
|
import { selectClass } from "../ui/Field";
|
||||||
import ClaudeAuthModal from "./ClaudeAuthModal";
|
import ClaudeAuthModal from "./ClaudeAuthModal";
|
||||||
import { clearClaudeToken } from "../../lib/tauri-commands";
|
import {
|
||||||
|
clearClaudeToken,
|
||||||
|
sweepClaudeTokenSnapshots,
|
||||||
|
} from "../../lib/tauri-commands";
|
||||||
import type { ClearTokenOutcome } from "../../lib/types";
|
import type { ClearTokenOutcome } from "../../lib/types";
|
||||||
import { useProjects } from "../../hooks/useProjects";
|
import { useProjects } from "../../hooks/useProjects";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
@@ -96,18 +99,30 @@ export default function SharedAuthSettings() {
|
|||||||
const display = STATUS_DISPLAY[status];
|
const display = STATUS_DISPLAY[status];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run `clear_claude_token`. It is deliberately the same command for both the
|
* Run one of the two cleanups. **They are different commands**, and that is
|
||||||
* first revoke and every retry: it sweeps the snapshot images first, deletes
|
* the point.
|
||||||
* the keychain entry second, and treats a missing entry as success — so
|
*
|
||||||
* calling it again with nothing stored is a pure snapshot sweep, and the
|
* `"revoke"` deletes the keychain entry and then rewrites the images. It is
|
||||||
* images themselves are the durable record of what is left to do.
|
* destructive, and it is only ever reached through the confirmation modal.
|
||||||
|
*
|
||||||
|
* `"sweep"` rewrites the images and never touches the keychain. Both the
|
||||||
|
* standalone check and the retry offered after an incomplete revocation use
|
||||||
|
* it, because neither is a request to delete a credential — the images are
|
||||||
|
* the durable record of what is left to do, so the work is re-derived from
|
||||||
|
* Docker rather than from any token that happens to be stored.
|
||||||
|
*
|
||||||
|
* While there was one command behind both, the retry button was an
|
||||||
|
* unconfirmed revoke: re-authenticate from the button above the panel, press
|
||||||
|
* "Retry snapshot cleanup", and the token acquired seconds earlier was gone,
|
||||||
|
* announced by a toast that mentioned only images.
|
||||||
*/
|
*/
|
||||||
const runSweep = async (mode: "revoke" | "sweep") => {
|
const runCleanup = async (mode: "revoke" | "sweep") => {
|
||||||
setSweeping(true);
|
setSweeping(true);
|
||||||
try {
|
try {
|
||||||
const outcome = (await clearClaudeToken()) as RevokeOutcome;
|
const outcome = (await (mode === "revoke"
|
||||||
|
? clearClaudeToken()
|
||||||
|
: sweepClaudeTokenSnapshots())) as RevokeOutcome;
|
||||||
setConfirmRevoke(false);
|
setConfirmRevoke(false);
|
||||||
await refresh();
|
|
||||||
|
|
||||||
const failed = list(outcome.snapshots_failed);
|
const failed = list(outcome.snapshots_failed);
|
||||||
const skipped = list(outcome.snapshots_skipped);
|
const skipped = list(outcome.snapshots_skipped);
|
||||||
@@ -116,12 +131,14 @@ export default function SharedAuthSettings() {
|
|||||||
|
|
||||||
setLeftover(needsAnotherPass(outcome) ? outcome : null);
|
setLeftover(needsAnotherPass(outcome) ? outcome : null);
|
||||||
|
|
||||||
// The keychain entry is gone either way. What matters here is the copy of
|
// Whatever the mode, what is being reported here is the copy of the
|
||||||
// the token that `docker commit` baked into each project's snapshot
|
// token that `docker commit` baked into each project's snapshot image:
|
||||||
// image: that one outlives every container, and `docker image inspect`
|
// that one outlives every container, and `docker image inspect` will
|
||||||
// will keep printing it until the image is rewritten. If that could not
|
// keep printing it until the image is rewritten. On a revoke the
|
||||||
// be done, the revocation is incomplete and saying "removed" would be a
|
// keychain entry is already gone by this point — the command deletes it
|
||||||
// lie.
|
// before it touches an image — so if the rewrite could not be done the
|
||||||
|
// revocation is incomplete and saying "removed" would be a lie. On a
|
||||||
|
// sweep nothing was deleted at all, and the wording must not imply it.
|
||||||
if (outcome.docker_unavailable) {
|
if (outcome.docker_unavailable) {
|
||||||
pushToast({
|
pushToast({
|
||||||
kind: "error",
|
kind: "error",
|
||||||
@@ -137,7 +154,10 @@ export default function SharedAuthSettings() {
|
|||||||
} else if (failed.length > 0) {
|
} else if (failed.length > 0) {
|
||||||
pushToast({
|
pushToast({
|
||||||
kind: "error",
|
kind: "error",
|
||||||
message: "Token removed from the keychain, but it is still in some images.",
|
message:
|
||||||
|
mode === "revoke"
|
||||||
|
? "Token removed from the keychain, but it is still in some images."
|
||||||
|
: "A token is still readable in some snapshot images.",
|
||||||
detail:
|
detail:
|
||||||
`${failed.length} snapshot image(s) could not be rewritten and ` +
|
`${failed.length} snapshot image(s) could not be rewritten and ` +
|
||||||
"still contain the token, readable via `docker image inspect`. Reset those " +
|
"still contain the token, readable via `docker image inspect`. Reset those " +
|
||||||
@@ -187,12 +207,24 @@ export default function SharedAuthSettings() {
|
|||||||
mode === "revoke"
|
mode === "revoke"
|
||||||
? "Could not remove the shared Claude token."
|
? "Could not remove the shared Claude token."
|
||||||
: "Could not clear the token from snapshot images.",
|
: "Could not clear the token from snapshot images.",
|
||||||
detail: authErrorMessage(
|
detail:
|
||||||
e,
|
mode === "revoke"
|
||||||
"The OS keychain rejected the delete. The token may still be stored.",
|
? // The keychain delete runs first and nothing else runs until it
|
||||||
),
|
// succeeds, so the consequence is knowable and worth stating
|
||||||
|
// rather than leaving the user with a raw keyring error: the
|
||||||
|
// token is still stored, and no image was touched. Revoke stays
|
||||||
|
// on screen because `status` is still "stored", and the snapshot
|
||||||
|
// sweep beside it does not need this delete to have worked.
|
||||||
|
`${authErrorMessage(e, "The OS keychain rejected the delete.")} ` +
|
||||||
|
"The token is still stored and no snapshot image was changed — try again, " +
|
||||||
|
"or use “Check snapshot images” to clean the images on their own."
|
||||||
|
: authErrorMessage(e, "The snapshot images could not be checked."),
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
// In `finally` rather than in the success path: a failed revoke leaves
|
||||||
|
// the token stored, and the panel has to say so rather than keeping
|
||||||
|
// whatever it believed before the attempt.
|
||||||
|
await refresh();
|
||||||
setSweeping(false);
|
setSweeping(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -255,7 +287,11 @@ export default function SharedAuthSettings() {
|
|||||||
<Button
|
<Button
|
||||||
size="md"
|
size="md"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
disabled={!host}
|
// Also disabled while a cleanup is running. A sweep is a per-image
|
||||||
|
// inspect/create/commit/rmi over the Docker socket and takes minutes;
|
||||||
|
// acquiring a token in the middle of one races the rewrite, and a
|
||||||
|
// revoke started before it would delete the new token when it lands.
|
||||||
|
disabled={!host || sweeping}
|
||||||
onClick={() => setAuthOpen(true)}
|
onClick={() => setAuthOpen(true)}
|
||||||
>
|
>
|
||||||
{status === "stored" ? "Re-authenticate" : "Authenticate"}
|
{status === "stored" ? "Re-authenticate" : "Authenticate"}
|
||||||
@@ -270,19 +306,21 @@ export default function SharedAuthSettings() {
|
|||||||
Revoke
|
Revoke
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{status === "absent" && (
|
{status !== "checking" && (
|
||||||
// Not gated on a stored token, on purpose. A revoke that could not
|
// Offered in every state, not just "absent". Three reasons, and the
|
||||||
// finish leaves the token in a snapshot image while the keychain
|
// command behind it deletes nothing, so none of them costs anything:
|
||||||
// entry — and therefore the Revoke button — is already gone, and a
|
// a snapshot committed by an older build carries a token whether or
|
||||||
// snapshot committed by an older build carries it whether or not
|
// not one is stored today; a revoke that could not finish leaves one
|
||||||
// anything is stored today. With nothing in the keychain the same
|
// in an image while the keychain entry — and the Revoke button — is
|
||||||
// command is a pure image sweep.
|
// already gone; and when the keychain refuses the delete, `status`
|
||||||
|
// stays "stored", which used to leave the images with no affordance
|
||||||
|
// at all.
|
||||||
<Button
|
<Button
|
||||||
size="md"
|
size="md"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
disabled={sweeping}
|
disabled={sweeping}
|
||||||
data-testid="shared-auth-sweep"
|
data-testid="shared-auth-sweep"
|
||||||
onClick={() => void runSweep("sweep")}
|
onClick={() => void runCleanup("sweep")}
|
||||||
>
|
>
|
||||||
{sweeping ? "Checking…" : "Check snapshot images"}
|
{sweeping ? "Checking…" : "Check snapshot images"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -327,7 +365,9 @@ export default function SharedAuthSettings() {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
disabled={sweeping}
|
disabled={sweeping}
|
||||||
data-testid="shared-auth-retry"
|
data-testid="shared-auth-retry"
|
||||||
onClick={() => void runSweep("sweep")}
|
// `runCleanup("sweep")` is `sweep_claude_token_snapshots`, which
|
||||||
|
// has no keychain delete on the wire at all — see `runCleanup`.
|
||||||
|
onClick={() => void runCleanup("sweep")}
|
||||||
>
|
>
|
||||||
{sweeping ? "Retrying…" : "Retry snapshot cleanup"}
|
{sweeping ? "Retrying…" : "Retry snapshot cleanup"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -360,6 +400,13 @@ export default function SharedAuthSettings() {
|
|||||||
projectName={host.name}
|
projectName={host.name}
|
||||||
onClose={() => setAuthOpen(false)}
|
onClose={() => setAuthOpen(false)}
|
||||||
onAuthenticated={() => {
|
onAuthenticated={() => {
|
||||||
|
// The panel is about a *previous* credential and the button that
|
||||||
|
// finishes it is a sweep, so leaving it up after a fresh sign-in
|
||||||
|
// is at best confusing and was at worst fatal: while the retry ran
|
||||||
|
// `clear_claude_token`, the obvious next click deleted the token
|
||||||
|
// just acquired. "Check snapshot images" stays available above, so
|
||||||
|
// nothing is lost by clearing this.
|
||||||
|
setLeftover(null);
|
||||||
void refresh();
|
void refresh();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -384,7 +431,7 @@ export default function SharedAuthSettings() {
|
|||||||
size="md"
|
size="md"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
disabled={sweeping}
|
disabled={sweeping}
|
||||||
onClick={() => void runSweep("revoke")}
|
onClick={() => void runCleanup("revoke")}
|
||||||
>
|
>
|
||||||
{sweeping ? "Revoking…" : "Revoke token"}
|
{sweeping ? "Revoking…" : "Revoke token"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -399,11 +446,13 @@ export default function SharedAuthSettings() {
|
|||||||
restarted.
|
restarted.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-[13px] text-[var(--text-secondary)] leading-snug">
|
<p className="mt-2 text-[13px] text-[var(--text-secondary)] leading-snug">
|
||||||
Each project’s snapshot image is rewritten first, because{" "}
|
The keychain entry goes first, then each project’s snapshot image is
|
||||||
<code className="font-mono">docker commit</code> copies the token into it
|
rewritten — <code className="font-mono">docker commit</code> copies
|
||||||
and an image outlives every container built from it. A project that is
|
the token into it, and an image outlives every container built from it.
|
||||||
busy right now is skipped rather than rewritten unsafely — you will
|
That second half takes a while, and a project that is busy right now is
|
||||||
be told which, and the cleanup can be run again from here afterwards.
|
skipped rather than rewritten unsafely: you will be told which, and
|
||||||
|
“Check snapshot images” runs the cleanup again afterwards
|
||||||
|
without touching the keychain.
|
||||||
</p>
|
</p>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -317,10 +317,16 @@ export const submitClaudeTokenCode = (code: string) =>
|
|||||||
/** Abort an in-flight acquisition and release the single-flight guard. No-op if nothing is running. */
|
/** Abort an in-flight acquisition and release the single-flight guard. No-op if nothing is running. */
|
||||||
export const cancelClaudeToken = () => invoke<void>("cancel_claude_token");
|
export const cancelClaudeToken = () => invoke<void>("cancel_claude_token");
|
||||||
export const hasClaudeToken = () => invoke<boolean>("has_claude_token");
|
export const hasClaudeToken = () => invoke<boolean>("has_claude_token");
|
||||||
/** Revoke the shared token. Also rewrites any snapshot image that still has it
|
/** Revoke the shared token: delete the keychain entry **first**, then rewrite
|
||||||
* baked into its env — see `ClearTokenOutcome` for what may be left behind. */
|
* any snapshot image that still has it baked into its env — see
|
||||||
|
* `ClearTokenOutcome` for what may be left behind. Destructive; confirm it. */
|
||||||
export const clearClaudeToken = () =>
|
export const clearClaudeToken = () =>
|
||||||
invoke<ClearTokenOutcome>("clear_claude_token");
|
invoke<ClearTokenOutcome>("clear_claude_token");
|
||||||
|
/** Rewrite snapshot images that still carry a credential, **without touching
|
||||||
|
* the keychain**. This is the retry behind an incomplete revocation, and the
|
||||||
|
* standalone "check my images" sweep; it never deletes a token. */
|
||||||
|
export const sweepClaudeTokenSnapshots = () =>
|
||||||
|
invoke<ClearTokenOutcome>("sweep_claude_token_snapshots");
|
||||||
|
|
||||||
// Container base-image migration — move a project onto the current base image
|
// Container base-image migration — move a project onto the current base image
|
||||||
// without deleting its volumes. Reset is the destructive alternative: it wipes
|
// without deleting its volumes. Reset is the destructive alternative: it wipes
|
||||||
|
|||||||
Reference in New Issue
Block a user