Add llama.cpp backend, model gateway, URL relay and browser view
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>
This commit is contained in:
@@ -156,3 +156,116 @@ pub fn delete_claude_oauth_token() -> Result<(), String> {
|
||||
);
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user