Add shared-auth-token UI and make cancelling actually cancel

UI for the shared Claude token: a Settings section showing token state
with Authenticate and Revoke, an acquisition modal built on the shared
Modal (sign-in link handed to the host browser via the opener plugin,
plus the code input that answers `setup-token`'s stdin prompt — the flow
cannot complete without it), and a per-project opt-out toggle shown only
for the Anthropic backend.

Cancellation: acquire_claude_token previously had only two exits,
completion and a 15-minute timeout, and held the single-flight guard for
the whole time. Closing the dialog therefore locked the user out of
retrying for up to 15 minutes. Adds cancel_claude_token, backed by a
oneshot claimed and released in lockstep with the input guard, selected
on in the run loop so it wins the race and tears the exec down. The
dialog's Cancel now calls it and closes either way.

Also refreshes CLAUDE.md, which had drifted: it documented the deleted
ProjectCard, and asserted that new IPC commands need permission grants
in capabilities/default.json — they do not, that file covers plugin
commands only. Adds the conventions that would otherwise bite:
container_needs_recreation() is purely label-based and never diffs env,
so container-affecting state needs its own label; and #[serde(default)]
on a bool yields false regardless of intent.

Corrects the claim that Reset preserves credentials. Reset calls
remove_project_volumes, which deletes both the home and claude-config
volumes, so it wipes ~/.claude, the OAuth token, installed skills and
session transcripts.

84 frontend tests, 34 Rust tests, both builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 11:49:03 -07:00
co-authored by Claude Opus 5
parent 01a2f6aec8
commit d95ba54a69
15 changed files with 1427 additions and 11 deletions
@@ -43,7 +43,7 @@ use std::time::Duration;
use futures_util::StreamExt;
use tauri::{AppHandle, Emitter, State};
use tokio::io::AsyncWriteExt;
use tokio::sync::{mpsc, Mutex};
use tokio::sync::{mpsc, oneshot, Mutex};
use crate::docker::container::is_container_running;
use crate::docker::exec::{create_attached_exec, wait_for_exec_exit, AttachedExec};
@@ -394,6 +394,18 @@ fn pending_input() -> &'static Mutex<Option<mpsc::UnboundedSender<Vec<u8>>>> {
PENDING_INPUT.get_or_init(|| Mutex::new(None))
}
/// Abort channel for the in-flight flow, claimed and released in lockstep with
/// [`PENDING_INPUT`].
///
/// Without this the only exits are "finished" and "timed out", so a user who
/// closes the dialog would be locked out by the single-flight guard until
/// `SETUP_TIMEOUT` elapsed.
static CANCEL_TX: OnceLock<Mutex<Option<oneshot::Sender<()>>>> = OnceLock::new();
fn cancel_slot() -> &'static Mutex<Option<oneshot::Sender<()>>> {
CANCEL_TX.get_or_init(|| Mutex::new(None))
}
fn emit_progress(app: &AppHandle, project_id: &str, message: &str) {
let _ = app.emit(
PROGRESS_EVENT,
@@ -431,6 +443,7 @@ async fn run_setup_token(
project_id: &str,
container_id: &str,
mut input_rx: mpsc::UnboundedReceiver<Vec<u8>>,
mut cancel_rx: oneshot::Receiver<()>,
) -> Result<String, String> {
// A pty (`tty = true`) because `setup-token` renders an interactive TUI and
// reads the pasted code in raw mode, which a plain pipe cannot provide.
@@ -460,6 +473,14 @@ async fn run_setup_token(
// alive for the whole session anyway — dropping it early would tear the
// output stream down with it.
let next = tokio::select! {
// Cancellation wins the race so a user who gives up isn't held by
// the single-flight guard until the timeout. Dropping `input` and
// `output` on return tears the exec down with them.
_ = &mut cancel_rx => {
return Err(
"Authentication cancelled. No token was stored.".to_string()
);
}
Some(data) = input_rx.recv() => {
if let Err(e) = input.write_all(&data).await {
return Err(format!(
@@ -569,7 +590,11 @@ pub async fn acquire_claude_token(
// Claim the flow before touching anything else, so a second caller bounces
// off the guard rather than half-configuring the same project.
let (input_tx, input_rx) = mpsc::unbounded_channel::<Vec<u8>>();
let (cancel_tx, cancel_rx) = oneshot::channel::<()>();
{
// Both slots are claimed under the input lock held first, and released
// in the same order below, so the guard and its abort channel can never
// disagree about whether a flow is live.
let mut slot = pending_input().lock().await;
if slot.is_some() {
return Err(
@@ -578,6 +603,7 @@ pub async fn acquire_claude_token(
);
}
*slot = Some(input_tx);
*cancel_slot().lock().await = Some(cancel_tx);
}
let bridge_was_enabled = project.auth_bridge_enabled;
@@ -616,13 +642,14 @@ pub async fn acquire_claude_token(
"Running `claude setup-token` — sign in at the URL below, then submit the code it gives you.",
);
run_setup_token(&app_handle, &project_id, &container_id, input_rx).await
run_setup_token(&app_handle, &project_id, &container_id, input_rx, cancel_rx).await
}
.await;
// Release the flow, then restore the bridge — both unconditionally, so a
// failed or cancelled login leaves nothing latched on.
*pending_input().lock().await = None;
*cancel_slot().lock().await = None;
if !bridge_was_enabled {
// Stop the poller first: it awaits teardown, so host ports are provably
// released before the flag goes back.
@@ -686,6 +713,22 @@ pub async fn submit_claude_token_code(code: String) -> Result<(), String> {
.map_err(|_| "The authentication flow has already ended.".to_string())
}
/// Abort an in-flight [`acquire_claude_token`].
///
/// Tears the `setup-token` exec down and releases the single-flight guard, so
/// the user can immediately try again rather than waiting out `SETUP_TIMEOUT`.
/// A no-op when nothing is running, so closing the dialog twice is harmless.
#[tauri::command]
pub async fn cancel_claude_token() -> Result<(), String> {
let Some(sender) = cancel_slot().lock().await.take() else {
return Ok(());
};
// `Err` only means the flow finished between the take and the send, which
// is exactly the outcome cancelling wanted.
let _ = sender.send(());
Ok(())
}
/// Whether a shared Claude token exists. Deliberately a boolean — no command
/// here ever hands the token itself to the frontend.
#[tauri::command]
+1
View File
@@ -168,6 +168,7 @@ pub fn run() {
// Shared Claude Code auth token
commands::auth_token_commands::acquire_claude_token,
commands::auth_token_commands::submit_claude_token_code,
commands::auth_token_commands::cancel_claude_token,
commands::auth_token_commands::has_claude_token,
commands::auth_token_commands::clear_claude_token,
// Settings