Four features, plus a latent bug fix.
llama.cpp backend. Claude Code only ever speaks the Anthropic Messages
API — confirmed empirically by pointing it at a logging server, which
received POST /v1/messages?beta=true. llama-server implements that
natively (verified in its README, alongside --port default 8080), so
this is a plain base-URL backend with no translation shim, the same
shape as Ollama. Its --api-key defaults to none, so the auth token is a
placeholder Claude Code requires and llama-server ignores.
Model alias fix. ANTHROPIC_DEFAULT_HAIKU_MODEL is documented as "also
used for background functionality", and Triple-C set none of the alias
vars. So on every custom-endpoint backend, Claude Code resolved `haiku`
to an Anthropic model id and sent it to a local server that does not
have it — background features failed silently. All four
ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU,FABLE}_MODEL vars are now pinned to
the backend's configured model, with an optional Haiku override, and
blanked for Anthropic and Bedrock so those keep Claude Code's defaults.
The deprecated ANTHROPIC_SMALL_FAST_MODEL is never emitted. Existing
Ollama and OpenAI-Compatible containers are recreated once so the new
env reaches them; the snapshot is preserved.
Model gateway. Optional LiteLLM sibling container, off by default,
mirroring stt.rs — this is what makes real OpenAI usable, since
api.openai.com has no /v1/messages. Pinned to v1.96.0 by tag and digest:
the 1.82.7/1.82.8 malware was PyPI-only and never affected the official
images, which is precisely why this builds FROM the image rather than
pip-installing, but 1.84.0 is still the floor for proxy CVEs (API-key
SQLi, Host-header auth bypass, MCP auth bypass). Binds 0.0.0.0 because
project containers consume it, and therefore always sets a master_key —
LiteLLM without one accepts any key. The provider key lives in the OS
keychain and is uploaded into a volume, never an image layer or label.
URL relay. A container-side xdg-open/BROWSER shim opens URLs in the
host's browser. Uses an OSC sequence to /dev/tty rather than a printed
sentinel, because the shim usually runs as a grandchild of a process
capturing its children's output. Degrades to printing the URL when no
terminal is attached, so scheduled tasks do not hang. Only http/https,
with control characters rejected before new URL() — which strips
newlines, so java\nscript: would otherwise parse as javascript:. Nothing
auto-opens; the user confirms. The web terminal shows a tap-to-open
banner instead, since that browser may be a phone across a tunnel.
Browser view. A Project Home tab that watches and takes over the browser
Claude drives with Playwright, using Playwright's own dashboard. Zero
image cost — Playwright stays user-installed. It does not reuse the auth
bridge's PortForward, which binds an unauthenticated port: correct for a
throwaway OAuth listener, wrong for mouse and keyboard control of a
browser in a passwordless-sudo container. Instead a token-gated loopback
proxy checks Host, then token or a forbidden-header origin signal,
before a byte reaches the container. Host ports are confined to
47820..=47827 so CSP frame-src can enumerate them rather than widening
to a wildcard, with a test asserting the two agree.
188 frontend tests, 107 Rust tests, both builds clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
272 lines
12 KiB
Rust
272 lines
12 KiB
Rust
//! 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);
|
|
let entry = keyring::Entry::new(&service, "secret")
|
|
.map_err(|e| format!("Keyring error: {}", e))?;
|
|
entry
|
|
.set_password(value)
|
|
.map_err(|e| format!("Failed to store project secret '{}': {}", key_name, e))
|
|
}
|
|
|
|
/// Retrieve a per-project secret from the OS keychain.
|
|
pub fn get_project_secret(project_id: &str, key_name: &str) -> Result<Option<String>, String> {
|
|
let service = format!("triple-c-project-{}-{}", project_id, key_name);
|
|
let entry = keyring::Entry::new(&service, "secret")
|
|
.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 project secret '{}': {}", key_name, e)),
|
|
}
|
|
}
|
|
|
|
/// Delete all known secrets for a project from the OS keychain.
|
|
pub fn delete_project_secrets(project_id: &str) -> Result<(), String> {
|
|
let secret_keys = [
|
|
"git-token",
|
|
"aws-access-key-id",
|
|
"aws-secret-access-key",
|
|
"aws-session-token",
|
|
"aws-bearer-token",
|
|
];
|
|
for key_name in &secret_keys {
|
|
let service = format!("triple-c-project-{}-{}", project_id, key_name);
|
|
let entry = keyring::Entry::new(&service, "secret")
|
|
.map_err(|e| format!("Keyring error: {}", e))?;
|
|
match entry.delete_credential() {
|
|
Ok(()) => {}
|
|
Err(keyring::Error::NoEntry) => {}
|
|
Err(e) => {
|
|
log::warn!("Failed to delete project secret '{}': {}", key_name, e);
|
|
}
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Model gateway secrets (global, not per project)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// Keychain service for the upstream provider API key (OpenAI etc.) the
|
|
/// LiteLLM gateway authenticates to the model provider with. This value is
|
|
/// written into the gateway's generated `config.yaml`, which is uploaded
|
|
/// straight into the container over the Docker API — it is never an env var,
|
|
/// never a Docker label, and is never returned to the frontend.
|
|
const GATEWAY_API_KEY_SERVICE: &str = "triple-c-gateway-provider-api-key";
|
|
|
|
/// Keychain service for the gateway's **master key** — the credential a
|
|
/// *project* presents to the gateway as `ANTHROPIC_AUTH_TOKEN`. Unlike the
|
|
/// provider key this one is minted by Triple-C and must be readable by the
|
|
/// user, since they have to paste it into a project's model config.
|
|
const GATEWAY_MASTER_KEY_SERVICE: &str = "triple-c-gateway-master-key";
|
|
|
|
/// Rotation id covering *both* gateway secrets, on the same reasoning as
|
|
/// `CLAUDE_TOKEN_VERSION_SERVICE`: container recreation is driven off Docker
|
|
/// labels, labels are world-readable via `docker inspect`, and a hash of a
|
|
/// secret is a verification oracle. This is unrelated random data that merely
|
|
/// changes whenever either secret does.
|
|
const GATEWAY_SECRET_VERSION_SERVICE: &str = "triple-c-gateway-secret-version";
|
|
|
|
/// Mint a fresh gateway rotation id. Called after either gateway secret moves.
|
|
fn bump_gateway_secret_version() -> Result<(), String> {
|
|
let version = uuid::Uuid::new_v4().to_string();
|
|
let entry = keyring::Entry::new(GATEWAY_SECRET_VERSION_SERVICE, KEYCHAIN_ACCOUNT)
|
|
.map_err(|e| format!("Keyring error: {}", e))?;
|
|
entry
|
|
.set_password(&version)
|
|
.map_err(|e| format!("Failed to store the gateway secret rotation id: {}", e))
|
|
}
|
|
|
|
/// The rotation id of the currently stored gateway secrets. Opaque random
|
|
/// data — safe to put in a Docker label, unlike either secret.
|
|
pub fn get_gateway_secret_version() -> Result<Option<String>, String> {
|
|
read_entry(
|
|
GATEWAY_SECRET_VERSION_SERVICE,
|
|
"the gateway secret rotation id",
|
|
)
|
|
}
|
|
|
|
/// Store the provider API key, replacing any previous one. Blank input is
|
|
/// rejected rather than silently stored.
|
|
pub fn store_gateway_api_key(key: &str) -> Result<(), String> {
|
|
if key.trim().is_empty() {
|
|
return Err("Refusing to store an empty gateway provider API key.".to_string());
|
|
}
|
|
|
|
let entry = keyring::Entry::new(GATEWAY_API_KEY_SERVICE, KEYCHAIN_ACCOUNT)
|
|
.map_err(|e| format!("Keyring error: {}", e))?;
|
|
entry
|
|
.set_password(key.trim())
|
|
.map_err(|e| format!("Failed to store the gateway provider API key: {}", e))?;
|
|
|
|
// Rotation id second: if this fails the key is still usable, and the stale
|
|
// id only costs one extra container recreation later.
|
|
bump_gateway_secret_version()
|
|
}
|
|
|
|
/// Retrieve the provider API key. **Host-side only** — this is consumed when
|
|
/// rendering the gateway config and must not be handed to the frontend.
|
|
pub fn get_gateway_api_key() -> Result<Option<String>, String> {
|
|
read_entry(GATEWAY_API_KEY_SERVICE, "the gateway provider API key")
|
|
}
|
|
|
|
/// Whether a provider API key is stored. A keychain failure is reported as
|
|
/// "no key" so the UI degrades to the unconfigured state instead of breaking.
|
|
pub fn has_gateway_api_key() -> bool {
|
|
matches!(get_gateway_api_key(), Ok(Some(k)) if !k.trim().is_empty())
|
|
}
|
|
|
|
/// Delete the provider API key and rotate the id so a running gateway holding
|
|
/// the old key is flagged for recreation.
|
|
pub fn delete_gateway_api_key() -> Result<(), String> {
|
|
let delete_result = delete_entry(GATEWAY_API_KEY_SERVICE, "the gateway provider API key");
|
|
let version_result = bump_gateway_secret_version();
|
|
delete_result.and(version_result)
|
|
}
|
|
|
|
/// The gateway master key, minting one on first use.
|
|
///
|
|
/// The gateway is published on a host port so project containers can reach it,
|
|
/// which means an unauthenticated gateway would be an open proxy onto the
|
|
/// user's provider account for anything that can route to the host. LiteLLM
|
|
/// only enforces auth when a master key is configured, so Triple-C always
|
|
/// configures one.
|
|
pub fn get_or_create_gateway_master_key() -> Result<String, String> {
|
|
if let Some(existing) = read_entry(GATEWAY_MASTER_KEY_SERVICE, "the gateway master key")? {
|
|
if !existing.trim().is_empty() {
|
|
return Ok(existing);
|
|
}
|
|
}
|
|
regenerate_gateway_master_key()
|
|
}
|
|
|
|
/// Mint a new gateway master key, invalidating the old one. Projects using the
|
|
/// previous value must be updated.
|
|
pub fn regenerate_gateway_master_key() -> Result<String, String> {
|
|
// LiteLLM requires the master key to start with `sk-`.
|
|
let key = format!("sk-triple-c-{}", uuid::Uuid::new_v4().simple());
|
|
|
|
let entry = keyring::Entry::new(GATEWAY_MASTER_KEY_SERVICE, KEYCHAIN_ACCOUNT)
|
|
.map_err(|e| format!("Keyring error: {}", e))?;
|
|
entry
|
|
.set_password(&key)
|
|
.map_err(|e| format!("Failed to store the gateway master key: {}", e))?;
|
|
|
|
bump_gateway_secret_version()?;
|
|
Ok(key)
|
|
}
|