Add Project Home, Auth Bridge, shared auth token, and Tier-1 polish
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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:<that port>/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<BridgedPort>,
|
||||
pub conflicts: Vec<PortConflict>,
|
||||
}
|
||||
|
||||
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<Mutex<…>>` so `get_auth_bridge_status` can read them without
|
||||
/// disturbing the poller.
|
||||
#[derive(Default)]
|
||||
struct BridgeState {
|
||||
forwards: BTreeMap<u16, PortForward>,
|
||||
conflicts: BTreeMap<u16, String>,
|
||||
}
|
||||
|
||||
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<bool>,
|
||||
state: Arc<Mutex<BridgeState>>,
|
||||
poller: JoinHandle<()>,
|
||||
}
|
||||
|
||||
type BridgeMap = Arc<Mutex<HashMap<String, ProjectBridge>>>;
|
||||
|
||||
#[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<ProjectsStore>,
|
||||
) {
|
||||
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<ProjectsStore>,
|
||||
state: Arc<Mutex<BridgeState>>,
|
||||
bridges: BridgeMap,
|
||||
mut cancel: watch::Receiver<bool>,
|
||||
) {
|
||||
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<u16> {
|
||||
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<u16, PortFamily>,
|
||||
skip: &HashSet<u16>,
|
||||
state: &Arc<Mutex<BridgeState>>,
|
||||
) -> 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<u16> = 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<Mutex<BridgeState>>) {
|
||||
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<Mutex<BridgeState>>,
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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 `<address>:<port>`, 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<u16, PortFamily> {
|
||||
let mut ports: BTreeMap<u16, PortFamily> = 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<LoopbackListener> {
|
||||
text.lines().filter_map(parse_listener_row).collect()
|
||||
}
|
||||
|
||||
fn parse_listener_row(line: &str) -> Option<LoopbackListener> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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:<port>`, 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<Self, std::io::Error> {
|
||||
// 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<TcpListener>,
|
||||
) {
|
||||
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);
|
||||
}
|
||||
@@ -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<AuthBridgeStatus, String> {
|
||||
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<AuthBridgeStatus, String> {
|
||||
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)
|
||||
}
|
||||
@@ -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-<your-token>` 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-<redacted>";
|
||||
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<String> {
|
||||
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<u8>,
|
||||
}
|
||||
|
||||
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<Mutex<Option<mpsc::UnboundedSender<Vec<u8>>>>> = OnceLock::new();
|
||||
|
||||
fn pending_input() -> &'static Mutex<Option<mpsc::UnboundedSender<Vec<u8>>>> {
|
||||
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<Vec<u8>>,
|
||||
) -> Result<String, String> {
|
||||
// A pty (`tty = true`) because `setup-token` renders an interactive TUI and
|
||||
// reads the pasted code in raw mode, which a plain pipe cannot provide.
|
||||
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::<Vec<u8>>();
|
||||
{
|
||||
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<bool, String> {
|
||||
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::<String>())
|
||||
}
|
||||
|
||||
#[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-<your-token-here>\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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Project, String> {
|
||||
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<Vec<Project>, 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",
|
||||
|
||||
@@ -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<String> = 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<EnvVar> {
|
||||
@@ -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<String> = 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);
|
||||
|
||||
@@ -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<Box<dyn Stream<Item = Result<LogOutput, bollard::errors::Error>> + Send>>,
|
||||
pub input: Pin<Box<dyn AsyncWrite + Send>>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
tty: bool,
|
||||
) -> Result<AttachedExec, String> {
|
||||
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<u8>) + 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::<Vec<u8>>();
|
||||
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,
|
||||
|
||||
@@ -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<ProjectsStore>,
|
||||
pub settings_store: Arc<SettingsStore>,
|
||||
pub exec_manager: Arc<ExecSessionManager>,
|
||||
pub auth_bridge: Arc<AuthBridgeManager>,
|
||||
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String>) -> Result<(), String> {
|
||||
let mut projects = self.lock();
|
||||
if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) {
|
||||
|
||||
@@ -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<Option<String>, 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<Option<String>, 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<Option<String>, 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user