From 01a2f6aec85c5286b7207720b80b0218b299b53a Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 9 Aug 2026 11:35:42 -0700 Subject: [PATCH] Add Project Home, Auth Bridge, shared auth token, and Tier-1 polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project Home (DESIGN-REVIEW §B2): the project is promoted from a 280px sidebar card to a first-class main-area view. ProjectCard.tsx (1,257 lines) is replaced by a select-only ProjectRow plus tabs for Overview, Sessions, Automation, Config and Files. The PortMappings, FileManager and ContainerProgress modals are absorbed rather than reimplemented. Config gains a Saved/Saving/Failed indicator — save-on-blur failures previously reached only console.error. Tier-1 polish (DESIGN-REVIEW §A): new elevation, muted-accent, disabled and focus-ring tokens; a global :focus-visible ring with every focus:outline-none removed; filled buttons moved to --accent-emphasis and white-on-success toggles retired, fixing three WCAG AA failures (2.1:1, 2.5:1, 2.4:1); a shared Modal primitive with role="dialog", focus trap and restore, adopted by all remaining modals; status indicators that carry a glyph and word rather than colour alone. Ctrl+Shift+W closes a tab, deliberately not Ctrl+W — that is readline's kill-word, used constantly in the terminal this app is built around. Auth Bridge: a general loopback-callback bridge so browser logins run inside a container (aws sso login, Concourse fly login, claude login) can complete against the host browser. Listeners are discovered from /proc/net/tcp{,6} — ss/netstat/lsof are absent from the image — bound on host 127.0.0.1 only, and tunnelled in over the Docker API via socat, which keeps working on Docker Desktop where container IPs are not routable. Falls back to [::1] because Node resolves localhost to IPv6 first, so claude login often binds ::1 alone. Opt-in per project. This extracts create_attached_exec() and moves the existing terminal session path onto it, so there is one attached-exec implementation rather than two. Shared auth token: `claude setup-token` is run in a container, the token is stored in the OS keychain and injected as CLAUDE_CODE_OAUTH_TOKEN into Anthropic-backend projects. Contrary to the initial design note, setup-token uses an Anthropic-hosted redirect and blocks on a stdin paste prompt rather than a loopback callback, so a stdin command is required for the flow to complete. The token is never logged, never returned to the frontend, and is redacted from the streamed output with a stateful matcher that withholds any tail that could still grow into a secret. Change detection uses a random rotation id rather than a hash, since a hash in a docker-inspect readable label would be an offline verification oracle. Frontend 33 -> 51 tests; Rust 34 tests. Both builds clean. Co-Authored-By: Claude Opus 5 (1M context) --- ROADMAP.md | 45 +- app/src-tauri/src/auth_bridge/mod.rs | 525 +++++++ app/src-tauri/src/auth_bridge/proc_net.rs | 302 ++++ app/src-tauri/src/auth_bridge/tunnel.rs | 245 ++++ .../src/commands/auth_bridge_commands.rs | 67 + .../src/commands/auth_token_commands.rs | 899 ++++++++++++ app/src-tauri/src/commands/mod.rs | 2 + .../src/commands/project_commands.rs | 69 +- app/src-tauri/src/docker/container.rs | 131 +- app/src-tauri/src/docker/exec.rs | 172 ++- app/src-tauri/src/lib.rs | 15 + app/src-tauri/src/models/project.rs | 27 + app/src-tauri/src/storage/projects_store.rs | 14 + app/src-tauri/src/storage/secure.rs | 113 ++ app/src/App.tsx | 140 +- app/src/components/DockerInstallDialog.tsx | 250 ++-- app/src/components/layout/HelpDialog.tsx | 69 +- app/src/components/layout/MainTabs.tsx | 328 +++++ app/src/components/layout/Sidebar.test.tsx | 6 + app/src/components/layout/Sidebar.tsx | 4 +- app/src/components/layout/StatusBar.tsx | 2 +- app/src/components/layout/TopBar.tsx | 71 +- .../components/projects/AddProjectDialog.tsx | 168 +-- .../projects/ClaudeCodeSettingsEditor.tsx | 151 ++ .../projects/ClaudeCodeSettingsModal.tsx | 206 +-- .../projects/ClaudeInstructionsEditor.tsx | 46 + .../projects/ClaudeInstructionsModal.tsx | 99 +- .../projects/ConfirmRemoveModal.tsx | 63 +- .../projects/ContainerProgressModal.tsx | 109 -- app/src/components/projects/EnvVarsEditor.tsx | 93 ++ app/src/components/projects/EnvVarsModal.tsx | 134 +- .../components/projects/FileManagerModal.tsx | 197 --- .../projects/PermissionModeControl.test.tsx | 140 ++ .../projects/PermissionModeControl.tsx | 116 ++ .../projects/PortMappingsEditor.tsx | 128 ++ .../components/projects/PortMappingsModal.tsx | 157 --- .../components/projects/ProjectCard.test.tsx | 129 -- app/src/components/projects/ProjectCard.tsx | 1212 ----------------- app/src/components/projects/ProjectList.tsx | 27 +- .../components/projects/ProjectRow.test.tsx | 146 ++ app/src/components/projects/ProjectRow.tsx | 140 ++ .../projects/home/AutomationTab.tsx | 267 ++++ .../projects/home/CapabilityTiles.tsx | 166 +++ .../components/projects/home/ConfigTab.tsx | 57 + app/src/components/projects/home/FilesTab.tsx | 162 +++ .../components/projects/home/OverviewTab.tsx | 218 +++ .../components/projects/home/ProjectHome.tsx | 228 ++++ .../components/projects/home/SessionsTab.tsx | 102 ++ .../projects/home/config/AccessSection.tsx | 150 ++ .../projects/home/config/ModelSection.tsx | 387 ++++++ .../projects/home/config/RuntimeSection.tsx | 103 ++ .../projects/home/config/WorkspaceSection.tsx | 145 ++ app/src/components/projects/home/format.ts | 39 + app/src/components/settings/AwsSettings.tsx | 8 +- .../components/settings/DockerSettings.tsx | 8 +- .../components/settings/ImageUpdateDialog.tsx | 97 +- .../settings/MicrophoneSettings.tsx | 4 +- .../components/settings/OllamaSettings.tsx | 4 +- .../settings/OpenAiCompatibleSettings.tsx | 4 +- app/src/components/settings/SettingsPanel.tsx | 30 +- app/src/components/settings/SttSettings.tsx | 26 +- app/src/components/settings/UpdateDialog.tsx | 141 +- .../settings/WebTerminalSettings.tsx | 18 +- app/src/components/terminal/TerminalTabs.tsx | 201 --- app/src/components/ui/Button.tsx | 50 + app/src/components/ui/Field.tsx | 89 ++ app/src/components/ui/Modal.test.tsx | 118 ++ app/src/components/ui/Modal.tsx | 183 +++ app/src/components/ui/OverflowMenu.tsx | 82 ++ app/src/components/ui/SaveIndicator.tsx | 28 + app/src/components/ui/SegmentedControl.tsx | 92 ++ app/src/components/ui/StatusIndicator.tsx | 105 ++ app/src/components/ui/ToastHost.tsx | 105 ++ app/src/components/ui/Toggle.tsx | 45 + app/src/components/ui/Tooltip.tsx | 5 +- app/src/hooks/useContainerProgress.ts | 25 + app/src/hooks/useKeyboardShortcuts.ts | 92 ++ app/src/hooks/useProjectActions.ts | 150 ++ app/src/hooks/useSaveState.ts | 50 + app/src/index.css | 46 +- app/src/lib/tauri-commands.ts | 29 +- app/src/lib/types.ts | 56 + app/src/store/appState.ts | 234 +++- 83 files changed, 8042 insertions(+), 3064 deletions(-) create mode 100644 app/src-tauri/src/auth_bridge/mod.rs create mode 100644 app/src-tauri/src/auth_bridge/proc_net.rs create mode 100644 app/src-tauri/src/auth_bridge/tunnel.rs create mode 100644 app/src-tauri/src/commands/auth_bridge_commands.rs create mode 100644 app/src-tauri/src/commands/auth_token_commands.rs create mode 100644 app/src/components/layout/MainTabs.tsx create mode 100644 app/src/components/projects/ClaudeCodeSettingsEditor.tsx create mode 100644 app/src/components/projects/ClaudeInstructionsEditor.tsx delete mode 100644 app/src/components/projects/ContainerProgressModal.tsx create mode 100644 app/src/components/projects/EnvVarsEditor.tsx delete mode 100644 app/src/components/projects/FileManagerModal.tsx create mode 100644 app/src/components/projects/PermissionModeControl.test.tsx create mode 100644 app/src/components/projects/PermissionModeControl.tsx create mode 100644 app/src/components/projects/PortMappingsEditor.tsx delete mode 100644 app/src/components/projects/PortMappingsModal.tsx delete mode 100644 app/src/components/projects/ProjectCard.test.tsx delete mode 100644 app/src/components/projects/ProjectCard.tsx create mode 100644 app/src/components/projects/ProjectRow.test.tsx create mode 100644 app/src/components/projects/ProjectRow.tsx create mode 100644 app/src/components/projects/home/AutomationTab.tsx create mode 100644 app/src/components/projects/home/CapabilityTiles.tsx create mode 100644 app/src/components/projects/home/ConfigTab.tsx create mode 100644 app/src/components/projects/home/FilesTab.tsx create mode 100644 app/src/components/projects/home/OverviewTab.tsx create mode 100644 app/src/components/projects/home/ProjectHome.tsx create mode 100644 app/src/components/projects/home/SessionsTab.tsx create mode 100644 app/src/components/projects/home/config/AccessSection.tsx create mode 100644 app/src/components/projects/home/config/ModelSection.tsx create mode 100644 app/src/components/projects/home/config/RuntimeSection.tsx create mode 100644 app/src/components/projects/home/config/WorkspaceSection.tsx create mode 100644 app/src/components/projects/home/format.ts delete mode 100644 app/src/components/terminal/TerminalTabs.tsx create mode 100644 app/src/components/ui/Button.tsx create mode 100644 app/src/components/ui/Field.tsx create mode 100644 app/src/components/ui/Modal.test.tsx create mode 100644 app/src/components/ui/Modal.tsx create mode 100644 app/src/components/ui/OverflowMenu.tsx create mode 100644 app/src/components/ui/SaveIndicator.tsx create mode 100644 app/src/components/ui/SegmentedControl.tsx create mode 100644 app/src/components/ui/StatusIndicator.tsx create mode 100644 app/src/components/ui/ToastHost.tsx create mode 100644 app/src/components/ui/Toggle.tsx create mode 100644 app/src/hooks/useContainerProgress.ts create mode 100644 app/src/hooks/useKeyboardShortcuts.ts create mode 100644 app/src/hooks/useProjectActions.ts create mode 100644 app/src/hooks/useSaveState.ts diff --git a/ROADMAP.md b/ROADMAP.md index 70a1f47..46d012a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -101,9 +101,16 @@ Two viable options: ### Option A — long-lived token injection (simple) `claude setup-token` (verified present on 2.1.226: *"Set up a long-lived authentication -token (requires Claude subscription)"*) returns a ~1-year OAuth token. Triple-C runs it -once on the host, stores the token in the OS keychain via the existing `secure.rs`, and -injects `CLAUDE_CODE_OAUTH_TOKEN` into every container that uses the Anthropic backend. +token (requires Claude subscription)"*) returns a ~1-year OAuth token. Triple-C runs it in +a running container, stores the token in the OS keychain via the existing `secure.rs`, and +injects `CLAUDE_CODE_OAUTH_TOKEN` into every container on the Anthropic backend. + +**Correction to an earlier assumption in this document.** `setup-token` does *not* start a +loopback callback listener, so it does not need the Auth Bridge. Verified by running it +under a pty: its `redirect_uri` is Anthropic-hosted +(`https://platform.claude.com/oauth/code/callback`), the user copies a code off that page, +and the CLI blocks at a `Paste code here if prompted >` prompt on **stdin**. A stdin path +is therefore mandatory — the flow cannot complete without one. - No routing, no ports, no proxy. - One auth event covers every project. @@ -111,6 +118,12 @@ injects `CLAUDE_CODE_OAUTH_TOKEN` into every container that uses the Anthropic b - Limits: token is subscription-scoped and expires annually; per the docs a `setup-token` token cannot drive Remote Control sessions or claude.ai connector fetches. +Change detection uses a **random rotation id** in the `triple-c.claude-token-version` +label, not a hash of the token. Labels are readable by anything that can run +`docker inspect`, so a hash would be an offline verification oracle — given a candidate +token you could confirm it. A presence boolean would instead miss rotations and silently +leave containers on a stale token. + ### Option B — the Auth Bridge (general loopback-callback bridge) Option A only solves Claude Code. The same problem affects every CLI that authenticates by @@ -187,4 +200,28 @@ the config volume by the entrypoint. Generalizes the pattern the MCP tab was rea 3. **Stale model placeholders** — see "Not yet scheduled" above. 4. **Silent save failures.** Project config saves on blur; failures go only to - `console.error`. No user-visible indication. Addressed in Phase 3. + `console.error`. No user-visible indication. Fixed in Phase 3 — `useProjectSave` + now renders a Saved / Saving / Save failed indicator and raises a toast. + +--- + +## Known gaps left by Phase 2–3 + +- **`open_terminal_session` takes no command argument.** "Resume session" and + "Manage in terminal" therefore open a bash tab and *type* the command after a + fixed prompt delay. It works, but it is timing-dependent and will misfire on a + slow container start. The fix is a `command: Option` parameter on the + Tauri command so the exec launches the process directly. +- **Uptime is observed, not reported.** `get_container_info` returns a status enum + with no start time, so Project Home records "running since" when the app *sees* + the transition. A container already running when the app launches shows + `● Running` with no elapsed time. Surfacing Docker's `State.StartedAt` would fix it. +- **`lucide-react` was not adopted** (DESIGN-REVIEW Tier-1 #9) — no package-registry + access in the build environment used for this cycle. The existing inline SVGs and + text glyphs remain. +- **The tab strip stayed in the TopBar** rather than moving onto the terminal panel's + top edge. DESIGN-REVIEW §A6 asks for the move but its own §B2 layout diagram puts + the tabs in the TopBar; the diagram won. Worth revisiting. +- **`Ctrl+Shift+W`, not `Ctrl+W`, closes a tab.** Plain `Ctrl+W` is readline's + `kill-word`, used constantly inside the terminal this app is built around; + intercepting it globally would break word-erase in every shell. diff --git a/app/src-tauri/src/auth_bridge/mod.rs b/app/src-tauri/src/auth_bridge/mod.rs new file mode 100644 index 0000000..568e925 --- /dev/null +++ b/app/src-tauri/src/auth_bridge/mod.rs @@ -0,0 +1,525 @@ +//! Auth Bridge — lets browser-based OAuth logins run by CLIs *inside* a +//! container complete against the browser on the *host*. +//! +//! ## The problem +//! +//! `claude login`, Concourse's `fly login`, `aws sso login` and friends all use +//! the same pattern: start a throwaway HTTP listener on a random loopback port, +//! then open a browser at a provider URL whose redirect points back to +//! `http://localhost:/callback`. Run inside a container, the listener +//! is on the *container's* loopback, the browser is on the *host's*, and the +//! callback goes nowhere — the login just hangs. The ports are ephemeral and not +//! configurable, so nothing can be pre-published at container creation time. +//! +//! ## The mechanism +//! +//! While the bridge is enabled for a running project, poll the container every +//! [`POLL_INTERVAL`] for loopback TCP listeners (see [`proc_net`]). For each one +//! that appears, bind the *same* port on the host's loopback and proxy each +//! accepted connection into the container over `docker exec … socat` (see +//! [`tunnel`]). When the in-container listener goes away, drop the host +//! listener. The host and container therefore agree on the port number, which is +//! the whole trick: the redirect URL the provider was given resolves correctly +//! on both sides. +//! +//! ## Lifecycle and teardown +//! +//! One poller task per project. It is the only thing that owns +//! [`PortForward`]s, and it always tears them down on its way out, so every way +//! the bridge can end funnels through the same code: +//! +//! | Trigger | Path | +//! |---|---| +//! | Bridge disabled | `set_auth_bridge_enabled(false)` → [`AuthBridgeManager::stop`] | +//! | Container stopped via UI | `stop_project_container` → [`AuthBridgeManager::stop`] | +//! | Container stopped/died another way | poller's own `is_container_running` check → loop exits | +//! | Project deleted | `remove_project` → [`AuthBridgeManager::stop`]; also the poller's `store.get()` check | +//! | Container rebuilt | `rebuild_project_container` → stop, then start re-arms it | +//! | App exit | window `CloseRequested` → [`AuthBridgeManager::stop_all`] | +//! +//! [`AuthBridgeManager::stop`] awaits the poller, so host ports are provably +//! released before it returns. As a backstop for any path that skips all of the +//! above (a panicking poller, an aborted task), `PortForward`'s [`Drop`] aborts +//! the accept loop, which drops the socket. + +pub mod proc_net; +pub mod tunnel; + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use serde::Serialize; +use tauri::{AppHandle, Emitter}; +use tokio::sync::{watch, Mutex}; +use tokio::task::JoinHandle; + +use crate::docker::container::is_container_running; +use crate::docker::exec::exec_oneshot; +use crate::storage::projects_store::ProjectsStore; + +use proc_net::PortFamily; +use tunnel::PortForward; + +/// How often the container is polled for new/vanished loopback listeners. +/// Short enough that a login redirect isn't left waiting, cheap enough to run +/// continuously (one `cat` of two procfs files per tick). +const POLL_INTERVAL: Duration = Duration::from_secs(2); + +/// Emitted whenever the bridged-port set (or the conflict set) changes. +/// Payload: `{ project_id, status: AuthBridgeStatus }`. +const AUTH_BRIDGE_EVENT: &str = "auth-bridge-changed"; + +// ───────────────────────────────────────────────────────────────────────────── +// IPC response models +// ───────────────────────────────────────────────────────────────────────────── + +/// A port currently bound on the host loopback and forwarded into the container. +#[derive(Debug, Clone, Serialize)] +pub struct BridgedPort { + pub port: u16, + pub family: PortFamily, + /// RFC 3339 timestamp of when the host listener was bound. + pub bridged_at: String, +} + +/// A loopback listener that was discovered but could not be bridged. +#[derive(Debug, Clone, Serialize)] +pub struct PortConflict { + pub port: u16, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AuthBridgeStatus { + pub enabled: bool, + pub active_ports: Vec, + pub conflicts: Vec, +} + +impl AuthBridgeStatus { + fn disabled() -> Self { + Self { + enabled: false, + active_ports: Vec::new(), + conflicts: Vec::new(), + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Manager +// ───────────────────────────────────────────────────────────────────────────── + +/// Everything the poller owns for one project. Live ports and conflicts sit +/// behind an `Arc>` so `get_auth_bridge_status` can read them without +/// disturbing the poller. +#[derive(Default)] +struct BridgeState { + forwards: BTreeMap, + conflicts: BTreeMap, +} + +impl BridgeState { + fn snapshot(&self, enabled: bool) -> AuthBridgeStatus { + AuthBridgeStatus { + enabled, + active_ports: self + .forwards + .values() + .map(|f| BridgedPort { + port: f.port, + family: f.family, + bridged_at: f.bridged_at.clone(), + }) + .collect(), + conflicts: self + .conflicts + .iter() + .map(|(port, reason)| PortConflict { + port: *port, + reason: reason.clone(), + }) + .collect(), + } + } +} + +struct ProjectBridge { + /// Distinguishes this poller from a later one for the same project, so a + /// poller that exits late can't remove its replacement's map entry. + epoch: u64, + cancel: watch::Sender, + state: Arc>, + poller: JoinHandle<()>, +} + +type BridgeMap = Arc>>; + +#[derive(Default)] +pub struct AuthBridgeManager { + bridges: BridgeMap, + next_epoch: AtomicU64, +} + +impl AuthBridgeManager { + pub fn new() -> Self { + Self::default() + } + + /// Start polling for `project_id`. Idempotent: a call while a live poller + /// already exists for the project is a no-op. + pub async fn start( + &self, + project_id: String, + container_id: String, + app: AppHandle, + store: Arc, + ) { + let mut map = self.bridges.lock().await; + + // A finished poller has already torn its ports down, so its entry is + // just a husk and can be replaced. A live one means we're already on. + if map + .get(&project_id) + .is_some_and(|b| !b.poller.is_finished()) + { + return; + } + + let epoch = self.next_epoch.fetch_add(1, Ordering::Relaxed); + let state = Arc::new(Mutex::new(BridgeState::default())); + let (cancel_tx, cancel_rx) = watch::channel(false); + + log::info!( + "Auth bridge: starting for project {} (container {})", + project_id, + &container_id[..container_id.len().min(12)] + ); + + let poller = tokio::spawn(poll_loop( + project_id.clone(), + container_id, + epoch, + app, + store, + state.clone(), + self.bridges.clone(), + cancel_rx, + )); + + map.insert( + project_id, + ProjectBridge { + epoch, + cancel: cancel_tx, + state, + poller, + }, + ); + } + + /// Stop the bridge for one project and wait until every host port it held + /// has been released. + pub async fn stop(&self, project_id: &str) { + // Remove under the lock, then release it before awaiting: the poller + // takes the same lock to deregister itself on exit. + let bridge = self.bridges.lock().await.remove(project_id); + if let Some(bridge) = bridge { + let _ = bridge.cancel.send(true); + let _ = bridge.poller.await; + log::info!("Auth bridge: stopped for project {}", project_id); + } + } + + /// Stop every bridge. Used on app exit. + pub async fn stop_all(&self) { + let bridges: Vec<(String, ProjectBridge)> = + self.bridges.lock().await.drain().collect(); + for (project_id, bridge) in bridges { + let _ = bridge.cancel.send(true); + let _ = bridge.poller.await; + log::info!("Auth bridge: stopped for project {}", project_id); + } + } + + /// Current status. `enabled` comes from the persisted project record, so a + /// project whose bridge is on but whose container is stopped still reports + /// `enabled: true` with no active ports. + pub async fn status(&self, project_id: &str, enabled: bool) -> AuthBridgeStatus { + let map = self.bridges.lock().await; + match map.get(project_id) { + Some(bridge) => bridge.state.lock().await.snapshot(enabled), + None => AuthBridgeStatus { + enabled, + ..AuthBridgeStatus::disabled() + }, + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Poller +// ───────────────────────────────────────────────────────────────────────────── + +#[allow(clippy::too_many_arguments)] +async fn poll_loop( + project_id: String, + container_id: String, + epoch: u64, + app: AppHandle, + store: Arc, + state: Arc>, + bridges: BridgeMap, + mut cancel: watch::Receiver, +) { + let mut exec_failures: u32 = 0; + + loop { + // Stop conditions checked every tick, so the bridge winds itself down + // even when nothing calls `stop()` (container died, project deleted + // out from under us, flag flipped off by another path). + let project = match store.get(&project_id) { + Some(p) => p, + None => { + log::info!("Auth bridge: project {} is gone — tearing down", project_id); + break; + } + }; + if !project.auth_bridge_enabled { + log::info!("Auth bridge: disabled for project {} — tearing down", project_id); + break; + } + if !is_container_running(&container_id).await.unwrap_or(false) { + log::info!( + "Auth bridge: container for project {} is no longer running — tearing down", + project_id + ); + break; + } + + // One exec per tick reads both procfs files. + let cmd = vec![ + "cat".to_string(), + "/proc/net/tcp".to_string(), + "/proc/net/tcp6".to_string(), + ]; + // Cancellation races the exec, not just the sleep, so disabling the + // bridge or stopping the container doesn't wait out an in-flight poll. + let discovery = tokio::select! { + _ = cancel.changed() => break, + res = exec_oneshot(&container_id, cmd) => res, + }; + + match discovery { + Ok(text) => { + exec_failures = 0; + let discovered = proc_net::parse_loopback_listeners(&text); + let skip = skipped_ports(&project); + if reconcile(&container_id, &discovered, &skip, &state).await { + emit_status(&app, &project_id, &state, true).await; + } + } + Err(e) => { + exec_failures += 1; + // Transient failures happen (container restarting, engine busy); + // only complain once per streak. + if exec_failures == 1 { + log::warn!( + "Auth bridge: failed to read /proc/net/tcp in container for project {}: {}", + project_id, + e + ); + } + } + } + + tokio::select! { + _ = cancel.changed() => break, + _ = tokio::time::sleep(POLL_INTERVAL) => {} + } + } + + teardown(&project_id, &state).await; + emit_status( + &app, + &project_id, + &state, + store + .get(&project_id) + .is_some_and(|p| p.auth_bridge_enabled), + ) + .await; + + // Deregister, unless a newer poller has already taken this project's slot. + let mut map = bridges.lock().await; + if map.get(&project_id).is_some_and(|b| b.epoch == epoch) { + map.remove(&project_id); + } +} + +/// Ports Docker already handles for this project. A container port that is +/// explicitly published has a host-side path already, and the mapping's host +/// port is a binding we must not fight over. +fn skipped_ports(project: &crate::models::Project) -> HashSet { + project + .port_mappings + .iter() + .flat_map(|m| [m.container_port, m.host_port]) + .collect() +} + +/// Bring the set of host listeners in line with what the container is currently +/// listening on. Returns whether anything the UI cares about changed. +async fn reconcile( + container_id: &str, + discovered: &BTreeMap, + skip: &HashSet, + state: &Arc>, +) -> bool { + let mut changed = false; + let mut st = state.lock().await; + + // Drop host listeners whose container-side counterpart vanished, became + // covered by an explicit port mapping, or changed address family (a family + // change alters the socat target, so it has to be rebound below). + let stale: Vec = st + .forwards + .iter() + .filter(|(port, forward)| match discovered.get(port) { + None => true, + Some(_) if skip.contains(port) => true, + Some(family) => *family != forward.family, + }) + .map(|(port, _)| *port) + .collect(); + for port in stale { + if let Some(mut forward) = st.forwards.remove(&port) { + forward.shutdown().await; + log::info!("Auth bridge: released host port {}", port); + changed = true; + } + } + + // Forget conflicts for ports that are no longer relevant. + let before = st.conflicts.len(); + st.conflicts + .retain(|port, _| discovered.contains_key(port) && !skip.contains(port)); + changed |= st.conflicts.len() != before; + + for (&port, &family) in discovered { + if skip.contains(&port) || st.forwards.contains_key(&port) { + continue; + } + match PortForward::bind(container_id.to_string(), port, family).await { + Ok(forward) => { + if st.conflicts.remove(&port).is_some() { + log::info!("Auth bridge: host port {} became available", port); + } + log::info!( + "Auth bridge: bridging 127.0.0.1:{} → container {} ({:?})", + port, + family.socat_target(port), + family + ); + st.forwards.insert(port, forward); + changed = true; + } + Err(e) => { + // Conflict policy: never fight for a port. Something else on the + // host owns it — another project's bridge, or an unrelated + // process. Skip it, record why so the UI can say so, and retry + // on later ticks in case the owner releases it. Warn only on + // the transition so a long-lived conflict doesn't spam the log. + let reason = format!( + "Host port {} is already in use ({}); not bridged.", + port, e + ); + if st.conflicts.get(&port) != Some(&reason) { + log::warn!("Auth bridge: {}", reason); + st.conflicts.insert(port, reason); + changed = true; + } + } + } + } + + changed +} + +/// Release every host port held for this project. Awaits each shutdown, so on +/// return nothing is bound. +async fn teardown(project_id: &str, state: &Arc>) { + let mut st = state.lock().await; + let forwards = std::mem::take(&mut st.forwards); + st.conflicts.clear(); + let count = forwards.len(); + for (_, mut forward) in forwards { + forward.shutdown().await; + } + if count > 0 { + log::info!( + "Auth bridge: released {} host port(s) for project {}", + count, + project_id + ); + } +} + +async fn emit_status( + app: &AppHandle, + project_id: &str, + state: &Arc>, + enabled: bool, +) { + let status = state.lock().await.snapshot(enabled); + let _ = app.emit( + AUTH_BRIDGE_EVENT, + serde_json::json!({ + "project_id": project_id, + "status": status, + }), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{PortMapping, Project, ProjectPath}; + + fn project_with_mappings(mappings: Vec<(u16, u16)>) -> Project { + let mut p = Project::new( + "test".to_string(), + vec![ProjectPath { + host_path: "/tmp".to_string(), + mount_name: "tmp".to_string(), + }], + ); + p.port_mappings = mappings + .into_iter() + .map(|(host_port, container_port)| PortMapping { + host_port, + container_port, + protocol: "tcp".to_string(), + }) + .collect(); + p + } + + #[test] + fn ports_already_published_by_docker_are_skipped() { + let skip = skipped_ports(&project_with_mappings(vec![(3000, 3000), (8081, 8080)])); + assert!(skip.contains(&3000)); + // Both ends of an asymmetric mapping are off limits: the container port + // is already reachable, and the host port is Docker's binding. + assert!(skip.contains(&8080)); + assert!(skip.contains(&8081)); + assert!(!skip.contains(&34567)); + } + + #[test] + fn no_mappings_means_nothing_is_skipped() { + assert!(skipped_ports(&project_with_mappings(vec![])).is_empty()); + } +} diff --git a/app/src-tauri/src/auth_bridge/proc_net.rs b/app/src-tauri/src/auth_bridge/proc_net.rs new file mode 100644 index 0000000..761a001 --- /dev/null +++ b/app/src-tauri/src/auth_bridge/proc_net.rs @@ -0,0 +1,302 @@ +//! Discovery of loopback TCP listeners by parsing `/proc/net/tcp` and +//! `/proc/net/tcp6` from inside the container. +//! +//! ## Why /proc and not `ss` +//! +//! The container image (`container/Dockerfile`) ships neither `iproute2` (`ss`) +//! nor `net-tools` (`netstat`) nor `lsof`. `/proc/net/tcp{,6}` is part of procfs +//! and needs no package at all, so discovery works in the stock image and in any +//! snapshot derived from it. +//! +//! ## Wire format +//! +//! Both files are fixed-column text with a header line: +//! +//! ```text +//! sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode +//! 0: 0100007F:8707 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 27764798 1 ... +//! ``` +//! +//! Only two columns matter: `local_address` (index 1) and `st` (index 3). +//! `st == 0A` is `TCP_LISTEN`; every other state is a connection, not a listener. +//! +//! ## Hex and endianness +//! +//! `local_address` is `
:`, both hex, but they are *not* encoded +//! the same way: +//! +//! * The **port** is a plain big-endian `%04X` — `8707` is 34567. +//! * The **address** is printed as one `%08X` per 32-bit word *in host byte +//! order*, which is little-endian on every platform this app targets. So each +//! 8-hex-digit group must be parsed as a `u32` and then expanded with +//! [`u32::to_le_bytes`] to recover the address bytes in network order: +//! `0100007F` → `0x0100007F` → `[7F, 00, 00, 01]` → `127.0.0.1`. +//! +//! IPv4 rows have one such group (8 hex digits); IPv6 rows have four (32 hex +//! digits), each converted independently, in order, to fill the 16 address +//! bytes. `::1` is therefore `00000000000000000000000001000000`, and the +//! IPv4-mapped `::ffff:127.0.0.1` is `0000000000000000FFFF00000100007F`. +//! +//! ## What counts as loopback +//! +//! Only `127.0.0.0/8` and `::1` (plus IPv4-mapped loopback, reported as v4). +//! A `0.0.0.0` or `::` listener is a service deliberately published to the +//! outside world — that is the port-mappings feature's job, not the auth +//! bridge's — so those rows are dropped. + +use std::collections::BTreeMap; +use std::net::{Ipv4Addr, Ipv6Addr}; + +use serde::{Deserialize, Serialize}; + +/// The `st` column value for `TCP_LISTEN`. +const TCP_LISTEN: &str = "0A"; + +/// Which loopback address family (or families) a container-side listener was +/// found on. Determines the `socat` target address used to reach it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PortFamily { + /// Only `127.0.0.0/8`. + V4, + /// Only `::1`. Common in practice: Node resolves `localhost` to IPv6 first + /// on Linux, so `claude login` frequently binds `::1` and nothing else + /// (anthropics/claude-code#44844). + V6, + /// Both — reachable either way; we use IPv4. + Dual, +} + +impl PortFamily { + fn merge(self, other: PortFamily) -> PortFamily { + if self == other { + self + } else { + PortFamily::Dual + } + } + + /// The `socat` address that reaches this listener from inside the container. + /// A `::1`-only listener genuinely cannot be reached via `127.0.0.1` + /// (verified: connect gets ECONNREFUSED), hence the split. + pub fn socat_target(&self, port: u16) -> String { + match self { + PortFamily::V4 | PortFamily::Dual => format!("TCP:127.0.0.1:{}", port), + PortFamily::V6 => format!("TCP6:[::1]:{}", port), + } + } +} + +/// One parsed LISTEN row that survived the loopback filter. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct LoopbackListener { + pub port: u16, + pub family: PortFamily, +} + +/// Parse the concatenated contents of `/proc/net/tcp` and `/proc/net/tcp6` into +/// the set of loopback ports being listened on, keyed by port with the families +/// merged (a port bound on both `127.0.0.1` and `::1` yields +/// [`PortFamily::Dual`]). +/// +/// Unparseable lines — the two header lines, `cat`'s "No such file" complaint +/// when IPv6 is disabled, anything else that ends up interleaved in the exec's +/// combined output — are silently ignored rather than failing the whole poll. +pub fn parse_loopback_listeners(text: &str) -> BTreeMap { + let mut ports: BTreeMap = BTreeMap::new(); + for listener in parse_listener_rows(text) { + ports + .entry(listener.port) + .and_modify(|f| *f = f.merge(listener.family)) + .or_insert(listener.family); + } + ports +} + +/// Row-level parse, before per-port family merging. Split out so tests can +/// assert on the individual rows. +pub fn parse_listener_rows(text: &str) -> Vec { + text.lines().filter_map(parse_listener_row).collect() +} + +fn parse_listener_row(line: &str) -> Option { + let mut fields = line.split_whitespace(); + let _sl = fields.next()?; + let local_address = fields.next()?; + let _rem_address = fields.next()?; + let state = fields.next()?; + + if state != TCP_LISTEN { + return None; + } + + let (addr_hex, port_hex) = local_address.split_once(':')?; + // The port is a straightforward big-endian hex u16 — no byte swapping. + let port = u16::from_str_radix(port_hex, 16).ok()?; + if port == 0 { + return None; + } + + let family = match addr_hex.len() { + 8 => { + let addr = Ipv4Addr::from(parse_le_word(addr_hex)?); + addr.is_loopback().then_some(PortFamily::V4) + } + 32 => { + let mut octets = [0u8; 16]; + for (i, group) in addr_hex.as_bytes().chunks(8).enumerate() { + let group = std::str::from_utf8(group).ok()?; + octets[i * 4..i * 4 + 4].copy_from_slice(&parse_le_word(group)?); + } + let addr = Ipv6Addr::from(octets); + // An IPv4-mapped row describes a v4 socket, so it is reachable at + // 127.0.0.1 and must be classified as v4, not v6. + match addr.to_ipv4_mapped() { + Some(v4) => v4.is_loopback().then_some(PortFamily::V4), + None => addr.is_loopback().then_some(PortFamily::V6), + } + } + _ => None, + }?; + + Some(LoopbackListener { port, family }) +} + +/// Parse one `%08X` procfs address word into its four address bytes in network +/// order. The kernel prints the word in host byte order, so the recovered bytes +/// are the little-endian expansion of the parsed integer. +fn parse_le_word(hex: &str) -> Option<[u8; 4]> { + Some(u32::from_str_radix(hex, 16).ok()?.to_le_bytes()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Verbatim `cat /proc/net/tcp` from a running `triple-c:latest` container + /// with three listeners deliberately started: + /// * `socat TCP4-LISTEN:34567,bind=127.0.0.1` → row 0 (`0100007F:8707`) + /// * `socat TCP4-LISTEN:34569,bind=0.0.0.0` → row 1 (`00000000:8709`) + /// * `node ... .listen(34568, "::1")` → appears in TCP6 only + const REAL_PROC_NET_TCP: &str = concat!( + " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode \n", + " 0: 0100007F:8707 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 27764798 1 0000000000000000 100 0 0 10 0 \n", + " 1: 00000000:8709 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 27758875 1 0000000000000000 100 0 0 10 0 \n", + ); + + /// Verbatim `cat /proc/net/tcp6` from the same container. The single row is + /// the Node listener bound to `::1` only — the case that motivates the + /// TCP6 socat target. + const REAL_PROC_NET_TCP6: &str = concat!( + " sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n", + " 0: 00000000000000000000000001000000:8708 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 27747129 1 0000000000000000 100 0 0 10 0\n", + ); + + fn both_files() -> String { + format!("{}{}", REAL_PROC_NET_TCP, REAL_PROC_NET_TCP6) + } + + #[test] + fn parses_ipv4_loopback_row_with_little_endian_address() { + let rows = parse_listener_rows(REAL_PROC_NET_TCP); + // 0100007F → 127.0.0.1 (kept), 00000000 → 0.0.0.0 (dropped). + assert_eq!( + rows, + vec![LoopbackListener { + port: 0x8707, + family: PortFamily::V4 + }] + ); + assert_eq!(rows[0].port, 34567); + } + + #[test] + fn parses_ipv6_loopback_row() { + let rows = parse_listener_rows(REAL_PROC_NET_TCP6); + assert_eq!( + rows, + vec![LoopbackListener { + port: 34568, + family: PortFamily::V6 + }] + ); + } + + #[test] + fn ignores_wildcard_bind_addresses() { + // 0.0.0.0:34569 is in the fixture and must never be bridged — that is + // the port-mappings feature's territory. + let ports = parse_loopback_listeners(&both_files()); + assert!(!ports.contains_key(&34569)); + + // Same for the IPv6 wildcard and a non-loopback unicast address. + let wildcard_v6 = " 0: 00000000000000000000000000000000:1F90 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1 1 0 100 0 0 10 0"; + let lan_v4 = " 0: 0245A8C0:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1 1 0 100 0 0 10 0"; + assert!(parse_listener_rows(wildcard_v6).is_empty()); + assert!(parse_listener_rows(lan_v4).is_empty()); + } + + #[test] + fn parses_both_files_concatenated_as_one_exec_output() { + let ports = parse_loopback_listeners(&both_files()); + assert_eq!(ports.len(), 2); + assert_eq!(ports.get(&34567), Some(&PortFamily::V4)); + assert_eq!(ports.get(&34568), Some(&PortFamily::V6)); + } + + #[test] + fn merges_families_for_a_dual_stack_port() { + let dual = format!( + "{} 1: 00000000000000000000000001000000:8707 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 2 1 0 100 0 0 10 0\n", + both_files() + ); + let ports = parse_loopback_listeners(&dual); + assert_eq!(ports.get(&34567), Some(&PortFamily::Dual)); + } + + #[test] + fn ipv4_mapped_loopback_is_reported_as_v4() { + // ::ffff:127.0.0.1 — a v4 socket surfacing in /proc/net/tcp6. + let row = " 0: 0000000000000000FFFF00000100007F:8707 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1 1 0 100 0 0 10 0"; + assert_eq!( + parse_listener_rows(row), + vec![LoopbackListener { + port: 34567, + family: PortFamily::V4 + }] + ); + } + + #[test] + fn ignores_non_listen_states() { + // Same loopback address, state 01 (ESTABLISHED) instead of 0A. + let established = " 0: 0100007F:8707 0100007F:C350 01 00000000:00000000 00:00000000 00000000 0 0 1 1 0 100 0 0 10 0"; + assert!(parse_listener_rows(established).is_empty()); + } + + #[test] + fn ignores_headers_and_garbage() { + assert!(parse_listener_rows("").is_empty()); + assert!(parse_listener_rows( + "cat: /proc/net/tcp6: No such file or directory\n\n sl local_address rem_address st\n" + ) + .is_empty()); + // Truncated / malformed rows must not panic or be accepted. + assert!(parse_listener_rows(" 0: 0100007F 00000000:0000 0A").is_empty()); + assert!(parse_listener_rows(" 0: ZZZZZZZZ:8707 00000000:0000 0A x").is_empty()); + assert!(parse_listener_rows(" 0: 0100007F:0000 00000000:0000 0A x").is_empty()); + } + + #[test] + fn socat_target_matches_family() { + assert_eq!( + PortFamily::V4.socat_target(34567), + "TCP:127.0.0.1:34567" + ); + assert_eq!( + PortFamily::Dual.socat_target(34567), + "TCP:127.0.0.1:34567" + ); + assert_eq!(PortFamily::V6.socat_target(34568), "TCP6:[::1]:34568"); + } +} diff --git a/app/src-tauri/src/auth_bridge/tunnel.rs b/app/src-tauri/src/auth_bridge/tunnel.rs new file mode 100644 index 0000000..216c207 --- /dev/null +++ b/app/src-tauri/src/auth_bridge/tunnel.rs @@ -0,0 +1,245 @@ +//! Host-side loopback listener for one bridged port, and the per-connection +//! tunnel that carries its bytes into the container. +//! +//! ## Why not connect to the container's IP +//! +//! Container IPs are not routable from the host on Docker Desktop (macOS and +//! Windows run the engine in a VM), so a host→`172.17.x.x` dial cannot be the +//! transport. The Docker API is the only channel guaranteed to reach the +//! container from the host, so each accepted connection is carried by a +//! `docker exec` running `socat - TCP:127.0.0.1:`, with the exec's stdin +//! and stdout wired to the TCP socket. `socat` ships in the container image. +//! +//! The exec plumbing itself is *not* reimplemented here: it comes from +//! [`crate::docker::exec::create_attached_exec`], the same helper the +//! interactive terminal sessions are built on. + +use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; + +use bollard::container::LogOutput; +use futures_util::StreamExt; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::task::{JoinHandle, JoinSet}; + +use crate::docker::exec::{create_attached_exec, AttachedExec}; + +use super::proc_net::PortFamily; + +/// Buffer size for the host→container direction. OAuth callbacks are tiny; this +/// only needs to not be pathological. +const PUMP_BUF: usize = 16 * 1024; + +/// Aborts a task when dropped, so a cancelled parent can never leave a detached +/// child running. +struct AbortOnDrop(JoinHandle<()>); + +impl Drop for AbortOnDrop { + fn drop(&mut self) { + self.0.abort(); + } +} + +/// One host loopback port bound and proxied into the container. +/// +/// The accept loop owns the [`TcpListener`](tokio::net::TcpListener)s and the +/// [`JoinSet`] of live connection tasks, so aborting the single task handle +/// releases the port *and* tears down every connection under it. [`Drop`] does +/// that as a backstop; [`PortForward::shutdown`] does it deterministically by +/// also awaiting the aborted task, which guarantees the socket is closed before +/// the caller proceeds (important when a port is rebound right after). +pub struct PortForward { + pub port: u16, + pub family: PortFamily, + pub bridged_at: String, + task: JoinHandle<()>, +} + +impl Drop for PortForward { + fn drop(&mut self) { + self.task.abort(); + } +} + +impl PortForward { + /// Bind `port` on the host loopback and start proxying into `container_id`. + /// + /// The bind happens before the task is spawned, so an already-taken port is + /// reported to the caller as an error rather than disappearing into a + /// background task. + pub async fn bind( + container_id: String, + port: u16, + family: PortFamily, + ) -> Result { + // SECURITY BOUNDARY: the host side binds loopback ONLY — 127.0.0.1 and + // ::1, never 0.0.0.0 / ::. Everything reachable through this socket is + // an unauthenticated service inside the container that deliberately + // bound loopback because it expected to be reachable from nowhere else. + // Binding a wildcard address here would publish container internals to + // every host on the LAN. Do not "fix" a connectivity problem by + // widening these addresses. + let v4 = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, port))).await?; + + // Also take ::1 when it is available. Browsers and CLIs resolve + // `localhost` to either family, and the IPv6 answer is often tried + // first, so a v4-only host listener would miss those callbacks. This is + // best-effort: if ::1 is unavailable (no IPv6, or that half is taken) + // the v4 listener alone still works, so it is not treated as a conflict. + let v6 = match TcpListener::bind(SocketAddr::from((Ipv6Addr::LOCALHOST, port))).await { + Ok(l) => Some(l), + Err(e) => { + log::debug!( + "Auth bridge: bound 127.0.0.1:{} but not [::1]:{} ({}) — continuing with IPv4 only", + port, + port, + e + ); + None + } + }; + + let target = family.socat_target(port); + let task = tokio::spawn(accept_loop(container_id, port, target, v4, v6)); + + Ok(Self { + port, + family, + bridged_at: chrono::Utc::now().to_rfc3339(), + task, + }) + } + + /// Stop accepting, drop the host socket, and abort every in-flight + /// connection. Awaits the aborted task so the port is provably released + /// when this returns. + pub async fn shutdown(&mut self) { + self.task.abort(); + let _ = (&mut self.task).await; + } +} + +/// Accept on both loopback listeners until aborted. Dropping this future drops +/// the listeners (freeing the port) and the `JoinSet` (aborting live tunnels). +async fn accept_loop( + container_id: String, + port: u16, + target: String, + v4: TcpListener, + v6: Option, +) { + let mut conns: JoinSet<()> = JoinSet::new(); + + loop { + let accepted = tokio::select! { + r = v4.accept() => r, + r = accept_optional(v6.as_ref()) => r, + // Reap finished tunnels so the JoinSet doesn't grow without bound. + // When the set is empty `join_next()` yields None, the pattern fails + // to match, and the branch simply drops out of the select. + Some(_) = conns.join_next() => continue, + }; + + match accepted { + Ok((stream, peer)) => { + log::debug!("Auth bridge: connection from {} to bridged port {}", peer, port); + let _ = stream.set_nodelay(true); + conns.spawn(tunnel_connection( + container_id.clone(), + target.clone(), + stream, + port, + )); + } + Err(e) => { + log::warn!("Auth bridge: accept failed on port {}: {} — stopping listener", port, e); + return; + } + } + } +} + +/// `accept()` on an optional listener; never completes when there is none, so it +/// can sit in a `select!` arm unconditionally. +async fn accept_optional( + listener: Option<&TcpListener>, +) -> std::io::Result<(TcpStream, SocketAddr)> { + match listener { + Some(l) => l.accept().await, + None => std::future::pending().await, + } +} + +/// Carry one accepted host connection into the container over `socat`. +async fn tunnel_connection(container_id: String, target: String, stream: TcpStream, port: u16) { + let cmd = vec!["socat".to_string(), "-".to_string(), target.clone()]; + + let AttachedExec { + mut output, + mut input, + .. + } = match create_attached_exec(&container_id, cmd, false).await { + Ok(e) => e, + Err(e) => { + log::warn!( + "Auth bridge: failed to open tunnel exec for port {} ({}): {}", + port, + target, + e + ); + return; + } + }; + + let (mut host_rx, mut host_tx) = stream.into_split(); + + // Host → container. Runs as its own task so the container→host direction is + // never blocked behind a client that has stopped sending. Finishing this + // direction drops `input`, which closes the exec's stdin and lets socat see + // a clean EOF (a half-close, not a teardown of the whole connection). + let upstream = AbortOnDrop(tokio::spawn(async move { + let mut buf = vec![0u8; PUMP_BUF]; + loop { + match host_rx.read(&mut buf).await { + Ok(0) => break, + Ok(n) => { + if input.write_all(&buf[..n]).await.is_err() || input.flush().await.is_err() { + break; + } + } + Err(_) => break, + } + } + })); + + // Container → host. This direction is authoritative: when the exec's output + // stream ends, socat has exited and the connection is over. + while let Some(chunk) = output.next().await { + match chunk { + // Only stdout is payload. The exec is created with tty = false + // precisely so Docker demultiplexes these, keeping socat's stderr + // diagnostics out of the proxied byte stream. + Ok(LogOutput::StdOut { message }) => { + if host_tx.write_all(&message).await.is_err() { + break; + } + } + Ok(LogOutput::StdErr { message }) => { + log::debug!( + "Auth bridge: socat stderr for port {}: {}", + port, + String::from_utf8_lossy(&message).trim() + ); + } + Ok(_) => {} + Err(e) => { + log::debug!("Auth bridge: tunnel stream error on port {}: {}", port, e); + break; + } + } + } + + let _ = host_tx.shutdown().await; + // Explicit: stop reading from the host now that the container side is gone. + drop(upstream); +} diff --git a/app/src-tauri/src/commands/auth_bridge_commands.rs b/app/src-tauri/src/commands/auth_bridge_commands.rs new file mode 100644 index 0000000..dddff71 --- /dev/null +++ b/app/src-tauri/src/commands/auth_bridge_commands.rs @@ -0,0 +1,67 @@ +//! IPC surface for the auth bridge. The mechanism lives in +//! [`crate::auth_bridge`]; this file only translates between it and the +//! frontend, and keeps the persisted per-project flag in step. + +use tauri::{AppHandle, State}; + +use crate::auth_bridge::AuthBridgeStatus; +use crate::AppState; + +/// Turn the bridge on or off for a project and return the resulting status. +/// +/// Enabling starts polling immediately when the container is already running; +/// otherwise the flag is simply persisted and `start_project_container` arms the +/// bridge on the next start. This is a host-side feature, so no container +/// recreation is involved either way. +#[tauri::command] +pub async fn set_auth_bridge_enabled( + project_id: String, + enabled: bool, + app_handle: AppHandle, + state: State<'_, AppState>, +) -> Result { + state + .projects_store + .set_auth_bridge_enabled(&project_id, enabled)?; + + if enabled { + let project = state + .projects_store + .get(&project_id) + .ok_or_else(|| format!("Project {} not found", project_id))?; + if let Some(container_id) = project.container_id { + if crate::docker::container::is_container_running(&container_id) + .await + .unwrap_or(false) + { + state + .auth_bridge + .start( + project_id.clone(), + container_id, + app_handle, + state.projects_store.clone(), + ) + .await; + } + } + } else { + // Awaits the poller, so every host port is released before we return. + state.auth_bridge.stop(&project_id).await; + } + + Ok(state.auth_bridge.status(&project_id, enabled).await) +} + +#[tauri::command] +pub async fn get_auth_bridge_status( + project_id: String, + state: State<'_, AppState>, +) -> Result { + let enabled = state + .projects_store + .get(&project_id) + .map(|p| p.auth_bridge_enabled) + .unwrap_or(false); + Ok(state.auth_bridge.status(&project_id, enabled).await) +} diff --git a/app/src-tauri/src/commands/auth_token_commands.rs b/app/src-tauri/src/commands/auth_token_commands.rs new file mode 100644 index 0000000..d2a699b --- /dev/null +++ b/app/src-tauri/src/commands/auth_token_commands.rs @@ -0,0 +1,899 @@ +//! Shared Claude Code authentication — one long-lived token for every project. +//! +//! ## Why +//! +//! Without this, every container is its own authentication island: each one +//! needs `claude login`, each one opens a browser flow, each one stores its own +//! credential in its own config volume. `claude setup-token` mints a single +//! ~1-year OAuth token that Claude Code accepts via `CLAUDE_CODE_OAUTH_TOKEN`, +//! so one authentication event can cover the whole fleet. +//! +//! ## How the token is obtained +//! +//! Observed directly against Claude Code 2.1.226, because the flow is not what +//! the design assumed. `claude setup-token` prints an authorization URL whose +//! `redirect_uri` is **Anthropic-hosted** +//! (`https://platform.claude.com/oauth/code/callback`) — it does *not* start a +//! loopback listener. After signing in, the user copies a code off that page +//! and the CLI waits at a `Paste code here if prompted >` prompt on **stdin**. +//! It then prints the token. +//! +//! Two consequences: +//! +//! * The flow needs a way to deliver the pasted code, hence +//! [`submit_claude_token_code`] and the stdin channel below. Without it the +//! command would simply sit at the prompt until it timed out. +//! * [`crate::auth_bridge`] is *not* required for this particular command, +//! since there is no container-local callback to reach. It is still enabled +//! for the duration (and restored afterwards) as designed: it costs nothing +//! here and keeps the flow working if a future CLI version, or the plain +//! `claude login` path, goes back to a loopback redirect. +//! +//! ## Handling of the token itself +//! +//! The token never reaches the frontend. It is parsed out of the command's +//! output, written straight to the OS keychain, and from then on only +//! [`crate::docker::container`] reads it, to inject the env var. Everything +//! streamed to the UI passes through [`SecretRedactor`] first, and no command +//! here returns the token or accepts it as an argument. + +use std::sync::OnceLock; +use std::time::Duration; + +use futures_util::StreamExt; +use tauri::{AppHandle, Emitter, State}; +use tokio::io::AsyncWriteExt; +use tokio::sync::{mpsc, Mutex}; + +use crate::docker::container::is_container_running; +use crate::docker::exec::{create_attached_exec, wait_for_exec_exit, AttachedExec}; +use crate::storage::secure; +use crate::AppState; + +/// Milestones in the acquisition flow. Payload `{ project_id, message }`, +/// matching the `container-progress` convention. +const PROGRESS_EVENT: &str = "claude-token-progress"; + +/// Redacted output from `claude setup-token`, so the UI can show the user the +/// URL to visit. Payload `{ project_id, chunk }`. +const OUTPUT_EVENT: &str = "claude-token-output"; + +/// How long to wait for the whole flow. Generous: the user has to switch to a +/// browser, sign in, and approve. Bounded so a wedged exec can't leak a task. +const SETUP_TIMEOUT: Duration = Duration::from_secs(15 * 60); + +/// Documented shape of a `setup-token` credential. +const TOKEN_PREFIX: &str = "sk-ant-oat01-"; + +/// Minimum number of body characters after [`TOKEN_PREFIX`] for a match to be +/// believed. Real tokens run to ~90 characters; this is set well below that but +/// far above anything prose would produce, so documentation-style decoys like +/// `sk-ant-oat01-...` or `sk-ant-oat01-` are rejected. +const MIN_TOKEN_BODY: usize = 32; + +/// Redaction is deliberately broader than extraction: anything shaped like an +/// Anthropic credential is masked on its way to the UI, not just `oat01` ones. +const SECRET_MARKER: &str = "sk-ant-"; +const SECRET_PLACEHOLDER: &str = "sk-ant-"; +const MIN_SECRET_BODY: usize = 8; + +/// Cap on how much text [`SecretRedactor`] will withhold waiting for a +/// candidate secret to end. Past this, it is not a token — release it (still +/// redacted) rather than swallow the UI's output. +const MAX_HOLDBACK: usize = 4096; + +/// Cap on the retained transcript used for parsing. The token is printed at the +/// end, and a re-rendering TUI can repaint many times, so keeping the tail is +/// both sufficient and bounded. +const MAX_TRANSCRIPT: usize = 256 * 1024; + +/// Characters that can appear in the body of an Anthropic credential. +fn is_token_byte(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'-' || b == b'_' +} + +// ───────────────────────────────────────────────────────────────────────────── +// Token extraction +// ───────────────────────────────────────────────────────────────────────────── + +/// Pull the long-lived token out of `claude setup-token`'s output. +/// +/// Strict by construction, because the alternative to failing is storing +/// garbage that silently breaks every container: +/// * the value must carry the documented `sk-ant-oat01-` prefix; +/// * the prefix must not be glued to the tail of a longer word; +/// * at least [`MIN_TOKEN_BODY`] token characters must follow it. +/// +/// The **last** match wins. The command narrates before it succeeds, and a TUI +/// may repaint the same frame repeatedly, so earlier matches are either prose +/// or superseded repaints of the same value. +pub fn parse_setup_token(output: &str) -> Option { + let bytes = output.as_bytes(); + let mut found = None; + let mut cursor = 0usize; + + while let Some(offset) = output[cursor..].find(TOKEN_PREFIX) { + let start = cursor + offset; + cursor = start + TOKEN_PREFIX.len(); + + // `xsk-ant-oat01-…` is not a token, it is a substring of something else. + if start > 0 && is_token_byte(bytes[start - 1]) { + continue; + } + + let body_start = start + TOKEN_PREFIX.len(); + let mut end = body_start; + while end < bytes.len() && is_token_byte(bytes[end]) { + end += 1; + } + if end - body_start < MIN_TOKEN_BODY { + continue; + } + + found = Some(output[start..end].to_string()); + } + + found +} + +// ───────────────────────────────────────────────────────────────────────────── +// Redaction +// ───────────────────────────────────────────────────────────────────────────── + +/// Mask every *complete* credential in `text`. +fn redact_complete(text: &str) -> String { + let bytes = text.as_bytes(); + let mut out = String::with_capacity(text.len()); + let mut copied = 0usize; + let mut cursor = 0usize; + + while let Some(offset) = text[cursor..].find(SECRET_MARKER) { + let start = cursor + offset; + cursor = start + SECRET_MARKER.len(); + + if start > 0 && is_token_byte(bytes[start - 1]) { + continue; + } + let body_start = start + SECRET_MARKER.len(); + let mut end = body_start; + while end < bytes.len() && is_token_byte(bytes[end]) { + end += 1; + } + if end - body_start < MIN_SECRET_BODY { + continue; + } + + out.push_str(&text[copied..start]); + out.push_str(SECRET_PLACEHOLDER); + copied = end; + cursor = end; + } + + out.push_str(&text[copied..]); + out +} + +/// Where the tail that might still grow into a credential begins. Everything +/// before this index is safe to emit; everything from it must be withheld until +/// more input arrives. Returns `text.len()` when nothing needs withholding. +fn holdback_index(text: &str) -> usize { + let bytes = text.as_bytes(); + + // A credential already under way: the last marker with nothing but token + // characters after it. If the *last* marker fails that test, no earlier one + // can pass it either — the disqualifying character lies after them all. + if let Some(start) = text.rfind(SECRET_MARKER) { + let clean_start = start == 0 || !is_token_byte(bytes[start - 1]); + let body_all_token = bytes[start + SECRET_MARKER.len()..] + .iter() + .all(|b| is_token_byte(*b)); + if clean_start && body_all_token { + return start; + } + } + + // Otherwise: a marker truncated mid-way by the chunk boundary. + for len in (1..SECRET_MARKER.len()).rev() { + if text.len() >= len && text.is_char_boundary(text.len() - len) + && &text[text.len() - len..] == &SECRET_MARKER[..len] + { + return text.len() - len; + } + } + + text.len() +} + +/// Masks credentials out of a stream, tolerating a secret split across chunk +/// boundaries by withholding any tail that could still turn into one. +#[derive(Default)] +struct SecretRedactor { + pending: String, +} + +impl SecretRedactor { + /// Absorb `chunk` and return the text that is now safe to show. + fn push(&mut self, chunk: &str) -> String { + self.pending.push_str(chunk); + + let mut split = holdback_index(&self.pending); + if self.pending.len() - split > MAX_HOLDBACK { + split = self.pending.len(); + } + + let emit = redact_complete(&self.pending[..split]); + self.pending.drain(..split); + emit + } + + /// Release whatever is still withheld. The stream is over, so a partial + /// credential can no longer grow — but it is still redacted on the way out. + fn flush(&mut self) -> String { + let out = redact_complete(&self.pending); + self.pending.clear(); + out + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Terminal control-sequence stripping +// ───────────────────────────────────────────────────────────────────────────── + +/// Length in bytes of the UTF-8 character starting with `b`. +fn utf8_len(b: u8) -> usize { + if b < 0x80 { + 1 + } else if b >> 5 == 0b110 { + 2 + } else if b >> 4 == 0b1110 { + 3 + } else if b >> 3 == 0b11110 { + 4 + } else { + 1 + } +} + +/// CSI final bytes that move the cursor. Claude Code's TUI lays text out by +/// jumping to a column (`ESC [ 9 G`) instead of emitting spaces, so deleting +/// these outright would weld neighbouring words together — which at best +/// garbles the URL the user has to read, and at worst welds a preceding word +/// onto the token and makes the parser reject it. They become a space instead: +/// a separator can never fabricate or destroy a match. +const CURSOR_MOVE_FINALS: &[u8] = b"ABCDEFGHd"; + +/// Strip terminal control sequences from the front of `bytes`, stopping at the +/// first incomplete sequence or truncated character. Returns the clean text and +/// how many bytes were consumed. +fn strip_ansi_prefix(bytes: &[u8]) -> (String, usize) { + let mut out = String::with_capacity(bytes.len()); + let mut i = 0usize; + + while i < bytes.len() { + match bytes[i] { + 0x1b => { + if i + 1 >= bytes.len() { + return (out, i); + } + match bytes[i + 1] { + // CSI: parameter/intermediate bytes, then a final 0x40..=0x7e. + b'[' => { + let mut j = i + 2; + while j < bytes.len() && !(0x40..=0x7e).contains(&bytes[j]) { + j += 1; + } + if j >= bytes.len() { + return (out, i); + } + if CURSOR_MOVE_FINALS.contains(&bytes[j]) { + out.push(' '); + } + i = j + 1; + } + // OSC: runs until BEL or ST (ESC \). + b']' => { + let mut j = i + 2; + loop { + if j >= bytes.len() { + return (out, i); + } + if bytes[j] == 0x07 { + j += 1; + break; + } + if bytes[j] == 0x1b { + if j + 1 >= bytes.len() { + return (out, i); + } + if bytes[j + 1] == b'\\' { + j += 2; + break; + } + } + j += 1; + } + i = j; + } + // Two-byte escapes (charset selection, keypad mode, …). + _ => i += 2, + } + } + // A repaint returns to column 0. Turn that into a line break so the + // old frame's trailing text cannot be glued onto the new frame's + // leading text — which could otherwise fabricate a "token". A run of + // CRs immediately before a LF is just the pty's ONLCR translation, + // so it collapses into that single LF rather than blank lines. + b'\r' => { + let mut j = i; + while j < bytes.len() && bytes[j] == b'\r' { + j += 1; + } + if j >= bytes.len() { + return (out, i); + } + if bytes[j] != b'\n' { + out.push('\n'); + } + i = j; + } + b'\n' => { + out.push('\n'); + i += 1; + } + b'\t' => { + out.push('\t'); + i += 1; + } + 0x00..=0x1f | 0x7f => i += 1, + b => { + let len = utf8_len(b); + if i + len > bytes.len() { + return (out, i); + } + if let Ok(s) = std::str::from_utf8(&bytes[i..i + len]) { + out.push_str(s); + } + i += len; + } + } + } + + (out, i) +} + +/// Stateful wrapper around [`strip_ansi_prefix`] that carries an incomplete +/// trailing sequence over to the next chunk. +#[derive(Default)] +struct AnsiStripper { + carry: Vec, +} + +impl AnsiStripper { + fn push(&mut self, chunk: &[u8]) -> String { + self.carry.extend_from_slice(chunk); + let (out, consumed) = strip_ansi_prefix(&self.carry); + self.carry.drain(..consumed); + out + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Commands +// ───────────────────────────────────────────────────────────────────────────── + +/// Stdin of the acquisition currently in flight, so [`submit_claude_token_code`] +/// can answer the CLI's `Paste code here` prompt. +/// +/// `Some` exactly while a flow is running, which doubles as the single-flight +/// guard: the token is global, so two concurrent logins would race to overwrite +/// each other's keychain entry and neither could tell which prompt it was +/// feeding. +static PENDING_INPUT: OnceLock>>>> = OnceLock::new(); + +fn pending_input() -> &'static Mutex>>> { + PENDING_INPUT.get_or_init(|| Mutex::new(None)) +} + +fn emit_progress(app: &AppHandle, project_id: &str, message: &str) { + let _ = app.emit( + PROGRESS_EVENT, + serde_json::json!({ "project_id": project_id, "message": message }), + ); +} + +fn emit_output(app: &AppHandle, project_id: &str, chunk: &str) { + let _ = app.emit( + OUTPUT_EVENT, + serde_json::json!({ "project_id": project_id, "chunk": chunk }), + ); +} + +/// Shell run inside the container. +/// +/// * `stty` widens the pty before Claude Code starts, so its layout engine does +/// not wrap the token or the sign-in URL across lines. Docker's default exec +/// pty is 80 columns; both are longer than that. Setting it here rather than +/// via a post-start resize avoids racing the process's startup. +/// * The `unset` line strips inherited auth so `setup-token` runs against a +/// clean claude.ai login instead of warning about, or deferring to, whatever +/// credential the container is already configured with — including a shared +/// token from a previous run, which is likely the very thing being replaced. +const SETUP_TOKEN_SCRIPT: &str = r#"stty cols 200 rows 50 2>/dev/null || true +unset CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN ANTHROPIC_BASE_URL \ + ANTHROPIC_MODEL CLAUDE_CODE_USE_BEDROCK AWS_BEARER_TOKEN_BEDROCK +exec claude setup-token"#; + +/// Run `claude setup-token` in the container and return the token it printed. +/// Streams redacted output as it arrives and forwards anything arriving on +/// `input_rx` (the user's pasted code) to the command's stdin. +async fn run_setup_token( + app: &AppHandle, + project_id: &str, + container_id: &str, + mut input_rx: mpsc::UnboundedReceiver>, +) -> Result { + // 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. + let AttachedExec { + exec_id, + mut output, + mut input, + } = create_attached_exec( + container_id, + vec![ + "sh".to_string(), + "-c".to_string(), + SETUP_TOKEN_SCRIPT.to_string(), + ], + true, + ) + .await?; + + let mut stripper = AnsiStripper::default(); + let mut redactor = SecretRedactor::default(); + let mut transcript = String::new(); + let deadline = tokio::time::Instant::now() + SETUP_TIMEOUT; + + loop { + // Writing stdin and reading stdout are driven from the same loop: with + // a hijacked exec both halves ride one socket, and `input` must stay + // alive for the whole session anyway — dropping it early would tear the + // output stream down with it. + let next = tokio::select! { + Some(data) = input_rx.recv() => { + if let Err(e) = input.write_all(&data).await { + return Err(format!( + "Could not send the code to `claude setup-token`: {}. No token was stored.", + e + )); + } + let _ = input.flush().await; + continue; + } + next = tokio::time::timeout_at(deadline, output.next()) => match next { + Ok(next) => next, + Err(_) => { + return Err(format!( + "Timed out after {} minutes waiting for `claude setup-token` to finish. \ + No token was stored.", + SETUP_TIMEOUT.as_secs() / 60 + )) + } + }, + }; + + let frame = match next { + Some(Ok(frame)) => frame, + Some(Err(e)) => { + return Err(format!( + "Lost the connection to `claude setup-token`: {}. No token was stored.", + e + )) + } + None => break, + }; + + let visible = stripper.push(&frame.into_bytes()); + if visible.is_empty() { + continue; + } + + transcript.push_str(&visible); + if transcript.len() > MAX_TRANSCRIPT { + // Keep the tail: that is where the token lands. + let cut = transcript.len() - MAX_TRANSCRIPT / 2; + let cut = (cut..transcript.len()) + .find(|i| transcript.is_char_boundary(*i)) + .unwrap_or(transcript.len()); + transcript.drain(..cut); + } + + let safe = redactor.push(&visible); + if !safe.is_empty() { + emit_output(app, project_id, &safe); + } + } + + let tail = redactor.flush(); + if !tail.is_empty() { + emit_output(app, project_id, &tail); + } + + let exit_code = wait_for_exec_exit(&exec_id).await.unwrap_or(0); + if exit_code != 0 { + return Err(format!( + "`claude setup-token` exited with status {}. No token was stored — \ + see the command output above for what went wrong.", + exit_code + )); + } + + parse_setup_token(&transcript).ok_or_else(|| { + "`claude setup-token` finished but printed no recognisable token. \ + Nothing was stored. This usually means the login was cancelled, or the \ + account has no Claude subscription (long-lived tokens require one)." + .to_string() + }) +} + +/// Mint a shared, long-lived Claude Code token by running `claude setup-token` +/// inside `project_id`'s container, and store it in the OS keychain. +/// +/// The project only lends its container — a place to run the CLI that already +/// has Claude Code installed. The resulting token is global, and is used by +/// every Anthropic-backend project that has not opted out. +#[tauri::command] +pub async fn acquire_claude_token( + project_id: String, + app_handle: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let project = state + .projects_store + .get(&project_id) + .ok_or_else(|| format!("Project {} not found", project_id))?; + + let container_id = project.container_id.clone().ok_or_else(|| { + format!( + "Project '{}' has no container yet. Start it, then run authentication again.", + project.name + ) + })?; + if !is_container_running(&container_id).await.unwrap_or(false) { + return Err(format!( + "The container for '{}' is not running. Start it, then run authentication again.", + project.name + )); + } + + // 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::>(); + { + let mut slot = pending_input().lock().await; + if slot.is_some() { + return Err( + "A Claude authentication flow is already running. Finish or cancel it first." + .to_string(), + ); + } + *slot = Some(input_tx); + } + + let bridge_was_enabled = project.auth_bridge_enabled; + + let result = async { + // See the module docs: 2.1.226's `setup-token` redirects to an + // Anthropic-hosted callback, so no container-local listener needs + // bridging. Enabled anyway, per design, to cover CLI versions and login + // paths that do use a loopback redirect. Temporary elevation — the + // prior setting is restored below whatever happens. + if !bridge_was_enabled { + state + .projects_store + .set_auth_bridge_enabled(&project_id, true)?; + emit_progress( + &app_handle, + &project_id, + "Auth bridge enabled for the duration of login.", + ); + } + // Called unconditionally, and idempotent: the flag may already have + // been on while the poller was not running (e.g. enabled before start). + state + .auth_bridge + .start( + project_id.clone(), + container_id.clone(), + app_handle.clone(), + state.projects_store.clone(), + ) + .await; + + emit_progress( + &app_handle, + &project_id, + "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 + } + .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; + if !bridge_was_enabled { + // Stop the poller first: it awaits teardown, so host ports are provably + // released before the flag goes back. + state.auth_bridge.stop(&project_id).await; + if let Err(e) = state + .projects_store + .set_auth_bridge_enabled(&project_id, false) + { + log::warn!( + "Failed to restore the auth bridge setting for project {}: {}", + project_id, + e + ); + } + } + + let token = result?; + secure::store_claude_oauth_token(&token)?; + + log::info!( + "Stored a shared Claude authentication token (acquired via project {})", + project_id + ); + emit_progress( + &app_handle, + &project_id, + "Token stored in the OS keychain. Restart your Anthropic-backend containers to use it.", + ); + + Ok(()) +} + +/// Answer the `Paste code here if prompted >` prompt of a running +/// [`acquire_claude_token`] with the code shown after signing in. +/// +/// Takes no project id: the flow is single-flight and the token is global, so +/// there is only ever one prompt waiting. +#[tauri::command] +pub async fn submit_claude_token_code(code: String) -> Result<(), String> { + let code = code.trim(); + if code.is_empty() { + return Err("Enter the code shown after signing in.".to_string()); + } + // The code goes to a TUI text input. A newline or escape embedded in it + // would submit early or drive the widget, so reject control characters + // outright rather than trying to sanitise them. + if code.chars().any(char::is_control) { + return Err("That code contains invalid characters. Copy it again and retry.".to_string()); + } + + let slot = pending_input().lock().await; + let sender = slot.as_ref().ok_or_else(|| { + "No Claude authentication flow is waiting for a code. Start authentication first." + .to_string() + })?; + + let mut keystrokes = code.as_bytes().to_vec(); + keystrokes.push(b'\r'); + sender + .send(keystrokes) + .map_err(|_| "The authentication flow has already ended.".to_string()) +} + +/// Whether a shared Claude token exists. Deliberately a boolean — no command +/// here ever hands the token itself to the frontend. +#[tauri::command] +pub async fn has_claude_token() -> Result { + Ok(secure::has_claude_oauth_token()) +} + +/// Forget the shared Claude token. Containers keep the injected value until +/// each is next started, at which point the rotation-id label mismatch forces a +/// recreation that blanks the env var. +#[tauri::command] +pub async fn clear_claude_token() -> Result<(), String> { + secure::delete_claude_oauth_token()?; + log::info!("Cleared the shared Claude authentication token"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A token-shaped value of realistic length. + fn token(seed: char) -> String { + format!("{}{}", TOKEN_PREFIX, std::iter::repeat(seed).take(90).collect::()) + } + + #[test] + fn extracts_the_token_from_realistic_output() { + let tok = token('A'); + let output = format!( + "Claude Code long-lived token setup\n\ + Opening browser to https://claude.ai/oauth/authorize?code=true\n\ + Login successful!\n\n\ + Your token:\n{}\n\n\ + Set CLAUDE_CODE_OAUTH_TOKEN to this value.\n", + tok + ); + assert_eq!(parse_setup_token(&output), Some(tok)); + } + + #[test] + fn ignores_prose_decoys_and_still_finds_the_real_token() { + let tok = token('B'); + let output = format!( + "Set the env var like so:\n export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...\n\ + or CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-\n\ + Your token: {}\n", + tok + ); + assert_eq!(parse_setup_token(&output), Some(tok)); + } + + #[test] + fn a_decoy_on_its_own_yields_nothing() { + let output = "Usage: export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...\n"; + assert_eq!(parse_setup_token(output), None); + } + + #[test] + fn no_match_returns_none_rather_than_guessing() { + assert_eq!(parse_setup_token(""), None); + assert_eq!( + parse_setup_token("error: authentication cancelled by the user\n"), + None + ); + // Right length, wrong product prefix. + assert_eq!( + parse_setup_token(&format!("sk-ant-api03-{}\n", "C".repeat(90))), + None + ); + } + + #[test] + fn multiple_matches_take_the_last() { + let old = token('D'); + let new = token('E'); + let output = format!( + "Replacing existing token {}\n...\nYour new token: {}\n", + old, new + ); + assert_eq!(parse_setup_token(&output), Some(new)); + } + + #[test] + fn a_repainted_tui_frame_yields_the_same_token_once() { + let tok = token('F'); + // Same frame drawn three times, as a TUI would. + let output = format!("Your token: {}\n", tok).repeat(3); + assert_eq!(parse_setup_token(&output), Some(tok)); + } + + #[test] + fn a_prefix_glued_to_a_longer_word_is_not_a_token() { + let output = format!("notasecretsk-ant-oat01-{}\n", "G".repeat(90)); + assert_eq!(parse_setup_token(&output), None); + } + + #[test] + fn the_token_stops_at_the_first_non_token_character() { + let tok = token('H'); + let output = format!("token=\"{}\", expires=2027-08-09\n", tok); + assert_eq!(parse_setup_token(&output), Some(tok)); + } + + #[test] + fn redaction_masks_a_token_in_one_piece() { + let mut r = SecretRedactor::default(); + let mut seen = r.push(&format!("Your token: {}\n", token('I'))); + seen.push_str(&r.flush()); + assert!(!seen.contains(TOKEN_PREFIX)); + assert!(seen.contains(SECRET_PLACEHOLDER)); + assert!(seen.contains("Your token: ")); + } + + #[test] + fn redaction_survives_a_token_split_across_chunks() { + let tok = token('J'); + let mut r = SecretRedactor::default(); + let mut seen = String::new(); + // Split mid-prefix and again mid-body — the worst case for a naive + // per-chunk regex. + seen.push_str(&r.push("Your token: sk-a")); + seen.push_str(&r.push(&tok[4..40])); + seen.push_str(&r.push(&tok[40..])); + seen.push_str(&r.push("\ndone\n")); + seen.push_str(&r.flush()); + assert!(!seen.contains(TOKEN_PREFIX), "leaked: {}", seen); + assert!(seen.contains(SECRET_PLACEHOLDER)); + assert!(seen.ends_with("\ndone\n")); + } + + #[test] + fn redaction_leaves_ordinary_text_alone() { + let mut r = SecretRedactor::default(); + let mut seen = r.push("Visit https://claude.ai/oauth/authorize?code=abc-def to continue\n"); + seen.push_str(&r.flush()); + assert_eq!( + seen, + "Visit https://claude.ai/oauth/authorize?code=abc-def to continue\n" + ); + } + + #[test] + fn ansi_stripping_recovers_the_token_from_a_styled_frame() { + let tok = token('K'); + let framed = format!( + "\x1b[2J\x1b[H\x1b[1;36mYour token:\x1b[0m\r\n\x1b[32m{}\x1b[0m\r\n", + tok + ); + let mut s = AnsiStripper::default(); + let visible = s.push(framed.as_bytes()); + assert!(!visible.contains('\x1b')); + assert_eq!(parse_setup_token(&visible), Some(tok)); + } + + /// Claude Code's TUI positions words with `ESC [ n G` instead of spaces + /// (verified against 2.1.226). Deleting those would weld words together. + #[test] + fn ansi_stripping_turns_column_jumps_into_separators() { + let mut s = AnsiStripper::default(); + let visible = s.push(b"\x1b[38;2;215;119;87mWelcome\x1b[9Gto\x1b[12GClaude\x1b[19GCode\x1b[39m"); + assert_eq!(visible, "Welcome to Claude Code"); + } + + /// The failure this protects against: a column jump immediately before the + /// token would, if simply deleted, glue the preceding word onto the prefix + /// and make `parse_setup_token` reject a perfectly good token. + #[test] + fn a_column_jump_before_the_token_does_not_hide_it() { + let tok = token('L'); + let framed = format!("\x1b[2GToken\x1b[8G{}\r\n", tok); + let mut s = AnsiStripper::default(); + let visible = s.push(framed.as_bytes()); + // The leading jump is an indent, so it becomes a space too. + assert_eq!(visible, format!(" Token {}\n", tok)); + assert_eq!(parse_setup_token(&visible), Some(tok)); + } + + /// A pty with ONLCR emits `\r\r\n` at end of line; that is one break. + #[test] + fn carriage_return_runs_before_a_newline_collapse() { + let mut s = AnsiStripper::default(); + let visible = s.push(b"one\r\r\ntwo\r\r\n"); + assert_eq!(visible, "one\ntwo\n"); + } + + /// A bare CR is a repaint, and must still break the line so the old frame's + /// tail cannot be welded onto the new frame's head. + #[test] + fn a_bare_carriage_return_breaks_the_line() { + let mut s = AnsiStripper::default(); + let visible = s.push(b"sk-ant-oat01-old\rsk-ant-oat01-new"); + assert_eq!(visible, "sk-ant-oat01-old\nsk-ant-oat01-new"); + } + + #[test] + fn ansi_stripping_removes_osc8_hyperlink_wrappers() { + let mut s = AnsiStripper::default(); + let visible = s.push(b"\x1b]8;id=1;https://claude.com/x\x07https://claude.com/x\x1b]8;;\x07"); + assert_eq!(visible, "https://claude.com/x"); + } + + #[test] + fn ansi_stripping_handles_a_sequence_split_across_chunks() { + let mut s = AnsiStripper::default(); + let mut visible = s.push(b"a\x1b[3"); + visible.push_str(&s.push(b"1mb")); + assert_eq!(visible, "ab"); + } +} + diff --git a/app/src-tauri/src/commands/mod.rs b/app/src-tauri/src/commands/mod.rs index 1f5ac05..c046d5e 100644 --- a/app/src-tauri/src/commands/mod.rs +++ b/app/src-tauri/src/commands/mod.rs @@ -1,3 +1,5 @@ +pub mod auth_bridge_commands; +pub mod auth_token_commands; pub mod aws_commands; pub mod docker_commands; pub mod file_commands; diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index dee3c99..a53dcf8 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -100,6 +100,10 @@ pub async fn remove_project( project_id: String, state: State<'_, AppState>, ) -> Result<(), String> { + // Release any host loopback ports the auth bridge holds for this project + // before the container (and the project record) go away. + state.auth_bridge.stop(&project_id).await; + // Stop and remove container if it exists if let Some(ref project) = state.projects_store.get(&project_id) { if let Some(ref container_id) = project.container_id { @@ -133,10 +137,35 @@ pub async fn remove_project( #[tauri::command] pub async fn update_project( project: Project, + app_handle: tauri::AppHandle, state: State<'_, AppState>, ) -> Result { store_secrets_for_project(&project)?; - state.projects_store.update(project) + let updated = state.projects_store.update(project)?; + + // `auth_bridge_enabled` can arrive through this generic save as well as + // through `set_auth_bridge_enabled`, so reconcile the running bridge with + // whatever was just persisted. `start` is idempotent and `stop` is a no-op + // when nothing is running, so this is safe on every project save. + if updated.auth_bridge_enabled { + if let Some(ref container_id) = updated.container_id { + if docker::is_container_running(container_id).await.unwrap_or(false) { + state + .auth_bridge + .start( + updated.id.clone(), + container_id.clone(), + app_handle, + state.projects_store.clone(), + ) + .await; + } + } + } else { + state.auth_bridge.stop(&updated.id).await; + } + + Ok(updated) } #[tauri::command] @@ -400,6 +429,20 @@ pub async fn start_project_container( state.projects_store.set_container_id(&project_id, Some(container_id.clone()))?; state.projects_store.update_status(&project_id, ProjectStatus::Running)?; + // Arm the auth bridge if this project opted in. Purely host-side, so it + // happens after the container is up and never affects the start itself. + if project.auth_bridge_enabled { + state + .auth_bridge + .start( + project_id.clone(), + container_id.clone(), + app_handle.clone(), + state.projects_store.clone(), + ) + .await; + } + project.container_id = Some(container_id); project.status = ProjectStatus::Running; Ok(project) @@ -418,6 +461,9 @@ pub async fn stop_project_container( state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?; + // Drop host listeners first: they only make sense while the container runs. + state.auth_bridge.stop(&project_id).await; + if let Some(ref container_id) = project.container_id { // Close exec sessions for this project emit_progress(&app_handle, &project_id, "Stopping container..."); @@ -443,6 +489,10 @@ pub async fn rebuild_project_container( .get(&project_id) .ok_or_else(|| format!("Project {} not found", project_id))?; + // The bridge is bound to the container that is about to be destroyed; + // `start_project_container` below re-arms it against the new one. + state.auth_bridge.stop(&project_id).await; + // Remove existing container if let Some(ref container_id) = project.container_id { state.exec_manager.close_sessions_for_container(container_id).await; @@ -469,6 +519,7 @@ pub async fn rebuild_project_container( /// to Stopped. #[tauri::command] pub async fn reconcile_project_statuses( + app_handle: tauri::AppHandle, state: State<'_, AppState>, ) -> Result, String> { let projects = state.projects_store.list(); @@ -490,6 +541,22 @@ pub async fn reconcile_project_statuses( project.name, project.id ); + // The app may have restarted while the container kept running; the + // bridge lives in this process, so re-arm it here. `start` is + // idempotent, so a bridge that is already polling is untouched. + if project.auth_bridge_enabled { + if let Some(ref container_id) = project.container_id { + state + .auth_bridge + .start( + project.id.clone(), + container_id.clone(), + app_handle.clone(), + state.projects_store.clone(), + ) + .await; + } + } } else { log::info!( "Project '{}' ({}) container is not running — setting to Stopped", diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 9f33c01..c98bf12 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -171,23 +171,45 @@ fn build_claude_instructions( combined } +/// The env var Claude Code reads a long-lived `claude setup-token` credential +/// from. Named once so injection, the reserved-name blocklist, and the +/// stale-value neutralization pass can never disagree about the spelling. +pub const CLAUDE_OAUTH_TOKEN_ENV: &str = "CLAUDE_CODE_OAUTH_TOKEN"; + +/// Env var name prefixes Triple-C manages itself; users cannot set these by hand. +const RESERVED_ENV_PREFIXES: &[&str] = &["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"]; + +/// Exact env var names Triple-C manages itself. Not covered by +/// [`RESERVED_ENV_PREFIXES`] because they don't share those prefixes. +/// +/// `MCP_SERVERS_JSON` is reserved for legacy reasons: the built-in MCP feature +/// was removed, but the name stays blocked so users cannot hand-set it. +/// `CLAUDE_CODE_OAUTH_TOKEN` is reserved because Triple-C owns it — a hand-set +/// value would silently outrank the keychain-held shared token and be invisible +/// to the auth UI. +const RESERVED_ENV_EXACT: &[&str] = &[ + "CLAUDE_INSTRUCTIONS", + "MCP_SERVERS_JSON", + "CLAUDE_CODE_SETTINGS_JSON", + "MISSION_CONTROL_ENABLED", + "TRIPLE_C_PERMISSION_MODE", + CLAUDE_OAUTH_TOKEN_ENV, +]; + +/// Whether `key` is an env var name Triple-C reserves for itself. +fn is_reserved_env_key(key: &str) -> bool { + let upper = key.to_uppercase(); + RESERVED_ENV_PREFIXES.iter().any(|p| upper.starts_with(p)) + || RESERVED_ENV_EXACT.iter().any(|e| upper == *e) +} + /// Compute a fingerprint string for the custom environment variables. /// Sorted alphabetically so order changes do not cause spurious recreation. fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String { - let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"]; - // MCP_SERVERS_JSON is reserved for legacy reasons: the built-in MCP feature was - // removed, but the name stays blocked so users cannot hand-set it. - let reserved_exact = ["CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", "MISSION_CONTROL_ENABLED", "TRIPLE_C_PERMISSION_MODE"]; let mut parts: Vec = Vec::new(); for env_var in custom_env_vars { let key = env_var.key.trim(); - if key.is_empty() { - continue; - } - let upper = key.to_uppercase(); - let is_reserved = reserved_prefixes.iter().any(|p| upper.starts_with(p)) - || reserved_exact.iter().any(|e| upper == *e); - if is_reserved { + if key.is_empty() || is_reserved_env_key(key) { continue; } parts.push(format!("{}={}", key, env_var.value)); @@ -196,6 +218,45 @@ fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String { parts.join(",") } +/// The shared Claude Code OAuth token to inject for this project, paired with +/// its rotation id. +/// +/// `None` unless *all* of: the backend is Anthropic (the token is meaningless +/// to Bedrock/Ollama/OpenAI-compatible), the project has not opted out, and a +/// non-blank token is actually in the keychain. Read here rather than passed in +/// because the token is global, not part of the per-project record. +/// +/// The returned token is never logged and never leaves this module except as +/// the env var value handed to Docker. +fn shared_claude_auth(project: &Project) -> Option<(String, String)> { + if project.backend != Backend::Anthropic || !project.use_shared_auth_token { + return None; + } + let token = crate::storage::secure::get_claude_oauth_token() + .unwrap_or_else(|e| { + log::warn!("Could not read the shared Claude token from the keychain: {}", e); + None + }) + .filter(|t| !t.trim().is_empty())?; + // A token with no rotation id predates versioning (or the id write failed). + // A constant stand-in still differs from the empty "no token" label, so + // presence changes are caught; only rotations could be missed. + let version = crate::storage::secure::get_claude_oauth_token_version() + .unwrap_or(None) + .unwrap_or_else(|| "unversioned".to_string()); + Some((token, version)) +} + +/// Label value tracking which shared Claude token (if any) a container was +/// created with. Empty means "none injected". See +/// [`crate::storage::secure`] for why this is a random rotation id rather than +/// a hash of the token. +fn claude_token_label(project: &Project) -> String { + shared_claude_auth(project) + .map(|(_, version)| version) + .unwrap_or_default() +} + /// Merge global and per-project custom environment variables. /// Per-project variables override global variables with the same key. fn merge_custom_env_vars(global: &[EnvVar], project: &[EnvVar]) -> Vec { @@ -680,6 +741,19 @@ pub async fn create_container( } } + // Shared Claude Code OAuth token (Anthropic backend only, opt-out per + // project). Injected *before* the neutralization pass below so that pass + // sees it as already-set; when it is absent the pass actively blanks the + // variable instead of leaving a stale one baked into the snapshot image. + let shared_claude = shared_claude_auth(project); + if let Some((ref token, _)) = shared_claude { + env_vars.push(format!("{}={}", CLAUDE_OAUTH_TOKEN_ENV, token)); + log::info!( + "Injecting the shared Claude authentication token into the container for project {}", + project.id + ); + } + // ── Neutralize stale backend auth env vars ────────────────────────────── // When a project switches backends (e.g. Bedrock → Anthropic) the container // is recreated *from a snapshot image* committed off the previous container. @@ -704,6 +778,11 @@ pub async fn create_container( "ANTHROPIC_MODEL", "DISABLE_PROMPT_CACHING", "ANTHROPIC_BEDROCK_SERVICE_TIER", + // Revoking the shared token, opting a project out, or switching away + // from the Anthropic backend must *clear* this, not merely stop setting + // it — otherwise the value committed into the snapshot image keeps + // authenticating the container with a credential the user removed. + CLAUDE_OAUTH_TOKEN_ENV, ]; let already_set: std::collections::HashSet = env_vars .iter() @@ -717,19 +796,12 @@ pub async fn create_container( // Custom environment variables (global + per-project, project overrides global for same key) let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars); - let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"]; - // MCP_SERVERS_JSON is reserved for legacy reasons: the built-in MCP feature was - // removed, but the name stays blocked so users cannot hand-set it. - let reserved_exact = ["CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", "MISSION_CONTROL_ENABLED", "TRIPLE_C_PERMISSION_MODE"]; for env_var in &merged_env { let key = env_var.key.trim(); if key.is_empty() { continue; } - let upper = key.to_uppercase(); - let is_reserved = reserved_prefixes.iter().any(|p| upper.starts_with(p)) - || reserved_exact.iter().any(|e| upper == *e); - if is_reserved { + if is_reserved_env_key(key) { log::warn!("Skipping reserved env var: {}", key); continue; } @@ -948,6 +1020,10 @@ pub async fn create_container( labels.insert("triple-c.git-user-email".to_string(), effective_git_email.unwrap_or_default().to_string()); labels.insert("triple-c.git-token-hash".to_string(), project.git_token.as_ref().map(|t| sha256_hex(t)).unwrap_or_default()); + // Rotation id, NOT the token and NOT a hash of it — labels are readable by + // anything on the host via `docker inspect`. + labels.insert("triple-c.claude-token-version".to_string(), + shared_claude.as_ref().map(|(_, v)| v.clone()).unwrap_or_default()); let host_config = HostConfig { mounts: Some(mounts), @@ -1144,7 +1220,8 @@ chmod 600 "$HOME/.aws/credentials""#; /// NOTE: `docker commit` always bakes the *running container's* full ENV into /// the resulting image — passing an empty Config here does NOT strip it, and /// the commit API gives no way to remove env vars. As a result auth vars (e.g. -/// CLAUDE_CODE_USE_BEDROCK, AWS_*) are present in this snapshot image's ENV. +/// CLAUDE_CODE_USE_BEDROCK, AWS_*, CLAUDE_CODE_OAUTH_TOKEN) are present in this +/// snapshot image's ENV — this image is local and per-project, never pushed. /// `create_container` defends against that by explicitly overriding every /// managed auth key for the active backend (see MANAGED_AUTH_KEYS), so a /// backend switch does not inherit the previous backend's stale credentials. @@ -1390,6 +1467,20 @@ pub async fn container_needs_recreation( return Ok(true); } + // ── Shared Claude Code OAuth token ─────────────────────────────────── + // Compares rotation ids, so this fires when the token is first acquired, + // re-acquired (rotated), revoked, or opted out of. Both "" means no token + // is in play, which is also what a container predating this feature reports + // — so existing installs are not recreated until a token actually exists. + // Recreation is the only way to change container env, and it is also what + // makes MANAGED_AUTH_KEYS blank a revoked token out of the snapshot image. + let expected_claude_token = claude_token_label(project); + let container_claude_token = get_label("triple-c.claude-token-version").unwrap_or_default(); + if container_claude_token != expected_claude_token { + log::info!("Shared Claude authentication token mismatch — recreating container"); + return Ok(true); + } + // ── Custom environment variables (label-based fingerprint) ────────── let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars); let expected_fingerprint = compute_env_fingerprint(&merged_env); diff --git a/app/src-tauri/src/docker/exec.rs b/app/src-tauri/src/docker/exec.rs index e9cce6e..dfbd66f 100644 --- a/app/src-tauri/src/docker/exec.rs +++ b/app/src-tauri/src/docker/exec.rs @@ -1,13 +1,78 @@ -use bollard::container::UploadToContainerOptions; +use bollard::container::{LogOutput, UploadToContainerOptions}; use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults}; -use futures_util::StreamExt; +use futures_util::{Stream, StreamExt}; use std::collections::HashMap; +use std::pin::Pin; use std::sync::Arc; -use tokio::io::AsyncWriteExt; +use tokio::io::{AsyncWrite, AsyncWriteExt}; use tokio::sync::{mpsc, Mutex}; use super::client::get_docker; +/// A `docker exec` that has been created and started with stdin/stdout/stderr +/// attached — the raw duplex halves, before any policy about what to do with +/// them. +/// +/// This is the single place in the codebase that knows how to open an attached +/// exec. Both consumers are built on it: +/// * [`ExecSessionManager`] — interactive terminals and the audio bridge, +/// which pump bytes through mpsc channels and a callback. +/// * `auth_bridge` — per-connection `socat` tunnels, which pump bytes +/// straight between a host TCP socket and these halves. +/// +/// With `tty = false` the output stream is demultiplexed by Docker, so the +/// consumer can tell [`LogOutput::StdOut`] from [`LogOutput::StdErr`]. That +/// distinction matters for the auth bridge: `socat`'s diagnostics must not be +/// spliced into the proxied byte stream. +pub struct AttachedExec { + pub exec_id: String, + pub output: Pin> + Send>>, + pub input: Pin>, +} + +/// Create and start an exec with stdin + stdout + stderr attached, returning the +/// raw duplex halves. Runs as `claude` in `/workspace`, like every other exec +/// this app opens. +pub async fn create_attached_exec( + container_id: &str, + cmd: Vec, + tty: bool, +) -> Result { + let docker = get_docker()?; + + let exec = docker + .create_exec( + container_id, + CreateExecOptions { + attach_stdin: Some(true), + attach_stdout: Some(true), + attach_stderr: Some(true), + tty: Some(tty), + cmd: Some(cmd), + user: Some("claude".to_string()), + working_dir: Some("/workspace".to_string()), + ..Default::default() + }, + ) + .await + .map_err(|e| format!("Failed to create exec: {}", e))?; + + let exec_id = exec.id.clone(); + + match docker + .start_exec(&exec_id, None) + .await + .map_err(|e| format!("Failed to start exec: {}", e))? + { + StartExecResults::Attached { output, input } => Ok(AttachedExec { + exec_id, + output, + input, + }), + StartExecResults::Detached => Err("Exec started in detached mode".to_string()), + } +} + pub struct ExecSession { pub exec_id: String, pub container_id: String, @@ -80,82 +145,55 @@ impl ExecSessionManager { where F: Fn(Vec) + Send + 'static, { - let docker = get_docker()?; - - let exec = docker - .create_exec( - container_id, - CreateExecOptions { - attach_stdin: Some(true), - attach_stdout: Some(true), - attach_stderr: Some(true), - tty: Some(tty), - cmd: Some(cmd), - user: Some("claude".to_string()), - working_dir: Some("/workspace".to_string()), - ..Default::default() - }, - ) - .await - .map_err(|e| format!("Failed to create exec: {}", e))?; - - let exec_id = exec.id.clone(); - - let result = docker - .start_exec(&exec_id, None) - .await - .map_err(|e| format!("Failed to start exec: {}", e))?; + let AttachedExec { + exec_id, + mut output, + mut input, + } = create_attached_exec(container_id, cmd, tty).await?; let (input_tx, mut input_rx) = mpsc::unbounded_channel::>(); let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1); - match result { - StartExecResults::Attached { mut output, mut input } => { - // Output reader task - let session_id_clone = session_id.to_string(); - let shutdown_tx_clone = shutdown_tx.clone(); - tokio::spawn(async move { - loop { - tokio::select! { - msg = output.next() => { - match msg { - Some(Ok(output)) => { - on_output(output.into_bytes().to_vec()); - } - Some(Err(e)) => { - log::error!("Exec output error for {}: {}", session_id_clone, e); - break; - } - None => { - log::info!("Exec output stream ended for {}", session_id_clone); - break; - } - } + // Output reader task + let session_id_clone = session_id.to_string(); + let shutdown_tx_clone = shutdown_tx.clone(); + tokio::spawn(async move { + loop { + tokio::select! { + msg = output.next() => { + match msg { + Some(Ok(output)) => { + on_output(output.into_bytes().to_vec()); } - _ = shutdown_rx.recv() => { - log::info!("Exec session {} shutting down", session_id_clone); + Some(Err(e)) => { + log::error!("Exec output error for {}: {}", session_id_clone, e); + break; + } + None => { + log::info!("Exec output stream ended for {}", session_id_clone); break; } } } - on_exit(); - let _ = shutdown_tx_clone; - }); - - // Input writer task - tokio::spawn(async move { - while let Some(data) = input_rx.recv().await { - if let Err(e) = input.write_all(&data).await { - log::error!("Failed to write to exec stdin: {}", e); - break; - } + _ = shutdown_rx.recv() => { + log::info!("Exec session {} shutting down", session_id_clone); + break; } - }); + } } - StartExecResults::Detached => { - return Err("Exec started in detached mode".to_string()); + on_exit(); + let _ = shutdown_tx_clone; + }); + + // Input writer task + tokio::spawn(async move { + while let Some(data) = input_rx.recv().await { + if let Err(e) = input.write_all(&data).await { + log::error!("Failed to write to exec stdin: {}", e); + break; + } } - } + }); let session = ExecSession { exec_id, diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index e82f0af..4ad063f 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +mod auth_bridge; mod commands; mod docker; mod install_helper; @@ -8,6 +9,7 @@ pub mod web_terminal; use std::sync::Arc; +use auth_bridge::AuthBridgeManager; use docker::exec::ExecSessionManager; use storage::projects_store::ProjectsStore; use storage::settings_store::SettingsStore; @@ -18,6 +20,7 @@ pub struct AppState { pub projects_store: Arc, pub settings_store: Arc, pub exec_manager: Arc, + pub auth_bridge: Arc, pub web_terminal_server: Arc>>, } @@ -39,6 +42,7 @@ pub fn run() { } }); let exec_manager = Arc::new(ExecSessionManager::new()); + let auth_bridge = Arc::new(AuthBridgeManager::new()); // Clone Arcs for the setup closure (web terminal auto-start) let projects_store_setup = projects_store.clone(); @@ -53,6 +57,7 @@ pub fn run() { projects_store, settings_store, exec_manager, + auth_bridge, web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)), }) .setup(move |app| { @@ -136,6 +141,8 @@ pub fn run() { let _ = docker::stt::stop_stt_container().await; // Close all exec sessions state.exec_manager.close_all_sessions().await; + // Release every host loopback port held by the auth bridge + state.auth_bridge.stop_all().await; }); } }) @@ -155,6 +162,14 @@ pub fn run() { commands::project_commands::stop_project_container, commands::project_commands::rebuild_project_container, commands::project_commands::reconcile_project_statuses, + // Auth bridge + commands::auth_bridge_commands::set_auth_bridge_enabled, + commands::auth_bridge_commands::get_auth_bridge_status, + // Shared Claude Code auth token + commands::auth_token_commands::acquire_claude_token, + commands::auth_token_commands::submit_claude_token_code, + commands::auth_token_commands::has_claude_token, + commands::auth_token_commands::clear_claude_token, // Settings commands::settings_commands::get_settings, commands::settings_commands::update_settings, diff --git a/app/src-tauri/src/models/project.rs b/app/src-tauri/src/models/project.rs index b47f1a1..bf2b0c0 100644 --- a/app/src-tauri/src/models/project.rs +++ b/app/src-tauri/src/models/project.rs @@ -30,6 +30,14 @@ fn default_full_permissions() -> bool { true } +/// `use_shared_auth_token` defaults to **on**: once the user has run +/// `claude setup-token` once, every existing Anthropic-backend project should +/// pick the token up without being edited one by one. Projects deliberately +/// pinned to their own `claude login` identity opt out. +fn default_use_shared_auth_token() -> bool { + true +} + /// How much autonomy Claude Code is granted inside the container. /// /// Maps onto Claude Code CLI flags — see [`PermissionMode::cli_args`], which is @@ -122,6 +130,23 @@ pub struct Project { pub sandbox_mode_enabled: bool, #[serde(default)] pub mission_control_enabled: bool, + /// Opt in to the auth bridge: while the container runs, its loopback + /// listeners are mirrored onto the host's loopback so browser OAuth + /// callbacks (`claude login`, `fly login`, `aws sso login`) can reach them. + /// Purely host-side — it deliberately has no container-recreation label, + /// because toggling it changes nothing about the container itself. + #[serde(default)] + pub auth_bridge_enabled: bool, + /// Use the shared, long-lived Claude Code OAuth token (from + /// `claude setup-token`, held in the OS keychain) for this project instead + /// of requiring its own `claude login`. Only consulted when `backend` is + /// [`Backend::Anthropic`] and a token has actually been stored. + /// + /// Defaults to **true** so a single `setup-token` run covers every project; + /// turn it off to pin a project to the identity it logged in with inside + /// its own container. + #[serde(default = "default_use_shared_auth_token")] + pub use_shared_auth_token: bool, /// Legacy binary permission flag. Superseded by `permission_mode`, but kept /// because it is the value already stored in users' `projects.json`; it is /// the fallback in `effective_permission_mode()` so old projects keep @@ -262,6 +287,8 @@ impl Project { allow_docker_access: false, sandbox_mode_enabled: false, mission_control_enabled: false, + auth_bridge_enabled: false, + use_shared_auth_token: default_use_shared_auth_token(), full_permissions: false, permission_mode: None, ssh_key_path: None, diff --git a/app/src-tauri/src/storage/projects_store.rs b/app/src-tauri/src/storage/projects_store.rs index 6028e70..dbf4eb6 100644 --- a/app/src-tauri/src/storage/projects_store.rs +++ b/app/src-tauri/src/storage/projects_store.rs @@ -177,6 +177,20 @@ impl ProjectsStore { } } + /// Granular setter for the auth bridge opt-in, so toggling it can't clobber + /// concurrent edits to the rest of the project record. + pub fn set_auth_bridge_enabled(&self, project_id: &str, enabled: bool) -> Result<(), String> { + let mut projects = self.lock(); + if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) { + p.auth_bridge_enabled = enabled; + p.updated_at = chrono::Utc::now().to_rfc3339(); + self.save(&projects)?; + Ok(()) + } else { + Err(format!("Project {} not found", project_id)) + } + } + pub fn set_container_id(&self, project_id: &str, container_id: Option) -> Result<(), String> { let mut projects = self.lock(); if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) { diff --git a/app/src-tauri/src/storage/secure.rs b/app/src-tauri/src/storage/secure.rs index ba47154..cd972b9 100644 --- a/app/src-tauri/src/storage/secure.rs +++ b/app/src-tauri/src/storage/secure.rs @@ -1,3 +1,31 @@ +//! OS keychain access, via the `keyring` crate. +//! +//! Two kinds of secret live here: +//! * **per-project** secrets (git token, AWS keys, …), keyed by project id; +//! * the **shared Claude Code OAuth token**, which is global — one +//! `claude setup-token` run authenticates every Anthropic-backend project. +//! +//! Nothing in this module ever logs a secret or folds one into an error string. + +/// Keychain service for the single, global Claude Code OAuth token minted by +/// `claude setup-token` and consumed via `CLAUDE_CODE_OAUTH_TOKEN`. +const CLAUDE_TOKEN_SERVICE: &str = "triple-c-claude-oauth-token"; + +/// Keychain service for the token's **rotation id** — a fresh random value +/// written every time the token is stored. +/// +/// Container recreation is driven off Docker labels, which anything on the host +/// can read with `docker inspect`. The token itself must obviously not go in a +/// label, and neither should a bare hash of it: a hash is a verification oracle +/// (holding a candidate token, you could confirm it). This id is not derived +/// from the token at all — it is unrelated random data that merely *changes* +/// whenever the token does, which is exactly (and only) what change detection +/// needs. +const CLAUDE_TOKEN_VERSION_SERVICE: &str = "triple-c-claude-oauth-token-version"; + +/// Fixed account name used for every triple-c keychain entry. +const KEYCHAIN_ACCOUNT: &str = "secret"; + /// Store a per-project secret in the OS keychain. pub fn store_project_secret(project_id: &str, key_name: &str, value: &str) -> Result<(), String> { let service = format!("triple-c-project-{}-{}", project_id, key_name); @@ -43,3 +71,88 @@ pub fn delete_project_secrets(project_id: &str) -> Result<(), String> { } Ok(()) } + +// ───────────────────────────────────────────────────────────────────────────── +// Shared Claude Code OAuth token (global, not per project) +// ───────────────────────────────────────────────────────────────────────────── + +/// Read a single-value keychain entry. `Ok(None)` when the entry is absent. +/// The error text names the entry, never its value. +fn read_entry(service: &str, label: &str) -> Result, String> { + let entry = keyring::Entry::new(service, KEYCHAIN_ACCOUNT) + .map_err(|e| format!("Keyring error: {}", e))?; + match entry.get_password() { + Ok(value) => Ok(Some(value)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(format!("Failed to retrieve {}: {}", label, e)), + } +} + +/// Delete a keychain entry, treating "wasn't there" as success. +fn delete_entry(service: &str, label: &str) -> Result<(), String> { + let entry = keyring::Entry::new(service, KEYCHAIN_ACCOUNT) + .map_err(|e| format!("Keyring error: {}", e))?; + match entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(format!("Failed to delete {}: {}", label, e)), + } +} + +/// Store the shared Claude Code OAuth token, replacing any previous one, and +/// mint a fresh rotation id so containers holding the old token are flagged for +/// recreation. Blank input is rejected rather than silently stored. +pub fn store_claude_oauth_token(token: &str) -> Result<(), String> { + if token.trim().is_empty() { + return Err("Refusing to store an empty Claude authentication token.".to_string()); + } + + let entry = keyring::Entry::new(CLAUDE_TOKEN_SERVICE, KEYCHAIN_ACCOUNT) + .map_err(|e| format!("Keyring error: {}", e))?; + entry + .set_password(token) + .map_err(|e| format!("Failed to store the Claude authentication token: {}", e))?; + + // Rotation id second: if this fails the token is still usable, and the + // stale id only costs one extra container recreation later. + let version = uuid::Uuid::new_v4().to_string(); + let version_entry = keyring::Entry::new(CLAUDE_TOKEN_VERSION_SERVICE, KEYCHAIN_ACCOUNT) + .map_err(|e| format!("Keyring error: {}", e))?; + version_entry + .set_password(&version) + .map_err(|e| format!("Failed to store the Claude token rotation id: {}", e))?; + + Ok(()) +} + +/// Retrieve the shared Claude Code OAuth token, if one has been stored. +pub fn get_claude_oauth_token() -> Result, String> { + read_entry(CLAUDE_TOKEN_SERVICE, "the Claude authentication token") +} + +/// The rotation id of the currently stored token. Opaque random data — safe to +/// put in a Docker label, unlike the token or any hash of it. +pub fn get_claude_oauth_token_version() -> Result, String> { + read_entry( + CLAUDE_TOKEN_VERSION_SERVICE, + "the Claude token rotation id", + ) +} + +/// Whether a shared Claude Code OAuth token is currently stored. A keychain +/// failure is reported as "no token" rather than surfacing as an error, so the +/// UI degrades to the un-authenticated state instead of breaking. +pub fn has_claude_oauth_token() -> bool { + matches!(get_claude_oauth_token(), Ok(Some(t)) if !t.trim().is_empty()) +} + +/// Delete the shared Claude Code OAuth token and its rotation id. Both are +/// attempted even if the first fails, so a partial failure cannot strand the +/// token behind a deleted id. +pub fn delete_claude_oauth_token() -> Result<(), String> { + let token_result = delete_entry(CLAUDE_TOKEN_SERVICE, "the Claude authentication token"); + let version_result = delete_entry( + CLAUDE_TOKEN_VERSION_SERVICE, + "the Claude token rotation id", + ); + token_result.and(version_result) +} diff --git a/app/src/App.tsx b/app/src/App.tsx index 64935bf..f408a6f 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -5,13 +5,20 @@ import TopBar from "./components/layout/TopBar"; import StatusBar from "./components/layout/StatusBar"; import TerminalView from "./components/terminal/TerminalView"; import DockerInstallDialog from "./components/DockerInstallDialog"; +import ProjectHome from "./components/projects/home/ProjectHome"; +import AddProjectDialog from "./components/projects/AddProjectDialog"; +import ToastHost from "./components/ui/ToastHost"; +import StatusIndicator from "./components/ui/StatusIndicator"; +import Button from "./components/ui/Button"; import { useDocker } from "./hooks/useDocker"; import { useSettings } from "./hooks/useSettings"; import { useProjects } from "./hooks/useProjects"; import { useUpdates } from "./hooks/useUpdates"; import { useTerminal } from "./hooks/useTerminal"; import { useSTT } from "./hooks/useSTT"; -import { useAppState } from "./store/appState"; +import { useContainerProgress } from "./hooks/useContainerProgress"; +import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts"; +import { useAppState, isHomeTab, tabKeyId, homeTabKey } from "./store/appState"; import { reconcileProjectStatuses } from "./lib/tauri-commands"; export default function App() { @@ -19,9 +26,17 @@ export default function App() { const { loadSettings } = useSettings(); const { refresh } = useProjects(); const { loadVersion, checkForUpdates, checkImageUpdate, startPeriodicCheck } = useUpdates(); - const { sessions, activeSessionId, setProjects, setSttToggle } = useAppState( - useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects, setSttToggle: s.setSttToggle })) - ); + const { sessions, activeSessionId, tabOrder, activeTabKey, setProjects, setSttToggle } = + useAppState( + useShallow(s => ({ + sessions: s.sessions, + activeSessionId: s.activeSessionId, + tabOrder: s.tabOrder, + activeTabKey: s.activeTabKey, + setProjects: s.setProjects, + setSttToggle: s.setSttToggle, + })) + ); const [showInstallDialog, setShowInstallDialog] = useState(false); // Single STT instance bound to the active session. The mic lives in the @@ -33,6 +48,9 @@ export default function App() { setSttToggle(stt.toggle); }, [stt.toggle, setSttToggle]); + useContainerProgress(); + useKeyboardShortcuts(); + // Initialize on mount useEffect(() => { loadSettings(); @@ -69,16 +87,25 @@ export default function App() { }; }, []); // eslint-disable-line react-hooks/exhaustive-deps + const homeProjectIds = tabOrder.filter(isHomeTab).map(tabKeyId); + return ( -
+
-
+
-
- {sessions.length === 0 ? ( +
+ {tabOrder.length === 0 ? ( ) : (
+ {homeProjectIds.map((projectId) => ( + + ))} {sessions.map((session) => (
+ {showInstallDialog && ( setShowInstallDialog(false)} /> )} @@ -98,18 +126,96 @@ export default function App() { ); } +/** + * First run is a checklist, not a paragraph: it reuses state the app already + * tracks and ends in a real button. + */ function WelcomeScreen() { + const { dockerAvailable, imageExists, projects, openProjectHome } = useAppState( + useShallow((s) => ({ + dockerAvailable: s.dockerAvailable, + imageExists: s.imageExists, + projects: s.projects, + openProjectHome: s.openProjectHome, + })), + ); + const [showAdd, setShowAdd] = useState(false); + + const steps: { + label: string; + state: boolean | null; + pendingLabel: string; + failLabel: string; + }[] = [ + { + label: "Docker detected", + state: dockerAvailable, + pendingLabel: "Checking for Docker…", + failLabel: "Docker not available", + }, + { + label: "Container image ready", + state: imageExists, + pendingLabel: "Checking for the image…", + failLabel: "Image not pulled yet — see Settings › Container", + }, + { + label: `${projects.length} project${projects.length === 1 ? "" : "s"} configured`, + state: projects.length > 0 ? true : false, + pendingLabel: "", + failLabel: "No projects yet", + }, + ]; + return ( -
-
-

- Triple-C -

-

Claude Code Container

-

- Add a project from the sidebar, start its container, then open a - terminal to begin using Claude Code in a sandboxed environment. +

+
+

Triple-C

+

+ Claude Code, sandboxed in a container.

+ +
    + {steps.map((step) => ( +
  1. + +
  2. + ))} +
+ +
+ + {projects.length > 0 && ( + + )} +
+ +

+ Then start its container and press{" "} + + Ctrl+T + {" "} + to open a Claude terminal. +

+ + {showAdd && setShowAdd(false)} />}
); diff --git a/app/src/components/DockerInstallDialog.tsx b/app/src/components/DockerInstallDialog.tsx index 69ddef2..cb3d712 100644 --- a/app/src/components/DockerInstallDialog.tsx +++ b/app/src/components/DockerInstallDialog.tsx @@ -1,7 +1,9 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { openUrl } from "@tauri-apps/plugin-opener"; import { useInstallHelper } from "../hooks/useInstallHelper"; import { useDocker } from "../hooks/useDocker"; +import Modal from "./ui/Modal"; +import Button from "./ui/Button"; interface Props { onClose: () => void; @@ -16,27 +18,11 @@ export default function DockerInstallDialog({ onClose }: Props) { const [phase, setPhase] = useState("idle"); const [log, setLog] = useState([]); const [error, setError] = useState(null); - const overlayRef = useRef(null); useEffect(() => { loadOptions(); }, [loadOptions]); - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape" && phase !== "installing") onClose(); - }; - document.addEventListener("keydown", onKey); - return () => document.removeEventListener("keydown", onKey); - }, [onClose, phase]); - - const handleOverlayClick = useCallback( - (e: React.MouseEvent) => { - if (e.target === overlayRef.current && phase !== "installing") onClose(); - }, - [onClose, phase], - ); - const handleInstall = async () => { setPhase("installing"); setLog([]); @@ -70,142 +56,122 @@ export default function DockerInstallDialog({ onClose }: Props) { return null; } - const installVerb = phase === "installing" ? "Installing…" : `Install ${options.product_name}`; + const installVerb = + phase === "installing" ? "Installing…" : `Install ${options.product_name}`; return ( -
+ Dismiss + + ) : undefined + } > -
-

Docker not detected

-

- Triple-C needs a Docker-compatible runtime to manage sandboxed project containers. - We can install {options.product_name}{" "} - for you, or you can follow the official instructions. -

+

+ Triple-C needs a Docker-compatible runtime to manage sandboxed project + containers. We can install{" "} + {options.product_name} for + you, or you can follow the official instructions. +

- {phase === "idle" && ( -
- {options.can_auto_install ? ( - - ) : ( -
- One-click install unavailable:{" "} - - {options.auto_install_blocker ?? "required tooling missing."} - -
- )} - - - - -
- )} - - {phase === "installing" && ( -
- Installing… a system password prompt may appear. Do not close this window. -
- )} - - {phase === "done" && ( -
-
Install finished.
- {options.post_install_notes.length > 0 && ( -
    - {options.post_install_notes.map((note, i) => ( -
  • {note}
  • - ))} -
- )} -
- - + {phase === "idle" && ( +
+ {options.can_auto_install ? ( + + ) : ( +
+ One-click install unavailable:{" "} + + {options.auto_install_blocker ?? "required tooling missing."} +
-
- )} + )} - {phase === "error" && ( -
-
Install failed.
- {error &&
{error}
} -
- - -
-
- )} + - {(showManual || phase === "error") && ( -
-
- Manual install steps -
-
    - {options.manual_steps.map((step, i) => ( -
  1. {step}
  2. + +
+ )} + + {phase === "installing" && ( +
+ Installing… a system password prompt may appear. Do not close this window. +
+ )} + + {phase === "done" && ( +
+
Install finished.
+ {options.post_install_notes.length > 0 && ( +
    + {options.post_install_notes.map((note, i) => ( +
  • {note}
  • ))} - +
+ )} +
+ +
- )} +
+ )} - {log.length > 0 && ( -
- {log.map((line, i) => ( -
{line}
+ {phase === "error" && ( +
+
Install failed.
+ {error && ( +
+ {error} +
+ )} +
+ + +
+
+ )} + + {(showManual || phase === "error") && ( +
+
+ Manual install steps +
+
    + {options.manual_steps.map((step, i) => ( +
  1. {step}
  2. ))} -
- )} + +
+ )} - {phase === "idle" && ( -
- -
- )} -
-
+ {log.length > 0 && ( +
+ {log.map((line, i) => ( +
{line}
+ ))} +
+ )} + ); } diff --git a/app/src/components/layout/HelpDialog.tsx b/app/src/components/layout/HelpDialog.tsx index 2152673..6d2590e 100644 --- a/app/src/components/layout/HelpDialog.tsx +++ b/app/src/components/layout/HelpDialog.tsx @@ -1,5 +1,7 @@ import { useEffect, useRef, useCallback, useState } from "react"; import { getHelpContent } from "../../lib/tauri-commands"; +import Modal from "../ui/Modal"; +import Button from "../ui/Button"; interface Props { onClose: () => void; @@ -140,32 +142,16 @@ function renderMarkdown(md: string): string { } export default function HelpDialog({ onClose }: Props) { - const overlayRef = useRef(null); const contentRef = useRef(null); const [markdown, setMarkdown] = useState(null); const [error, setError] = useState(null); - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); - }; - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [onClose]); - useEffect(() => { getHelpContent() .then(setMarkdown) .catch((e) => setError(String(e))); }, []); - const handleOverlayClick = useCallback( - (e: React.MouseEvent) => { - if (e.target === overlayRef.current) onClose(); - }, - [onClose], - ); - // Handle anchor link clicks to scroll within the dialog const handleContentClick = useCallback((e: React.MouseEvent) => { const target = e.target as HTMLElement; @@ -179,40 +165,25 @@ export default function HelpDialog({ onClose }: Props) { }, []); return ( -
Close} > -
- {/* Header */} -
-

How to Use Triple-C

- -
- - {/* Scrollable content */} -
- {error && ( -

Failed to load help content: {error}

- )} - {!markdown && !error && ( -

Loading...

- )} - {markdown && ( -
- )} -
+
+ {error && ( +

+ Failed to load help content: {error} +

+ )} + {!markdown && !error && ( +

Loading…

+ )} + {markdown && ( +
+ )}
-
+ ); } diff --git a/app/src/components/layout/MainTabs.tsx b/app/src/components/layout/MainTabs.tsx new file mode 100644 index 0000000..5d768c6 --- /dev/null +++ b/app/src/components/layout/MainTabs.tsx @@ -0,0 +1,328 @@ +import { useEffect, useRef, useState } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { useTerminal } from "../../hooks/useTerminal"; +import { useProjects } from "../../hooks/useProjects"; +import { + useAppState, + isHomeTab, + tabKeyId, + terminalTabKey, +} from "../../store/appState"; +import { effectivePermissionMode } from "../projects/PermissionModeControl"; +import { ProjectStatusIndicator } from "../ui/StatusIndicator"; +import type { PermissionMode } from "../../lib/types"; + +interface ContextMenuState { + sessionId: string; + x: number; + y: number; +} + +const MODE_BADGE: Record = { + plan: { text: "plan", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" }, + default: { text: "ask", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" }, + acceptEdits: { text: "edits", className: "bg-[var(--accent-muted)] text-[var(--accent)]" }, + bypass: { text: "bypass", className: "bg-[var(--warning-muted)] text-[var(--warning)]" }, +}; + +/** + * One strip for both main-area tab kinds: Project Home views (⌂) and + * terminals (▣). + */ +export default function MainTabs() { + const { sessions, close } = useTerminal(); + const { projects, update } = useProjects(); + const { tabOrder, activeTabKey, setActiveTabKey, closeHomeTab } = useAppState( + useShallow((s) => ({ + tabOrder: s.tabOrder, + activeTabKey: s.activeTabKey, + setActiveTabKey: s.setActiveTabKey, + closeHomeTab: s.closeHomeTab, + })), + ); + const [menu, setMenu] = useState(null); + const [renamingId, setRenamingId] = useState(null); + const [renameDraft, setRenameDraft] = useState(""); + const renameInputRef = useRef(null); + + useEffect(() => { + if (!menu) return; + const dismiss = () => setMenu(null); + window.addEventListener("click", dismiss); + window.addEventListener("scroll", dismiss, true); + return () => { + window.removeEventListener("click", dismiss); + window.removeEventListener("scroll", dismiss, true); + }; + }, [menu]); + + useEffect(() => { + if (renamingId) { + renameInputRef.current?.focus(); + renameInputRef.current?.select(); + } + }, [renamingId]); + + if (tabOrder.length === 0) { + return ( +
+ No open tabs — select a project to open its home view. +
+ ); + } + + const getCustomName = (projectId: string, sessionId: string): string | null => { + const project = projects.find((p) => p.id === projectId); + return project?.renamed_session_names?.[sessionId] ?? null; + }; + + const startRename = (sessionId: string) => { + const session = sessions.find((s) => s.id === sessionId); + if (!session) return; + const current = + getCustomName(session.projectId, sessionId) ?? + session.sessionName ?? + session.projectName; + setRenameDraft(current); + setRenamingId(sessionId); + setMenu(null); + }; + + const commitRename = async (sessionId: string) => { + const session = sessions.find((s) => s.id === sessionId); + if (!session) { + setRenamingId(null); + return; + } + const project = projects.find((p) => p.id === session.projectId); + if (!project) { + setRenamingId(null); + return; + } + const trimmed = renameDraft.trim(); + const map = { ...(project.renamed_session_names ?? {}) }; + if (trimmed) { + map[sessionId] = trimmed; + } else { + delete map[sessionId]; + } + try { + await update({ ...project, renamed_session_names: map }); + } catch (err) { + console.error("Failed to rename terminal tab:", err); + } finally { + setRenamingId(null); + } + }; + + const clearCustomName = async (sessionId: string) => { + const session = sessions.find((s) => s.id === sessionId); + if (!session) return; + const project = projects.find((p) => p.id === session.projectId); + if (!project) return; + const map = { ...(project.renamed_session_names ?? {}) }; + if (!(sessionId in map)) { + setMenu(null); + return; + } + delete map[sessionId]; + try { + await update({ ...project, renamed_session_names: map }); + } catch (err) { + console.error("Failed to reset terminal tab name:", err); + } finally { + setMenu(null); + } + }; + + const tabClass = (active: boolean) => + `flex items-center gap-1.5 pl-3 pr-1.5 h-full text-xs cursor-pointer border-r border-[var(--border-color)] transition-colors ${ + active + ? "bg-[var(--bg-primary)] text-[var(--text-primary)]" + : "text-[var(--text-secondary)] hover:text-[var(--text-primary)]" + }`; + + return ( +
+ {tabOrder.map((key) => { + const active = activeTabKey === key; + + if (isHomeTab(key)) { + const projectId = tabKeyId(key); + const project = projects.find((p) => p.id === projectId); + if (!project) return null; + return ( +
setActiveTabKey(key)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setActiveTabKey(key); + } + }} + className={tabClass(active)} + > + + + {project.name} + + + +
+ ); + } + + const sessionId = tabKeyId(key); + const session = sessions.find((s) => s.id === sessionId); + if (!session) return null; + const project = projects.find((p) => p.id === session.projectId); + const customName = getCustomName(session.projectId, session.id); + const baseLabel = + (session.sessionName ?? session.projectName) + + (session.sessionType === "bash" ? " (bash)" : ""); + const displayLabel = customName + ? `${session.projectName}: ${customName}` + : baseLabel; + const isRenaming = renamingId === session.id; + const badge = project ? MODE_BADGE[effectivePermissionMode(project)] : null; + + return ( +
setActiveTabKey(terminalTabKey(session.id))} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setActiveTabKey(terminalTabKey(session.id)); + } + }} + onContextMenu={(e) => { + e.preventDefault(); + setMenu({ sessionId: session.id, x: e.clientX, y: e.clientY }); + }} + onDoubleClick={() => startRename(session.id)} + className={tabClass(active)} + > + + {isRenaming ? ( + setRenameDraft(e.target.value)} + onClick={(e) => e.stopPropagation()} + onBlur={() => commitRename(session.id)} + onKeyDown={(e) => { + if (e.key === "Enter") (e.target as HTMLInputElement).blur(); + if (e.key === "Escape") setRenamingId(null); + }} + className="max-w-[180px] px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)] rounded-[var(--radius-control)] text-xs text-[var(--text-primary)]" + /> + ) : ( + + {displayLabel} + + )} + {badge && ( + + {badge.text} + + )} + +
+ ); + })} + + {menu && (() => { + const session = sessions.find((s) => s.id === menu.sessionId); + const hasCustom = session + ? !!getCustomName(session.projectId, menu.sessionId) + : false; + return ( +
e.stopPropagation()} + > + + {hasCustom && ( + + )} + {session && ( + + )} +
+ +
+ ); + })()} +
+ ); +} diff --git a/app/src/components/layout/Sidebar.test.tsx b/app/src/components/layout/Sidebar.test.tsx index d1072dd..03ea549 100644 --- a/app/src/components/layout/Sidebar.test.tsx +++ b/app/src/components/layout/Sidebar.test.tsx @@ -34,6 +34,12 @@ describe("Sidebar", () => { expect(screen.getByText("Settings")).toBeInTheDocument(); }); + it("renders the project list, not a settings form, in the projects view", () => { + render(); + expect(screen.getByTestId("project-list")).toBeInTheDocument(); + expect(screen.queryByTestId("settings-panel")).not.toBeInTheDocument(); + }); + it("content area has min-w-0 to prevent flex overflow", () => { const { container } = render(); const contentArea = container.querySelector(".overflow-y-auto"); diff --git a/app/src/components/layout/Sidebar.tsx b/app/src/components/layout/Sidebar.tsx index 3e2b719..af4824e 100644 --- a/app/src/components/layout/Sidebar.tsx +++ b/app/src/components/layout/Sidebar.tsx @@ -63,7 +63,7 @@ export default function Sidebar() { }; return ( -
+
)} {imageUpdateInfo && ( )} - - + + @@ -103,15 +118,29 @@ export default function TopBar() { ); } -function StatusDot({ ok, label }: { ok: boolean; label: string }) { - return ( - - - {label} - - ); +/** + * `null` (still checking) is visually distinct and pulses; `false` is an + * outage and renders red — previously both fell through to the same gray dot. + */ +function HealthDot({ + state, + okLabel, + failLabel, + pendingLabel, +}: { + state: boolean | null; + okLabel: string; + failLabel: string; + pendingLabel: string; +}) { + let tone: StatusTone = "unknown"; + let label = pendingLabel; + if (state === true) { + tone = "ok"; + label = okLabel; + } else if (state === false) { + tone = "error"; + label = failLabel; + } + return ; } diff --git a/app/src/components/projects/AddProjectDialog.tsx b/app/src/components/projects/AddProjectDialog.tsx index 856fb63..72149a2 100644 --- a/app/src/components/projects/AddProjectDialog.tsx +++ b/app/src/components/projects/AddProjectDialog.tsx @@ -1,7 +1,10 @@ -import { useState, useEffect, useRef, useCallback } from "react"; +import { useId, useRef, useState } from "react"; import { open } from "@tauri-apps/plugin-dialog"; import { useProjects } from "../../hooks/useProjects"; import type { ProjectPath } from "../../lib/types"; +import Modal from "../ui/Modal"; +import Button from "../ui/Button"; +import { inputClass, monoInputClass } from "../ui/Field"; interface Props { onClose: () => void; @@ -25,26 +28,7 @@ export default function AddProjectDialog({ onClose }: Props) { const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const nameInputRef = useRef(null); - const overlayRef = useRef(null); - - useEffect(() => { - nameInputRef.current?.focus(); - }, []); - - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); - }; - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [onClose]); - - const handleOverlayClick = useCallback( - (e: React.MouseEvent) => { - if (e.target === overlayRef.current) onClose(); - }, - [onClose], - ); + const formId = useId(); const handleBrowse = async (index: number) => { const selected = await open({ directory: true, multiple: false }); @@ -63,24 +47,12 @@ export default function AddProjectDialog({ onClose }: Props) { } }; - const updateEntry = ( - index: number, - field: keyof PathEntry, - value: string, - ) => { + const updateEntry = (index: number, field: keyof PathEntry, value: string) => { const entries = [...pathEntries]; entries[index] = { ...entries[index], [field]: value }; setPathEntries(entries); }; - const removeEntry = (index: number) => { - setPathEntries(pathEntries.filter((_, i) => i !== index)); - }; - - const addEntry = () => { - setPathEntries([...pathEntries, { host_path: "", mount_name: "" }]); - }; - const handleSubmit = async (e?: React.FormEvent) => { if (e) e.preventDefault(); if (!name.trim()) { @@ -115,98 +87,106 @@ export default function AddProjectDialog({ onClose }: Props) { }; return ( -
+ + + + } > -
-

Add Project

- -
-