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:
@@ -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