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:
2026-08-09 16:55:28 -07:00
co-authored by Claude Opus 5
parent 7d00390e1f
commit cc5f691677
46 changed files with 6194 additions and 61 deletions
+51 -4
View File
@@ -362,14 +362,43 @@ async fn poll_loop(
/// 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.
///
/// [`RESERVED_CONTAINER_PORTS`] is folded in as well: those are container
/// loopback listeners another feature owns and exposes on its own,
/// authenticated terms.
fn skipped_ports(project: &crate::models::Project) -> HashSet<u16> {
project
let mut skip: HashSet<u16> = project
.port_mappings
.iter()
.flat_map(|m| [m.container_port, m.host_port])
.collect()
.collect();
skip.extend(RESERVED_CONTAINER_PORTS.clone());
skip
}
// ─────────────────────────────────────────────────────────────────────────────
// Reservations
// ─────────────────────────────────────────────────────────────────────────────
/// Container loopback ports another feature owns, which the bridge must leave
/// alone.
///
/// The bridge's contract is "mirror every container loopback listener onto the
/// same host port, **unauthenticated**" — correct for the throwaway OAuth
/// callback listeners it exists for, wrong for anything sensitive. The
/// browser-view pane runs Playwright's dashboard on a container loopback port
/// in this range and puts a token-gated listener in front of it; mirroring that
/// port here would quietly publish an ungated second door to full control of a
/// browser inside the container.
///
/// This is a constant rather than a registry the pane populates at runtime, and
/// that is the point: Playwright's dashboard is a detached daemon that outlives
/// the app, so after a crash an orphaned viewer can still be listening with
/// nothing in this process left to remember it. A static range is the only form
/// of the rule that survives a restart. It must stay in step with
/// `browser_view::VIEWER_PORTS`, which asserts on it.
pub const RESERVED_CONTAINER_PORTS: std::ops::RangeInclusive<u16> = 39321..=39328;
/// 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(
@@ -519,7 +548,25 @@ mod tests {
}
#[test]
fn no_mappings_means_nothing_is_skipped() {
assert!(skipped_ports(&project_with_mappings(vec![])).is_empty());
fn no_mappings_means_nothing_but_the_reserved_range_is_skipped() {
let skip = skipped_ports(&project_with_mappings(vec![]));
assert_eq!(skip.len(), RESERVED_CONTAINER_PORTS.clone().count());
}
#[test]
fn the_browser_views_ports_are_never_mirrored() {
// Mirroring these would publish an ungated second door to the
// Playwright dashboard, which the pane deliberately keeps behind a
// token-checking listener.
let skip = skipped_ports(&project_with_mappings(vec![]));
for port in RESERVED_CONTAINER_PORTS {
assert!(skip.contains(&port), "port {} should be reserved", port);
}
assert!(!skip.contains(&(RESERVED_CONTAINER_PORTS.end() + 1)));
// Reservations coexist with Docker's own published ports.
let skip = skipped_ports(&project_with_mappings(vec![(3000, 3000)]));
assert!(skip.contains(RESERVED_CONTAINER_PORTS.start()));
assert!(skip.contains(&3000));
}
}
+25
View File
@@ -172,6 +172,24 @@ async fn accept_optional(
/// Carry one accepted host connection into the container over `socat`.
async fn tunnel_connection(container_id: String, target: String, stream: TcpStream, port: u16) {
tunnel_connection_with_prelude(container_id, target, stream, port, Vec::new()).await
}
/// As [`tunnel_connection`], but `prelude` is written into the container first,
/// ahead of anything further read from `stream`.
///
/// This exists for callers that must *inspect* the beginning of a connection
/// before deciding to forward it — the browser-view proxy reads the HTTP request
/// head off the socket to check a token, and then has to put those same bytes
/// back on the wire. Passing them here keeps the byte stream exact, rather than
/// re-serialising a parsed request.
pub async fn tunnel_connection_with_prelude(
container_id: String,
target: String,
stream: TcpStream,
port: u16,
prelude: Vec<u8>,
) {
let cmd = vec!["socat".to_string(), "-".to_string(), target.clone()];
let AttachedExec {
@@ -198,6 +216,13 @@ async fn tunnel_connection(container_id: String, target: String, stream: TcpStre
// 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 {
// Bytes the caller already consumed from the socket go first, so the
// container sees the connection exactly as the client sent it.
if !prelude.is_empty()
&& (input.write_all(&prelude).await.is_err() || input.flush().await.is_err())
{
return;
}
let mut buf = vec![0u8; PUMP_BUF];
loop {
match host_rx.read(&mut buf).await {
@@ -0,0 +1,78 @@
//! IPC surface for the browser view pane. The mechanism lives in
//! [`crate::browser_view`]; this file only translates between it and the
//! frontend.
use tauri::{AppHandle, State};
use crate::browser_view::{manager, BrowserViewStatus};
use crate::AppState;
/// Turn the pane on or off for a project.
///
/// Enabling probes the container and brings the viewer up when it can; a
/// container that isn't running, or one without Playwright, comes back as a
/// non-`Running` status carrying an explanation rather than an error, so the
/// pane always has something specific to say. This is host-side only — no
/// container recreation is involved either way.
#[tauri::command]
pub async fn set_browser_view_enabled(
project_id: String,
enabled: bool,
app_handle: AppHandle,
state: State<'_, AppState>,
) -> Result<BrowserViewStatus, String> {
if !enabled {
// Awaits the supervisor, so the host port is released before we return.
manager().stop(&project_id).await;
return Ok(manager().status(&project_id).await);
}
let project = state
.projects_store
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let Some(container_id) = project.container_id.clone() else {
return Err("Start the container before opening the browser view.".to_string());
};
if !crate::docker::container::is_container_running(&container_id)
.await
.unwrap_or(false)
{
return Err("Start the container before opening the browser view.".to_string());
}
manager()
.start(
project_id,
container_id,
app_handle,
state.projects_store.clone(),
)
.await
}
/// Current status. Cheap: reads in-process state only, never the container.
#[tauri::command]
pub async fn get_browser_view_status(project_id: String) -> Result<BrowserViewStatus, String> {
Ok(manager().status(&project_id).await)
}
/// Probe the container for Playwright without starting anything.
///
/// Lets the pane say "install this" before the user asks for a view, and lets
/// them re-check after installing without toggling the feature.
#[tauri::command]
pub async fn check_browser_view_support(
project_id: String,
state: State<'_, AppState>,
) -> Result<crate::browser_view::detect::PlaywrightDetection, String> {
let project = state
.projects_store
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let container_id = project
.container_id
.ok_or_else(|| "Start the container to check for Playwright.".to_string())?;
crate::browser_view::detect::detect(&container_id).await
}
+263
View File
@@ -0,0 +1,263 @@
//! Is there anything in this container worth watching, and can we serve a viewer
//! for it?
//!
//! Playwright is **not** in the container image — it is installed by the user or
//! by Claude, into whichever `node_modules` happens to be in scope. So detection
//! has to be done inside the container, at the moment the pane is opened, and it
//! has to produce an *actionable* answer when the pieces are missing: the pane's
//! one unforgivable failure mode would be an unexplained spinner.
//!
//! Three things must line up:
//!
//! 1. **`playwright-core`** (directly, or via `playwright`, which re-exports it),
//! 2. at a version whose `Browser` exposes **`bind()`** — the live-dashboard API
//! that publishes a browser for a viewer to attach to, and
//! 3. **`@playwright/cli`**, which ships the viewer UI itself.
//!
//! Discovery of published browsers is local-filesystem based (a cache directory
//! plus a unix-socket singleton in the temp dir), which is exactly why the viewer
//! has to run *in the container* next to the browsers rather than on the host.
use serde::{Deserialize, Serialize};
use crate::docker::exec::exec_oneshot;
/// Marks the JSON payload in the probe's stdout, so unrelated chatter on the
/// same stream (npm notices, Node warnings) can't be mistaken for the result.
const MARKER: &str = "__TRIPLE_C_BROWSER_VIEW__";
/// What the probe found. Serialised straight to the frontend so the pane can
/// explain itself precisely rather than saying "not available".
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PlaywrightDetection {
/// Node's own version, if `node` ran at all.
#[serde(default)]
pub node_version: Option<String>,
/// Resolved `playwright-core` (or `playwright`) version.
#[serde(default)]
pub playwright_version: Option<String>,
/// Absolute path of the resolved package manifest, for the diagnostics line.
#[serde(default)]
pub playwright_path: Option<String>,
/// Whether the resolved build's type definitions declare `Browser.bind()`.
#[serde(default)]
pub has_bind: bool,
/// Resolved `@playwright/cli` version — the package that serves the viewer.
#[serde(default)]
pub cli_version: Option<String>,
/// Absolute path of `@playwright/cli`'s entry script. Invoked with `node`
/// directly rather than through its bin shim, so the viewer's PID is the one
/// we can signal.
#[serde(default)]
pub cli_entry: Option<String>,
/// Where the probe looked, echoed back for the "not found" message.
#[serde(default)]
pub searched: Vec<String>,
}
impl PlaywrightDetection {
/// Everything needed to actually serve the pane.
pub fn is_usable(&self) -> bool {
self.playwright_version.is_some() && self.has_bind && self.cli_entry.is_some()
}
/// A specific, actionable explanation of what is missing. `None` when the
/// container is ready.
pub fn blocker(&self) -> Option<String> {
if self.node_version.is_none() {
return Some(
"Node.js isn't runnable in this container, so Playwright can't be detected."
.to_string(),
);
}
if self.playwright_version.is_none() {
return Some(format!(
"Playwright isn't installed in this container. Install it with \
`npm i -D playwright` (or `npm i -g playwright`), then have Claude call \
`await browser.bind('claude')` after launching a browser — or use \
`@playwright/mcp`, which binds automatically. Looked in: {}.",
if self.searched.is_empty() {
"the container's default module paths".to_string()
} else {
self.searched.join(", ")
}
));
}
if !self.has_bind {
return Some(format!(
"Playwright {} is installed, but it predates the live-dashboard API \
(`browser.bind()`). Upgrade with `npm i -D playwright@latest` and restart \
the browser Claude is driving.",
self.playwright_version.as_deref().unwrap_or("?")
));
}
if self.cli_entry.is_none() {
return Some(
"Playwright is installed, but the viewer UI package isn't. Install it with \
`npm i -D @playwright/cli`, then reopen this tab."
.to_string(),
);
}
None
}
}
/// One `node -e` probe, run as `claude` inside the container.
///
/// No shell quoting is involved: the script is a single `argv` element. The
/// script finds the global `node_modules` root itself, so a Playwright installed
/// with `npm i -g` is found as readily as one in `/workspace/node_modules`.
pub async fn detect(container_id: &str) -> Result<PlaywrightDetection, String> {
let output = exec_oneshot(
container_id,
vec!["node".to_string(), "-e".to_string(), PROBE.to_string()],
)
.await?;
parse_probe_output(&output)
}
/// Pull the marked JSON object out of the probe's combined output.
///
/// `exec_oneshot` interleaves stdout and stderr, and Node happily writes
/// deprecation warnings to the latter, so the payload is located by marker
/// rather than by assuming it is the whole stream.
pub(crate) fn parse_probe_output(output: &str) -> Result<PlaywrightDetection, String> {
let start = output.find(MARKER).ok_or_else(|| {
let trimmed = output.trim();
if trimmed.is_empty() {
"Playwright detection produced no output. Is Node.js present in the container?"
.to_string()
} else {
format!(
"Playwright detection failed: {}",
trimmed.lines().next_back().unwrap_or(trimmed)
)
}
})? + MARKER.len();
// The payload runs to the end of that line; anything the probe's own
// children wrote afterwards is not ours.
let json = output[start..].lines().next().unwrap_or("").trim();
serde_json::from_str(json)
.map_err(|e| format!("Could not read the Playwright detection result: {}", e))
}
/// The probe. Kept as one string so the quoting story is "there isn't one".
///
/// Deliberately tolerant: every lookup is individually guarded, because a
/// half-installed `node_modules` must produce a *partial* answer that
/// [`PlaywrightDetection::blocker`] can turn into advice, not an exception that
/// produces "detection failed".
const PROBE: &str = concat!(
r#"const fs=require("fs"),path=require("path"),cp=require("child_process");"#,
r#"const out={node_version:process.versions.node,searched:[],has_bind:false};"#,
// `npm root -g` is the only reliable way to learn the global prefix, and it
// is cheap enough to pay for once per pane open.
r#"let g=null;try{g=cp.execSync("npm root -g",{encoding:"utf8",stdio:["ignore","pipe","ignore"]}).trim()||null;}catch(e){}"#,
r#"const roots=[...new Set(["/workspace",process.cwd(),process.env.HOME?path.join(process.env.HOME,"node_modules"):null,g].filter(Boolean))];"#,
r#"out.searched=roots;"#,
r#"const res=(s)=>{for(const r of roots){try{return require.resolve(s,{paths:[r]});}catch(e){}}return null;};"#,
r#"const core=res("playwright-core/package.json")||res("playwright/package.json");"#,
r#"if(core){try{out.playwright_path=core;out.playwright_version=JSON.parse(fs.readFileSync(core,"utf8")).version;}catch(e){}"#,
// `bind`/`unbind` are checked against the shipped type definitions rather
// than by loading the module: it is a static read, needs no browser, and
// cannot be tripped up by a package that fails to import.
r#"try{const t=fs.readFileSync(path.join(path.dirname(core),"types","types.d.ts"),"utf8");"#,
r#"out.has_bind=/\bunbind\s*\(\s*\)/.test(t)&&/\bbind\s*\(/.test(t);}catch(e){}}"#,
r#"const cli=res("@playwright/cli/package.json");"#,
r#"if(cli){try{const j=JSON.parse(fs.readFileSync(cli,"utf8"));out.cli_version=j.version;"#,
r#"const b=typeof j.bin==="string"?{[j.name]:j.bin}:(j.bin||{});const k=Object.keys(b)[0];"#,
r#"if(k)out.cli_entry=path.resolve(path.dirname(cli),b[k]);}catch(e){}}"#,
r#"process.stdout.write("\n__TRIPLE_C_BROWSER_VIEW__"+JSON.stringify(out)+"\n");"#,
);
#[cfg(test)]
mod tests {
use super::*;
fn payload(json: &str) -> String {
format!("some npm noise\n{}{}\n", MARKER, json)
}
#[test]
fn a_complete_install_is_usable() {
let d = parse_probe_output(&payload(
r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true,"cli_version":"0.1.18","cli_entry":"/workspace/node_modules/@playwright/cli/playwright-cli.js","searched":["/workspace"]}"#,
))
.unwrap();
assert!(d.is_usable());
assert_eq!(d.blocker(), None);
}
#[test]
fn stderr_noise_before_and_after_the_payload_is_ignored() {
let out = format!(
"(node:41) Warning: something\n{}{}\nnpm notice trailing\n",
MARKER, r#"{"node_version":"22.11.0","has_bind":false}"#
);
let d = parse_probe_output(&out).unwrap();
assert_eq!(d.node_version.as_deref(), Some("22.11.0"));
}
#[test]
fn a_missing_playwright_is_reported_with_where_we_looked() {
let d = parse_probe_output(&payload(
r#"{"node_version":"22.11.0","searched":["/workspace","/usr/lib/node_modules"]}"#,
))
.unwrap();
assert!(!d.is_usable());
let msg = d.blocker().unwrap();
assert!(msg.contains("npm i -D playwright"), "{}", msg);
assert!(msg.contains("browser.bind"), "{}", msg);
assert!(msg.contains("/usr/lib/node_modules"), "{}", msg);
}
#[test]
fn a_playwright_without_bind_asks_for_an_upgrade() {
let d = parse_probe_output(&payload(
r#"{"node_version":"22.11.0","playwright_version":"1.44.0","has_bind":false,"cli_entry":"/x/cli.js"}"#,
))
.unwrap();
let msg = d.blocker().unwrap();
assert!(msg.contains("1.44.0"), "{}", msg);
assert!(msg.contains("playwright@latest"), "{}", msg);
}
#[test]
fn a_missing_viewer_package_is_reported_separately() {
let d = parse_probe_output(&payload(
r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true}"#,
))
.unwrap();
assert!(!d.is_usable());
assert!(d.blocker().unwrap().contains("@playwright/cli"));
}
#[test]
fn a_container_without_node_says_so() {
let d = parse_probe_output(&payload(r#"{"has_bind":false}"#)).unwrap();
assert!(d.blocker().unwrap().contains("Node.js"));
}
#[test]
fn an_unmarked_stream_surfaces_the_containers_own_error() {
let err = parse_probe_output("sh: 1: node: not found\n").unwrap_err();
assert!(err.contains("node: not found"), "{}", err);
}
#[test]
fn an_empty_stream_is_explained_rather_than_parsed() {
let err = parse_probe_output(" \n").unwrap_err();
assert!(err.contains("no output"), "{}", err);
}
#[test]
fn the_probe_is_a_single_argv_element_with_no_quoting_hazards() {
// It is passed straight to `node -e`; a stray single quote would only
// matter if someone later routed it through a shell, and a newline
// would break the marker-line contract in `parse_probe_output`.
assert!(!PROBE.contains('\n'));
assert!(PROBE.contains(MARKER));
}
}
+839
View File
@@ -0,0 +1,839 @@
//! Browser view — watch, and take over, the browser Claude is driving.
//!
//! ## What is actually being watched
//!
//! Playwright ships a live dashboard. A script inside the container calls
//! `await browser.bind('claude')`, which publishes a descriptor for the running
//! browser into `~/.cache/ms-playwright/b/`; `@playwright/mcp` does this for you.
//! `playwright-cli show --host 127.0.0.1 --port <p>` then serves a React viewer
//! that watches that directory, connects to the published browser, and gives you
//! a CDP screencast with full mouse and keyboard takeover — all of which works
//! with `headless: true`, which is the only thing that could work in a container.
//!
//! Discovery is *local filesystem*, so the viewer has to run in the same
//! container as the browsers. There is nothing a host-side viewer could see.
//!
//! ## Getting it onto the screen safely
//!
//! ```text
//! webview <iframe> host container
//! ──────────────── ──── ─────────
//! http://127.0.0.1:47820/index.html
//! ?ws=…&token=… ────► BrowserViewProxy ──socat exec──► playwright-cli show
//! (token gate) (Docker API) 127.0.0.1:39321
//! ```
//!
//! The proxy is the *only* host-bound socket, and it authenticates before a byte
//! reaches the container — see [`proxy`] for the gate, and for why the auth
//! bridge's unauthenticated [`PortForward`](crate::auth_bridge::tunnel::PortForward)
//! is deliberately not used to carry this port. The container-side viewer port is
//! additionally *reserved* with
//! [`crate::auth_bridge::RESERVED_CONTAINER_PORTS`], so that a project which
//! also has the auth bridge on cannot end up with the viewer mirrored onto the
//! host a second time, ungated.
//!
//! ## Lifecycle
//!
//! Off by default and per-project opt-in, exactly like `auth_bridge_enabled`.
//! One supervisor task per session owns the proxy and the viewer process, and it
//! is the only thing that tears them down, so every way a session can end funnels
//! through one code path:
//!
//! | Trigger | Path |
//! |---|---|
//! | Turned off in the UI | `set_browser_view_enabled(false)` → [`BrowserViewManager::stop`] |
//! | Container stopped, by the UI or otherwise | supervisor's `is_container_running` check |
//! | Project deleted | supervisor's `store.get()` check |
//! | Container rebuilt | old container stops → supervisor exits; the new one is not auto-started |
//! | Viewer died in the container | supervisor's periodic HTTP liveness probe |
//! | App exit | [`BrowserViewManager::stop_all`] |
//!
//! [`BrowserViewManager::stop`] awaits the supervisor, so the host port is
//! provably released before it returns.
//!
//! One honest gap, verified rather than assumed: `playwright-cli show` is only
//! a launcher — the dashboard it starts reparents to PID 1 and survives the
//! exec that spawned it. Every ordinary teardown path above calls
//! [`kill_dashboard`], which does stop it, but a *hard* app crash leaves the
//! dashboard running inside the container until the container stops. That
//! orphan is reachable on container loopback only: the host-side port dies with
//! the app, and [`crate::auth_bridge::RESERVED_CONTAINER_PORTS`] is a constant
//! precisely so the bridge will not mirror an orphan the next time the app
//! starts. The next [`BrowserViewManager::start`] reclaims it.
pub mod commands;
pub mod detect;
pub mod proxy;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use serde::Serialize;
use tauri::{AppHandle, Emitter};
use tokio::sync::{watch, Mutex};
use tokio::task::JoinHandle;
use crate::auth_bridge::proc_net::{self, PortFamily};
use crate::docker::container::is_container_running;
use crate::docker::exec::exec_oneshot;
use crate::storage::projects_store::ProjectsStore;
use detect::PlaywrightDetection;
use proxy::BrowserViewProxy;
/// Emitted whenever a project's browser view starts, stops or fails.
/// Payload: `{ project_id, status: BrowserViewStatus }`.
const BROWSER_VIEW_EVENT: &str = "browser-view-changed";
/// Container-side ports the viewer may bind, tried in order. The dashboard is a
/// per-workspace singleton inside the container, so only one is ever in use at
/// a time; the range exists only so an unrelated service already sitting on the
/// first port doesn't take the feature down.
///
/// This *is* [`crate::auth_bridge::RESERVED_CONTAINER_PORTS`] — the bridge must
/// never mirror these, so the two cannot be allowed to drift.
const VIEWER_PORTS: std::ops::RangeInclusive<u16> = crate::auth_bridge::RESERVED_CONTAINER_PORTS;
/// How often the supervisor re-checks that the session still has a reason to
/// exist. Matches the auth bridge's cadence.
const SUPERVISE_INTERVAL: Duration = Duration::from_secs(2);
/// Supervisor ticks between HTTP liveness probes of the viewer. The two cheap
/// checks run every tick; this one costs a container exec, so it runs at 1/5
/// the rate (~10s).
const LIVENESS_EVERY: u32 = 5;
/// Ceiling on one readiness/liveness probe. Enforced inside the container by
/// Node and again here, so neither a wedged daemon nor a wedged exec can stall
/// the supervisor.
const PROBE_TIMEOUT: Duration = Duration::from_secs(4);
/// How long to wait for `playwright-cli show` to start answering HTTP.
const READY_TIMEOUT: Duration = Duration::from_secs(30);
const READY_POLL: Duration = Duration::from_millis(400);
// ─────────────────────────────────────────────────────────────────────────────
// IPC response model
// ─────────────────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum BrowserViewState {
/// Not running. Either never started, or stopped.
Off,
/// Running and reachable at `url`.
Running,
/// The container can't serve this — see `message` for what to install.
Unavailable,
}
#[derive(Debug, Clone, Serialize)]
pub struct BrowserViewStatus {
/// The per-project opt-in. Off by default.
pub enabled: bool,
pub state: BrowserViewState,
/// Fully-formed, token-bearing URL for the pane's iframe. Loopback only.
pub url: Option<String>,
pub host_port: Option<u16>,
pub container_port: Option<u16>,
/// RFC 3339 timestamp of when the viewer came up.
pub started_at: Option<String>,
/// What was found in the container. Present even when unusable, because
/// that is exactly when the user needs to see it.
pub detection: Option<PlaywrightDetection>,
/// Human-readable explanation, set whenever `state` isn't `Running`.
pub message: Option<String>,
}
impl BrowserViewStatus {
fn off(enabled: bool) -> Self {
Self {
enabled,
state: BrowserViewState::Off,
url: None,
host_port: None,
container_port: None,
started_at: None,
detection: None,
message: None,
}
}
fn unavailable(enabled: bool, detection: PlaywrightDetection, message: String) -> Self {
Self {
enabled,
state: BrowserViewState::Unavailable,
detection: Some(detection),
message: Some(message),
..Self::off(enabled)
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Manager
// ─────────────────────────────────────────────────────────────────────────────
/// Everything a live session exposes to `status()`. Fixed once the session is
/// up, so it can be cloned out from under the map lock.
#[derive(Debug, Clone)]
struct SessionMeta {
url: String,
host_port: u16,
container_port: u16,
started_at: String,
detection: PlaywrightDetection,
}
struct Session {
/// Distinguishes this supervisor from a later one for the same project, so
/// a supervisor that exits late can't evict its replacement.
epoch: u64,
cancel: watch::Sender<bool>,
meta: SessionMeta,
supervisor: JoinHandle<()>,
}
type SessionMap = Arc<Mutex<HashMap<String, Session>>>;
#[derive(Default)]
pub struct BrowserViewManager {
sessions: SessionMap,
/// The per-project opt-in.
///
/// NOTE: in memory only, so it does not survive an app restart. The durable
/// home for this is a `browser_view_enabled: bool` field on
/// `models::Project` (see the report) — `models/project.rs` is out of scope
/// for this change, so the flag lives here and the wiring is otherwise
/// identical to `auth_bridge_enabled`.
enabled: Mutex<std::collections::HashSet<String>>,
next_epoch: AtomicU64,
}
/// Process-wide handle.
///
/// Deliberately *not* a field on `AppState`: keeping it here means the feature
/// needs no edit to `lib.rs` beyond declaring the module and registering the
/// commands, and it lets teardown paths reach it without threading state.
pub fn manager() -> &'static Arc<BrowserViewManager> {
static MANAGER: OnceLock<Arc<BrowserViewManager>> = OnceLock::new();
MANAGER.get_or_init(|| Arc::new(BrowserViewManager::default()))
}
impl BrowserViewManager {
pub async fn is_enabled(&self, project_id: &str) -> bool {
self.enabled.lock().await.contains(project_id)
}
async fn set_enabled(&self, project_id: &str, enabled: bool) {
let mut set = self.enabled.lock().await;
if enabled {
set.insert(project_id.to_string());
} else {
set.remove(project_id);
}
}
/// Current status without touching the container.
pub async fn status(&self, project_id: &str) -> BrowserViewStatus {
let enabled = self.is_enabled(project_id).await;
match self.sessions.lock().await.get(project_id) {
Some(session) => BrowserViewStatus {
enabled,
state: BrowserViewState::Running,
url: Some(session.meta.url.clone()),
host_port: Some(session.meta.host_port),
container_port: Some(session.meta.container_port),
started_at: Some(session.meta.started_at.clone()),
detection: Some(session.meta.detection.clone()),
message: None,
},
None => BrowserViewStatus::off(enabled),
}
}
/// Probe the container and, if it can serve a viewer, bring one up.
///
/// Idempotent: a call while a live session exists returns that session's
/// status untouched, so re-opening the tab does not restart the dashboard.
pub async fn start(
&self,
project_id: String,
container_id: String,
app: AppHandle,
store: Arc<ProjectsStore>,
) -> Result<BrowserViewStatus, String> {
self.set_enabled(&project_id, true).await;
// Bind the answer before acting on it: `status()` takes the same lock,
// and this mutex is not reentrant.
let already_live = self
.sessions
.lock()
.await
.get(&project_id)
.is_some_and(|s| !s.supervisor.is_finished());
if already_live {
return Ok(self.status(&project_id).await);
}
let detection = detect::detect(&container_id).await?;
if !detection.is_usable() {
let blocker = detection.blocker().unwrap_or_else(|| {
"Playwright is present but incomplete in this container.".to_string()
});
let status = BrowserViewStatus::unavailable(true, detection, blocker);
emit(&app, &project_id, &status);
return Ok(status);
}
// `is_usable()` already established this, so the fallback is unreachable.
let cli_entry = detection.cli_entry.clone().unwrap_or_default();
// The dashboard is a per-workspace singleton keyed on a unix socket in
// the temp dir, not on a port. Verified: while one is running, a second
// `show --port` prints "Dashboard is running pid=…", exits 0, and
// *ignores the port you asked for*. So always reclaim first — including
// a daemon this app orphaned in an earlier run, since it outlives us.
// Doing this before choosing a port also frees the one a previous
// session was using, so sessions don't walk up the range. Best-effort:
// a container with no dashboard makes this a no-op.
let _ = kill_dashboard(&container_id, &cli_entry).await;
let container_port = pick_viewer_port(&container_id).await?;
launch_viewer(&container_id, &cli_entry, container_port).await?;
// Wait for it to actually answer, and learn the entry URL while we're
// there — see `probe_entry_path` for why that matters. This, not the
// launcher's stdout, is the readiness signal: verified that the
// "Listening on …" line is printed only on the very first start.
let entry_path = match wait_until_ready(&container_id, container_port).await {
Ok(path) => path,
Err(e) => {
let log = read_viewer_log(&container_id).await;
let _ = kill_dashboard(&container_id, &cli_entry).await;
return Err(explain_start_failure(&e, &log));
}
};
let token = generate_token();
// `--host 127.0.0.1` is ours to set, so the family is known and there is
// no need to go back to /proc/net to work it out.
let proxy = match BrowserViewProxy::bind(
container_id.clone(),
container_port,
PortFamily::V4,
token.clone(),
)
.await
{
Ok(p) => p,
Err(e) => {
let _ = kill_dashboard(&container_id, &cli_entry).await;
return Err(e);
}
};
let meta = SessionMeta {
url: build_url(proxy.port, &entry_path, &token),
host_port: proxy.port,
container_port,
started_at: chrono::Utc::now().to_rfc3339(),
detection,
};
let epoch = self.next_epoch.fetch_add(1, Ordering::Relaxed);
let (cancel_tx, cancel_rx) = watch::channel(false);
let supervisor = tokio::spawn(supervise(
project_id.clone(),
container_id.clone(),
cli_entry,
container_port,
epoch,
app.clone(),
store,
self.sessions.clone(),
cancel_rx,
proxy,
));
log::info!(
"Browser view: project {} → 127.0.0.1:{} → container 127.0.0.1:{}",
project_id,
meta.host_port,
container_port
);
self.sessions.lock().await.insert(
project_id.clone(),
Session {
epoch,
cancel: cancel_tx,
meta,
supervisor,
},
);
let status = self.status(&project_id).await;
emit(&app, &project_id, &status);
Ok(status)
}
/// Stop one project's view and wait until its host port has been released.
pub async fn stop(&self, project_id: &str) {
self.set_enabled(project_id, false).await;
// Remove under the lock, then release it before awaiting: the
// supervisor takes the same lock to deregister itself on exit.
let session = self.sessions.lock().await.remove(project_id);
if let Some(session) = session {
let _ = session.cancel.send(true);
let _ = session.supervisor.await;
log::info!("Browser view: stopped for project {}", project_id);
}
}
/// Stop every view. Used on app exit.
pub async fn stop_all(&self) {
let sessions: Vec<(String, Session)> = self.sessions.lock().await.drain().collect();
for (project_id, session) in sessions {
let _ = session.cancel.send(true);
let _ = session.supervisor.await;
log::info!("Browser view: stopped for project {}", project_id);
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Supervisor
// ─────────────────────────────────────────────────────────────────────────────
/// Owns the proxy and the viewer process for one session and is the only thing
/// that tears them down, so a session can't half-die.
#[allow(clippy::too_many_arguments)]
async fn supervise(
project_id: String,
container_id: String,
cli_entry: String,
container_port: u16,
epoch: u64,
app: AppHandle,
store: Arc<ProjectsStore>,
sessions: SessionMap,
mut cancel: watch::Receiver<bool>,
mut proxy: BrowserViewProxy,
) {
let mut ticks: u32 = 0;
loop {
if store.get(&project_id).is_none() {
log::info!("Browser view: project {} is gone — tearing down", project_id);
break;
}
if !is_container_running(&container_id).await.unwrap_or(false) {
log::info!(
"Browser view: container for project {} is no longer running — tearing down",
project_id
);
break;
}
// The dashboard is a detached daemon, so there is no process handle to
// watch: liveness has to be an actual request. That costs an exec, so
// it runs at a coarser cadence than the two cheap checks above.
ticks = ticks.wrapping_add(1);
if ticks % LIVENESS_EVERY == 0 {
// Cancellation races the probe, not just the sleep, so stopping the
// view never waits out an in-flight exec.
let alive = tokio::select! {
_ = cancel.changed() => break,
res = probe_entry_path(&container_id, container_port) => res.is_ok(),
};
if !alive {
log::warn!(
"Browser view: the viewer for project {} stopped answering — tearing down",
project_id
);
break;
}
}
tokio::select! {
_ = cancel.changed() => break,
_ = tokio::time::sleep(SUPERVISE_INTERVAL) => {}
}
}
proxy.shutdown().await;
let _ = kill_dashboard(&container_id, &cli_entry).await;
// Deregister, unless a newer session has already taken this project's slot.
{
let mut map = sessions.lock().await;
if map.get(&project_id).is_some_and(|s| s.epoch == epoch) {
map.remove(&project_id);
}
}
let enabled = manager().is_enabled(&project_id).await;
emit(&app, &project_id, &BrowserViewStatus::off(enabled));
}
// ─────────────────────────────────────────────────────────────────────────────
// The viewer process
// ─────────────────────────────────────────────────────────────────────────────
/// Where the detached viewer's own output goes, so a failed start still has
/// something to show the user.
const VIEWER_LOG: &str = "/tmp/triple-c-browser-view.log";
/// Start `playwright-cli show`, detached.
///
/// `playwright-cli show` is a *launcher*: verified that it spawns
/// `playwright-core/lib/entry/dashboardApp.js`, which reparents to PID 1 and
/// outlives both the launcher and the exec that started it. So there is no
/// point tying a process lifetime to the exec's stdin — signalling the launcher
/// leaves the dashboard bound to its port and still serving. Teardown is
/// [`kill_dashboard`], which is the only thing verified to actually stop it.
///
/// Consequently this is a fire-and-forget exec: the launcher's output is
/// redirected to [`VIEWER_LOG`] (both so `exec_oneshot` can return immediately
/// rather than waiting on an inherited stdout, and so a failure has a trail),
/// and readiness is established by [`wait_until_ready`] instead.
async fn launch_viewer(container_id: &str, cli_entry: &str, port: u16) -> Result<(), String> {
// `NO_UPDATE_NOTIFIER` stops the CLI phoning registry.npmjs.org on every
// launch; the container may have no egress, and we don't want to wait out a
// DNS timeout before the dashboard binds.
let script = format!(
"{}; NO_UPDATE_NOTIFIER=1 nohup node {} show --host 127.0.0.1 --port {} >{} 2>&1 &",
WORKDIR_PREFIX,
shell_quote(cli_entry),
port,
VIEWER_LOG
);
exec_oneshot(
container_id,
vec!["sh".to_string(), "-c".to_string(), script],
)
.await
.map(|_| ())
.map_err(|e| format!("Could not start the Playwright viewer: {}", e))
}
/// The dashboard singleton is keyed on a hash of the working directory, so
/// `show` and `show --kill` must agree on one. `exec_oneshot` doesn't set a
/// working directory (it inherits the image's), and `/workspace` is both what
/// the image sets today and where Claude actually runs — but pinning it here
/// means a change to the image can't silently split the two into different
/// singletons, leaving a dashboard nothing can kill.
const WORKDIR_PREFIX: &str = "cd /workspace 2>/dev/null || true";
/// Stop the dashboard daemon. Verified to free the port and stop answering.
async fn kill_dashboard(container_id: &str, cli_entry: &str) -> Result<String, String> {
let script = format!(
"{}; NO_UPDATE_NOTIFIER=1 node {} show --kill",
WORKDIR_PREFIX,
shell_quote(cli_entry)
);
exec_oneshot(
container_id,
vec!["sh".to_string(), "-c".to_string(), script],
)
.await
}
/// Turn a failed start into something the user can act on.
///
/// The one failure worth naming is the singleton clash: if a dashboard we
/// couldn't reclaim is still alive, the launcher exits 0 having printed
/// "Dashboard is running pid=…" and having silently ignored the port we asked
/// for, so all the caller sees is a port that never answers.
fn explain_start_failure(err: &str, log: &str) -> String {
let log = log.trim();
if log.contains("Dashboard is running") {
return format!(
"Another Playwright dashboard is already running in this container and would not \
give up its port. Stop it from a terminal in the container with \
`npx playwright-cli show --kill`, then try again.\n\nViewer output:\n{}",
log
);
}
if log.is_empty() {
err.to_string()
} else {
format!("{}\n\nViewer output:\n{}", err, log)
}
}
/// Tail of the viewer's own output, for a start that didn't come up.
async fn read_viewer_log(container_id: &str) -> String {
exec_oneshot(
container_id,
vec!["tail".to_string(), "-n".to_string(), "40".to_string(), VIEWER_LOG.to_string()],
)
.await
.unwrap_or_default()
}
// ─────────────────────────────────────────────────────────────────────────────
// Readiness, ports, URLs
// ─────────────────────────────────────────────────────────────────────────────
/// First port in [`VIEWER_PORTS`] that nothing in the container is listening on.
async fn pick_viewer_port(container_id: &str) -> Result<u16, String> {
let text = exec_oneshot(
container_id,
vec![
"cat".to_string(),
"/proc/net/tcp".to_string(),
"/proc/net/tcp6".to_string(),
],
)
.await
.unwrap_or_default();
let taken = proc_net::parse_loopback_listeners(&text);
VIEWER_PORTS
.clone()
.find(|p| !taken.contains_key(p))
.ok_or_else(|| {
format!(
"No free port in {}{} inside the container for the Playwright viewer.",
VIEWER_PORTS.start(),
VIEWER_PORTS.end()
)
})
}
/// Poll the viewer until it answers, and return the path the pane should load.
async fn wait_until_ready(container_id: &str, port: u16) -> Result<String, String> {
let deadline = tokio::time::Instant::now() + READY_TIMEOUT;
loop {
let last = match probe_entry_path(container_id, port).await {
Ok(path) => return Ok(path),
Err(e) => e,
};
if tokio::time::Instant::now() >= deadline {
return Err(format!(
"The Playwright viewer did not start listening on container port {} within {}s ({}).",
port,
READY_TIMEOUT.as_secs(),
last
));
}
tokio::time::sleep(READY_POLL).await;
}
}
const PROBE_MARKER: &str = "__TRIPLE_C_BV_PATH__";
/// Ask the viewer, from inside the container, what it wants to be loaded as.
///
/// `GET /` answers `302 Location: /index.html?ws=<guid>`, where the guid is the
/// dashboard's own per-run capability for its WebSocket. Resolving that here and
/// pointing the iframe straight at the final URL means the pane never traverses
/// a redirect — which matters, because a redirect drops the `?token=` the proxy
/// gate wants and would leave a fresh connection to be authorised with nothing.
/// A `200` (no redirect) is fine too; then the entry point is just `/`.
async fn probe_entry_path(container_id: &str, port: u16) -> Result<String, String> {
// The request is bounded on both sides. Verified: the dashboard answers a
// bad WebSocket path by holding the socket open forever rather than
// erroring, so "no reply" is a state this probe has to be able to leave —
// otherwise a wedged daemon would wedge the supervisor, and `stop()` waits
// on the supervisor.
let script = format!(
r#"const q=require("http").get({{host:"127.0.0.1",port:{},path:"/",headers:{{host:"127.0.0.1:{}"}}}},r=>{{process.stdout.write("\n{}"+r.statusCode+" "+(r.headers.location||"/")+"\n");r.resume();process.exit(0);}});q.on("error",e=>{{process.stderr.write(String(e.message));process.exit(1);}});q.setTimeout({},()=>{{process.stderr.write("timed out waiting for the viewer");q.destroy();process.exit(1);}});"#,
port,
port,
PROBE_MARKER,
PROBE_TIMEOUT.as_millis()
);
let out = tokio::time::timeout(
PROBE_TIMEOUT * 2,
exec_oneshot(
container_id,
vec!["node".to_string(), "-e".to_string(), script],
),
)
.await
.map_err(|_| "the viewer probe did not return".to_string())??;
parse_entry_probe(&out)
}
/// Turn the readiness probe's output into the path to load.
fn parse_entry_probe(out: &str) -> Result<String, String> {
let Some(idx) = out.find(PROBE_MARKER) else {
let trimmed = out.trim();
return Err(if trimmed.is_empty() {
"no response".to_string()
} else {
trimmed.lines().next_back().unwrap_or(trimmed).to_string()
});
};
let line = out[idx + PROBE_MARKER.len()..]
.lines()
.next()
.unwrap_or("")
.trim();
let (status, location) = line.split_once(' ').unwrap_or((line, "/"));
match status {
"301" | "302" | "303" | "307" | "308" => {
// Only same-origin, absolute paths — the dashboard never sends
// anything else, and following an off-host redirect through the
// pane would be a nasty surprise.
if location.starts_with('/') {
Ok(location.to_string())
} else {
Ok("/".to_string())
}
}
"200" => Ok("/".to_string()),
other => Err(format!("viewer answered HTTP {}", other)),
}
}
/// The pane's iframe URL: the viewer's own entry path with our session token
/// appended, on the host loopback port the gate is listening on.
fn build_url(host_port: u16, entry_path: &str, token: &str) -> String {
let sep = if entry_path.contains('?') { '&' } else { '?' };
format!(
"http://127.0.0.1:{}{}{}token={}",
host_port, entry_path, sep, token
)
}
/// Single-quote a path for `sh -c`. Paths from `require.resolve` never contain
/// quotes in practice, but this is a shell command line and the cost of being
/// sure is one line.
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
/// 256 bits of URL-safe randomness, matching `web_terminal`'s token shape.
fn generate_token() -> String {
use base64::Engine;
use rand::Rng;
let mut rng = rand::rng();
let bytes: Vec<u8> = (0..32).map(|_| rng.random::<u8>()).collect();
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&bytes)
}
fn emit(app: &AppHandle, project_id: &str, status: &BrowserViewStatus) {
let _ = app.emit(
BROWSER_VIEW_EVENT,
serde_json::json!({ "project_id": project_id, "status": status }),
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_redirect_becomes_the_entry_path() {
let out = format!("\n{}302 /index.html?ws=abc123\n", PROBE_MARKER);
assert_eq!(parse_entry_probe(&out).unwrap(), "/index.html?ws=abc123");
}
#[test]
fn a_plain_200_entry_point_is_the_root() {
let out = format!("\n{}200 /\n", PROBE_MARKER);
assert_eq!(parse_entry_probe(&out).unwrap(), "/");
}
#[test]
fn an_off_host_redirect_is_not_followed() {
let out = format!("\n{}302 https://evil.example/\n", PROBE_MARKER);
assert_eq!(parse_entry_probe(&out).unwrap(), "/");
}
#[test]
fn a_refused_connection_is_an_error_the_poller_can_retry() {
// Verified shape: node writes this to stderr with no trailing newline.
let err = parse_entry_probe("connect ECONNREFUSED 127.0.0.1:39321").unwrap_err();
assert!(err.contains("ECONNREFUSED"), "{}", err);
assert_eq!(parse_entry_probe("").unwrap_err(), "no response");
assert!(parse_entry_probe("timed out waiting for the viewer")
.unwrap_err()
.contains("timed out"));
}
#[test]
fn an_unexpected_status_is_surfaced_rather_than_loaded() {
let out = format!("\n{}500 /\n", PROBE_MARKER);
assert!(parse_entry_probe(&out).unwrap_err().contains("500"));
}
#[test]
fn the_pane_url_is_loopback_and_carries_the_token() {
let url = build_url(47820, "/index.html?ws=abc", "TOKEN");
assert_eq!(url, "http://127.0.0.1:47820/index.html?ws=abc&token=TOKEN");
assert!(url.starts_with("http://127.0.0.1:"));
// A viewer that doesn't redirect gets a `?`, not a stray `&`.
assert_eq!(
build_url(47821, "/", "T"),
"http://127.0.0.1:47821/?token=T"
);
}
#[test]
fn tokens_are_unique_and_url_safe() {
let a = generate_token();
let b = generate_token();
assert_ne!(a, b);
assert_eq!(a.len(), 43); // 32 bytes, base64url, unpadded
assert!(a.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
}
#[test]
fn shell_quoting_survives_a_hostile_path() {
assert_eq!(shell_quote("/a/b/cli.js"), "'/a/b/cli.js'");
assert_eq!(
shell_quote("/a/'; rm -rf /; '"),
r#"'/a/'\''; rm -rf /; '\'''"#
);
}
#[test]
fn a_singleton_clash_is_named_rather_than_left_as_a_dead_port() {
let msg = explain_start_failure(
"did not start listening on container port 39321 within 30s",
"Dashboard is running pid=1823\n",
);
assert!(msg.contains("show --kill"), "{}", msg);
assert!(msg.contains("pid=1823"), "{}", msg);
}
#[test]
fn an_ordinary_start_failure_keeps_the_error_and_any_log() {
assert_eq!(explain_start_failure("boom", " "), "boom");
let msg = explain_start_failure("boom", "EADDRINUSE 39321");
assert!(msg.starts_with("boom"), "{}", msg);
assert!(msg.contains("EADDRINUSE 39321"), "{}", msg);
}
#[test]
fn the_viewer_port_range_is_bounded() {
assert_eq!(VIEWER_PORTS.clone().count(), 8);
// The auth bridge refuses to mirror exactly this range; if they ever
// drifted apart the pane would gain an ungated second front door.
assert_eq!(VIEWER_PORTS, crate::auth_bridge::RESERVED_CONTAINER_PORTS);
}
#[test]
fn an_off_status_says_nothing_is_running() {
let s = BrowserViewStatus::off(true);
assert!(s.enabled);
assert_eq!(s.state, BrowserViewState::Off);
assert!(s.url.is_none());
}
#[test]
fn an_unavailable_status_keeps_the_detail_the_user_needs() {
let mut d = PlaywrightDetection::default();
d.node_version = Some("22.11.0".to_string());
let s = BrowserViewStatus::unavailable(true, d, "install it".to_string());
assert_eq!(s.state, BrowserViewState::Unavailable);
assert_eq!(s.message.as_deref(), Some("install it"));
assert!(s.detection.is_some());
assert!(s.url.is_none());
}
}
+699
View File
@@ -0,0 +1,699 @@
//! The host-side, token-gated front door for one project's Playwright viewer.
//!
//! ## Why this is not `PortForward` on its own
//!
//! [`crate::auth_bridge::tunnel::PortForward`] mirrors a container loopback port
//! onto the *same* host loopback port with **no authentication at all**. That is
//! the right trade for the auth bridge — the things it exposes are short-lived
//! OAuth callback listeners whose whole purpose is to receive one unauthenticated
//! request — but it is the wrong trade here. The Playwright viewer is full mouse
//! and keyboard control of a browser running inside a container that has
//! passwordless sudo and, very often, the host's Docker socket bind-mounted. A
//! bare loopback port is reachable by:
//!
//! * any other local user on a multi-user host, and
//! * **any web page the user happens to have open**, via localhost port scanning
//! or DNS rebinding.
//!
//! So this module keeps the tunnel half of the auth bridge (a per-connection
//! `socat` exec through the Docker API — see
//! [`crate::auth_bridge::tunnel::tunnel_connection_with_prelude`]) and replaces
//! the listener half with one that authenticates before a single byte reaches
//! the container. There is therefore exactly **one** host-bound socket per
//! session, and it is gated.
//!
//! ## The gate
//!
//! Gating happens on the first HTTP request head of every accepted TCP
//! connection, before anything is forwarded. To get a connection through you
//! must satisfy all of:
//!
//! 1. `Host` is `127.0.0.1:<port>` or `localhost:<port>` — this is the
//! anti-DNS-rebinding check. A page on `evil.com` that rebinds its name to
//! 127.0.0.1 still sends `Host: evil.com`.
//! 2. Either
//! * the request carries the session token (in `?token=`, in a `Cookie`, or
//! in the query of a same-origin `Referer`), **or**
//! * `Origin` / `Referer` is exactly this proxy's own origin — i.e. the
//! request was issued by a document that we already served, which itself
//! had to present the token. This is what lets the viewer's own
//! sub-resource and WebSocket requests through: a browser will not let a
//! hostile page forge either header, and requests that carry neither (a
//! cross-site `<script src>` or a top-level navigation) are rejected.
//!
//! Once the first head passes, the rest of the connection is spliced verbatim,
//! so HTTP/1.1 keep-alive, the WebSocket upgrade and the CDP screencast frames
//! all pass through untouched and protocol-agnostically. Riding an existing
//! connection is not an escalation: opening one required the token.
//!
//! ## Port allocation and the CSP
//!
//! Host ports come from the small fixed range [`PROXY_PORTS`]. That is
//! deliberate: `tauri.conf.json`'s `frame-src` has to name every origin the pane
//! may embed, and CSP has no port wildcards short of `http://127.0.0.1:*`.
//! Allocating from a bounded, known range keeps that directive an exact
//! enumeration instead of "any localhost port".
use std::net::{Ipv4Addr, SocketAddr};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::task::{JoinHandle, JoinSet};
use crate::auth_bridge::proc_net::PortFamily;
use crate::auth_bridge::tunnel::tunnel_connection_with_prelude;
/// Host loopback ports the pane may be served on, and therefore the exact set of
/// origins enumerated in the app's `frame-src`. Keep the two in sync: adding a
/// port here without adding it to `tauri.conf.json` produces a pane that is
/// silently blocked by CSP.
pub const PROXY_PORTS: std::ops::RangeInclusive<u16> = 47820..=47827;
/// Ceiling on the request head we will buffer before deciding. Real heads are
/// well under 8 KiB; anything larger is either broken or hostile.
const MAX_HEAD: usize = 32 * 1024;
/// How long a freshly accepted connection has to produce a complete request
/// head. Prevents a slowloris from pinning accept-loop tasks.
const HEAD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const REFUSAL_BODY: &str = concat!(
"<!doctype html><meta charset=\"utf-8\">",
"<title>Not available</title>",
"<p>This Triple-C browser view is only reachable from the app that started it.</p>"
);
/// A bound, token-gated host listener in front of one container-side viewer.
///
/// The accept loop owns the [`TcpListener`] and the [`JoinSet`] of live
/// connections, so aborting the one task handle releases the port *and* tears
/// down everything under it. [`Drop`] does that as a backstop;
/// [`BrowserViewProxy::shutdown`] does it deterministically by also awaiting the
/// aborted task, so the port is provably free before the caller continues.
pub struct BrowserViewProxy {
pub port: u16,
task: JoinHandle<()>,
}
impl Drop for BrowserViewProxy {
fn drop(&mut self) {
self.task.abort();
}
}
impl BrowserViewProxy {
/// Take the first free port in [`PROXY_PORTS`] on the host loopback and
/// start gating connections into `container_id`'s `container_port`.
pub async fn bind(
container_id: String,
container_port: u16,
family: PortFamily,
token: String,
) -> Result<Self, String> {
let mut last_err = None;
for port in PROXY_PORTS {
// SECURITY BOUNDARY: 127.0.0.1 ONLY, never 0.0.0.0. Unlike
// `web_terminal`, which binds a wildcard on purpose because remote
// access *is* its feature, this pane is remote control of a browser
// in a privileged container and must never leave the host. Do not
// "fix" a connectivity problem by widening this address.
match TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, port))).await {
Ok(listener) => {
let task = tokio::spawn(accept_loop(
listener,
container_id,
family.socat_target(container_port),
container_port,
token,
self_origins(port),
host_authorities(port),
));
log::info!("Browser view: proxy listening on 127.0.0.1:{}", port);
return Ok(Self { port, task });
}
Err(e) => last_err = Some(e),
}
}
Err(format!(
"No free host port in {}{} for the browser view proxy ({}). \
Close another project's browser view and try again.",
PROXY_PORTS.start(),
PROXY_PORTS.end(),
last_err
.map(|e| e.to_string())
.unwrap_or_else(|| "range empty".to_string())
))
}
/// Stop accepting, release the host port and abort every live connection.
pub async fn shutdown(&mut self) {
self.task.abort();
let _ = (&mut self.task).await;
log::info!("Browser view: proxy on 127.0.0.1:{} released", self.port);
}
}
/// The origins a request may legitimately claim to come from.
fn self_origins(port: u16) -> Vec<String> {
vec![
format!("http://127.0.0.1:{}", port),
format!("http://localhost:{}", port),
]
}
/// The `Host` values we will answer to. Anything else is a rebinding attempt.
fn host_authorities(port: u16) -> Vec<String> {
vec![
format!("127.0.0.1:{}", port),
format!("localhost:{}", port),
]
}
#[allow(clippy::too_many_arguments)]
async fn accept_loop(
listener: TcpListener,
container_id: String,
target: String,
container_port: u16,
token: String,
origins: Vec<String>,
authorities: Vec<String>,
) {
let mut conns: JoinSet<()> = JoinSet::new();
loop {
let accepted = tokio::select! {
r = listener.accept() => r,
// Reap finished connections so the set can't grow without bound.
// An empty set yields `None`, the pattern fails, and the branch is
// simply dropped from the select.
Some(_) = conns.join_next() => continue,
};
match accepted {
Ok((stream, _peer)) => {
let _ = stream.set_nodelay(true);
conns.spawn(serve_connection(
stream,
container_id.clone(),
target.clone(),
container_port,
token.clone(),
origins.clone(),
authorities.clone(),
));
}
Err(e) => {
log::warn!("Browser view: accept failed: {} — stopping proxy listener", e);
return;
}
}
}
}
#[allow(clippy::too_many_arguments)]
async fn serve_connection(
mut stream: TcpStream,
container_id: String,
target: String,
container_port: u16,
token: String,
origins: Vec<String>,
authorities: Vec<String>,
) {
let head = match tokio::time::timeout(HEAD_TIMEOUT, read_head(&mut stream)).await {
Ok(Ok(head)) => head,
Ok(Err(e)) => {
log::debug!("Browser view: dropping connection: {}", e);
let _ = reject(&mut stream, 400, "Bad Request").await;
return;
}
Err(_) => {
log::debug!("Browser view: dropping connection: no request head within timeout");
return;
}
};
let head_text = String::from_utf8_lossy(&head).into_owned();
let verdict = authorize(&head_text, &token, &origins, &authorities);
if verdict != Verdict::Allow {
log::warn!(
"Browser view: rejected a connection on the proxy for container port {} ({:?})",
container_port,
verdict
);
let _ = reject(&mut stream, 403, "Forbidden").await;
return;
}
// Authorized: hand the socket to the same socat-over-Docker-exec tunnel the
// auth bridge uses, replaying the head we had to buffer to make the call.
tunnel_connection_with_prelude(container_id, target, stream, container_port, head).await;
}
/// Read bytes until the end of the HTTP request head (`\r\n\r\n`), or fail.
async fn read_head(stream: &mut TcpStream) -> Result<Vec<u8>, String> {
let mut buf = Vec::with_capacity(1024);
let mut chunk = [0u8; 1024];
loop {
let n = stream
.read(&mut chunk)
.await
.map_err(|e| format!("read failed: {}", e))?;
if n == 0 {
return Err("connection closed before a request head arrived".to_string());
}
buf.extend_from_slice(&chunk[..n]);
if find_head_end(&buf).is_some() {
return Ok(buf);
}
if buf.len() > MAX_HEAD {
return Err(format!("request head exceeded {} bytes", MAX_HEAD));
}
}
}
/// Index just past the blank line terminating the head, if it has arrived.
/// Tolerates a bare-LF terminator, which some minimal clients still emit.
fn find_head_end(buf: &[u8]) -> Option<usize> {
buf.windows(4)
.position(|w| w == b"\r\n\r\n")
.map(|i| i + 4)
.or_else(|| buf.windows(2).position(|w| w == b"\n\n").map(|i| i + 2))
}
async fn reject(stream: &mut TcpStream, code: u16, reason: &str) -> std::io::Result<()> {
let body = REFUSAL_BODY;
let response = format!(
"HTTP/1.1 {} {}\r\n\
Content-Type: text/html; charset=utf-8\r\n\
Content-Length: {}\r\n\
Cache-Control: no-store\r\n\
Connection: close\r\n\r\n{}",
code,
reason,
body.len(),
body
);
stream.write_all(response.as_bytes()).await?;
stream.shutdown().await
}
// ─────────────────────────────────────────────────────────────────────────────
// The gate itself — pure, so it can be tested without sockets
// ─────────────────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Verdict {
Allow,
/// No request line, or one we can't parse.
Malformed,
/// `Host` is not one of ours — a rebinding attempt, or a stray client.
BadHost,
/// Well-formed and addressed to us, but presented no token and no proof of
/// having come from a document we served.
Unauthenticated,
}
/// Decide whether the connection whose first request head this is may be
/// spliced into the container. See the module docs for the rules.
pub(crate) fn authorize(
head: &str,
token: &str,
self_origins: &[String],
host_authorities: &[String],
) -> Verdict {
let mut lines = head.split(['\r', '\n']).filter(|l| !l.is_empty());
let Some(request_line) = lines.next() else {
return Verdict::Malformed;
};
// "GET /path?query HTTP/1.1"
let mut parts = request_line.split(' ');
let (Some(_method), Some(request_target)) = (parts.next(), parts.next()) else {
return Verdict::Malformed;
};
if !request_target.starts_with('/') && !request_target.starts_with("http") {
// CONNECT and origin-form-violating targets are not something the
// viewer ever sends; refuse to be used as a forward proxy.
return Verdict::Malformed;
}
let mut host = None;
let mut origin = None;
let mut referer = None;
let mut cookie = None;
let mut fetch_site = None;
for line in lines {
let Some((name, value)) = line.split_once(':') else {
continue;
};
let value = value.trim();
match name.trim().to_ascii_lowercase().as_str() {
"host" => host = Some(value),
"origin" => origin = Some(value),
"referer" => referer = Some(value),
"cookie" => cookie = Some(value),
"sec-fetch-site" => fetch_site = Some(value),
_ => {}
}
}
// 1. Anti-rebinding. A hostile page that points its own name at 127.0.0.1
// still sends its own name here.
match host {
Some(h) if host_authorities.iter().any(|a| a.eq_ignore_ascii_case(h)) => {}
_ => return Verdict::BadHost,
}
// 2a. An explicit token, from the request target, a cookie, or the query of
// the referring document's URL (same-origin requests send the full URL,
// query included, under the default referrer policy).
if query_token(request_target).is_some_and(|t| tokens_match(t, token))
|| cookie_token(cookie.unwrap_or("")).is_some_and(|t| tokens_match(t, token))
|| referer.and_then(query_token).is_some_and(|t| tokens_match(t, token))
{
return Verdict::Allow;
}
// 2b. …or proof that a document we already served issued this request. The
// viewer's WebSocket upgrade carries `Origin` and no `Referer`, and
// nothing in it is under our control, so this is the clause that makes
// the pane work at all. A browser will not let a hostile page forge
// either header; a request with neither (cross-site `<script src>`,
// top-level navigation, `curl`) falls through and is refused.
if origin.is_some_and(|o| origin_is_self(o, self_origins))
|| referer.is_some_and(|r| origin_is_self(r, self_origins))
// Fetch metadata says the same thing as `Origin`, and keeps saying it
// for the plain sub-resource loads that carry no `Origin` and whose
// `Referer` a `no-referrer` policy could strip. `Sec-Fetch-Site` is a
// forbidden header, so page script cannot set it either.
|| fetch_site.is_some_and(|s| s.eq_ignore_ascii_case("same-origin"))
{
return Verdict::Allow;
}
Verdict::Unauthenticated
}
/// The value of a `token` query parameter in a request target or absolute URL.
fn query_token(target: &str) -> Option<&str> {
let query = target.split_once('?')?.1;
// Fragments never reach the wire in a request target, but a `Referer` can
// legally carry one on some clients.
let query = query.split('#').next().unwrap_or(query);
query.split('&').find_map(|pair| {
let (k, v) = pair.split_once('=')?;
(k == "token").then_some(v)
})
}
/// The value of our session cookie in a `Cookie` header.
fn cookie_token(cookie_header: &str) -> Option<&str> {
cookie_header.split(';').find_map(|pair| {
let (k, v) = pair.split_once('=')?;
(k.trim() == COOKIE_NAME).then_some(v.trim())
})
}
/// Name of the cookie the gate will accept a token in. Nothing sets it today —
/// the pane relies on the query parameter for the document and on `Origin` /
/// `Referer` for everything under it, because a webview iframe pointed at
/// 127.0.0.1 is a third-party context and WKWebView and WebKitGTK both drop
/// third-party cookies by default. It is accepted so that a future first-party
/// entry point (opening the pane in the user's own browser, say) needs no
/// change here.
const COOKIE_NAME: &str = "triple_c_browser_view";
/// Whether a URL (or bare origin) has exactly one of our own origins.
fn origin_is_self(value: &str, self_origins: &[String]) -> bool {
// Compare scheme://host:port only; a Referer carries a path as well.
let origin = match value.split_once("://") {
Some((scheme, rest)) => {
let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
format!("{}://{}", scheme, authority)
}
None => value.to_string(),
};
self_origins.iter().any(|o| o.eq_ignore_ascii_case(&origin))
}
/// Length-independent-ish equality. A timing oracle over a loopback socket is
/// not a realistic attack, but comparing in constant time costs nothing and
/// keeps the primitive honest.
fn tokens_match(candidate: &str, expected: &str) -> bool {
let a = candidate.as_bytes();
let b = expected.as_bytes();
let mut diff = (a.len() ^ b.len()) as u8;
for i in 0..a.len().max(b.len()) {
let x = a.get(i).copied().unwrap_or(0);
let y = b.get(i).copied().unwrap_or(0);
diff |= x ^ y;
}
diff == 0
}
#[cfg(test)]
mod tests {
use super::*;
const TOKEN: &str = "s3cr3t-token-value";
fn origins() -> Vec<String> {
self_origins(47820)
}
fn authorities() -> Vec<String> {
host_authorities(47820)
}
fn head(request_line: &str, headers: &[&str]) -> String {
let mut s = String::from(request_line);
s.push_str("\r\n");
for h in headers {
s.push_str(h);
s.push_str("\r\n");
}
s.push_str("\r\n");
s
}
fn verdict(request_line: &str, headers: &[&str]) -> Verdict {
authorize(&head(request_line, headers), TOKEN, &origins(), &authorities())
}
#[test]
fn the_initial_document_is_allowed_by_its_query_token() {
assert_eq!(
verdict(
&format!("GET /?token={} HTTP/1.1", TOKEN),
&["Host: 127.0.0.1:47820"]
),
Verdict::Allow
);
}
#[test]
fn a_wrong_token_is_not_enough() {
assert_eq!(
verdict("GET /?token=nope HTTP/1.1", &["Host: 127.0.0.1:47820"]),
Verdict::Unauthenticated
);
}
#[test]
fn a_subresource_is_allowed_by_the_token_in_its_referer() {
assert_eq!(
verdict(
"GET /assets/app.js HTTP/1.1",
&[
"Host: 127.0.0.1:47820",
&format!("Referer: http://127.0.0.1:47820/?token={}", TOKEN),
]
),
Verdict::Allow
);
}
#[test]
fn the_websocket_upgrade_is_allowed_by_its_own_origin() {
// The viewer's CDP screencast socket carries Origin and no Referer, and
// its URL is not ours to add a token to.
assert_eq!(
verdict(
"GET /ws HTTP/1.1",
&[
"Host: 127.0.0.1:47820",
"Upgrade: websocket",
"Connection: Upgrade",
"Origin: http://127.0.0.1:47820",
]
),
Verdict::Allow
);
}
#[test]
fn a_subresource_is_allowed_by_fetch_metadata_when_the_referer_is_stripped() {
assert_eq!(
verdict(
"GET /assets/app.js HTTP/1.1",
&[
"Host: 127.0.0.1:47820",
"Sec-Fetch-Site: same-origin",
"Sec-Fetch-Dest: script",
]
),
Verdict::Allow
);
}
#[test]
fn cross_site_fetch_metadata_is_refused() {
for site in ["cross-site", "same-site", "none"] {
assert_eq!(
verdict(
"GET /assets/app.js HTTP/1.1",
&["Host: 127.0.0.1:47820", &format!("Sec-Fetch-Site: {}", site)]
),
Verdict::Unauthenticated,
"Sec-Fetch-Site: {}",
site
);
}
}
#[test]
fn a_hostile_pages_fetch_is_refused_by_its_origin() {
assert_eq!(
verdict(
"GET / HTTP/1.1",
&["Host: 127.0.0.1:47820", "Origin: http://evil.example"]
),
Verdict::Unauthenticated
);
}
#[test]
fn a_bare_port_scan_is_refused() {
// No token, no Origin, no Referer — a cross-site <script src>, a
// top-level navigation, or curl.
assert_eq!(
verdict("GET / HTTP/1.1", &["Host: 127.0.0.1:47820"]),
Verdict::Unauthenticated
);
}
#[test]
fn dns_rebinding_is_refused_even_with_a_valid_token() {
// The attacker's name resolves to 127.0.0.1, but the Host header still
// says who the browser thinks it is talking to.
assert_eq!(
verdict(
&format!("GET /?token={} HTTP/1.1", TOKEN),
&["Host: evil.example:47820"]
),
Verdict::BadHost
);
}
#[test]
fn localhost_is_an_acceptable_authority_and_origin() {
assert_eq!(
verdict(
"GET /ws HTTP/1.1",
&["Host: localhost:47820", "Origin: http://localhost:47820"]
),
Verdict::Allow
);
}
#[test]
fn another_panes_origin_does_not_authorize_this_one() {
// Ports are what separate one project's pane from another's, so the
// neighbouring port must not be accepted as "self".
assert_eq!(
verdict(
"GET /ws HTTP/1.1",
&["Host: 127.0.0.1:47820", "Origin: http://127.0.0.1:47821"]
),
Verdict::Unauthenticated
);
}
#[test]
fn a_cookie_borne_token_is_accepted() {
assert_eq!(
verdict(
"GET /assets/app.js HTTP/1.1",
&[
"Host: 127.0.0.1:47820",
&format!("Cookie: other=1; {}={}", COOKIE_NAME, TOKEN),
]
),
Verdict::Allow
);
}
#[test]
fn a_missing_host_header_is_refused() {
assert_eq!(
verdict(&format!("GET /?token={} HTTP/1.1", TOKEN), &[]),
Verdict::BadHost
);
}
#[test]
fn header_names_are_matched_case_insensitively() {
assert_eq!(
verdict(
"GET /ws HTTP/1.1",
&["HOST: 127.0.0.1:47820", "ORIGIN: http://127.0.0.1:47820"]
),
Verdict::Allow
);
}
#[test]
fn a_connect_request_cannot_turn_this_into_a_forward_proxy() {
assert_eq!(
verdict("CONNECT evil.example:443 HTTP/1.1", &["Host: 127.0.0.1:47820"]),
Verdict::Malformed
);
}
#[test]
fn an_empty_head_is_malformed() {
assert_eq!(authorize("", TOKEN, &origins(), &authorities()), Verdict::Malformed);
}
#[test]
fn the_head_terminator_is_found_for_both_crlf_and_lf() {
assert_eq!(find_head_end(b"GET / HTTP/1.1\r\n\r\n"), Some(18));
assert_eq!(find_head_end(b"GET / HTTP/1.1\n\n"), Some(16));
assert_eq!(find_head_end(b"GET / HTTP/1.1\r\nHost: x\r\n"), None);
}
#[test]
fn query_token_ignores_lookalike_parameters() {
assert_eq!(query_token("/?mytoken=a&token=b"), Some("b"));
assert_eq!(query_token("/?tokenish=a"), None);
assert_eq!(query_token("/nothing"), None);
}
#[test]
fn tokens_match_rejects_prefixes_and_suffixes() {
assert!(tokens_match(TOKEN, TOKEN));
assert!(!tokens_match(&TOKEN[..5], TOKEN));
assert!(!tokens_match(&format!("{}x", TOKEN), TOKEN));
assert!(!tokens_match("", TOKEN));
}
#[test]
fn the_proxy_port_range_is_the_one_the_csp_enumerates() {
// tauri.conf.json lists these origins in `frame-src`; a change here
// without a change there yields a pane that is silently blocked.
assert_eq!(PROXY_PORTS.clone().count(), 8);
assert_eq!(*PROXY_PORTS.start(), 47820);
assert_eq!(*PROXY_PORTS.end(), 47827);
}
}
@@ -0,0 +1,88 @@
//! Tauri commands for the model gateway container.
//!
//! Mirrors `stt_commands`. The one rule that is specific to this module: the
//! **provider API key never crosses back to the frontend**. It goes in through
//! `set_gateway_api_key`, lives in the OS keychain, and is only ever read
//! host-side when rendering the gateway config. `get_gateway_status` reports
//! its presence as a boolean.
//!
//! The gateway *master key* is different and is returned deliberately — it is
//! the value the user has to paste into a project's model config as its auth
//! token, so keeping it hidden would just make the feature unusable.
use tauri::{AppHandle, Emitter, State};
use crate::docker::gateway;
use crate::models::GatewayStatus;
use crate::storage::secure;
use crate::AppState;
#[tauri::command]
pub async fn get_gateway_status(state: State<'_, AppState>) -> Result<GatewayStatus, String> {
let settings = state.settings_store.get();
gateway::get_gateway_status(&settings.gateway).await
}
#[tauri::command]
pub async fn start_gateway(state: State<'_, AppState>) -> Result<GatewayStatus, String> {
let settings = state.settings_store.get();
gateway::ensure_gateway_running(&settings.gateway).await
}
#[tauri::command]
pub async fn stop_gateway() -> Result<(), String> {
gateway::stop_gateway_container().await
}
/// Whether the gateway is actually answering yet. LiteLLM needs a few seconds
/// after the container starts before `/v1/messages` will serve anything.
#[tauri::command]
pub async fn check_gateway_health(state: State<'_, AppState>) -> Result<bool, String> {
let settings = state.settings_store.get();
gateway::check_gateway_health(settings.gateway.port).await
}
#[tauri::command]
pub async fn build_gateway_image(app_handle: AppHandle) -> Result<(), String> {
gateway::build_gateway_image(move |msg| {
let _ = app_handle.emit("gateway-build-progress", &msg);
})
.await
}
#[tauri::command]
pub async fn pull_gateway_image(app_handle: AppHandle) -> Result<(), String> {
gateway::pull_gateway_image(move |msg| {
let _ = app_handle.emit("gateway-pull-progress", &msg);
})
.await
}
/// Store the upstream provider API key. Write-only from the frontend's point
/// of view — there is no matching getter.
#[tauri::command]
pub async fn set_gateway_api_key(api_key: String) -> Result<(), String> {
secure::store_gateway_api_key(&api_key)
}
/// Forget the provider API key. The gateway keeps serving until it is
/// restarted, at which point it will refuse to start without a key.
#[tauri::command]
pub async fn clear_gateway_api_key() -> Result<(), String> {
secure::delete_gateway_api_key()
}
/// The token a project sends to the gateway (`ANTHROPIC_AUTH_TOKEN`), minting
/// one on first use.
#[tauri::command]
pub async fn get_gateway_auth_token() -> Result<String, String> {
secure::get_or_create_gateway_master_key()
}
/// Mint a new gateway auth token, invalidating the old one. Projects still
/// holding the previous value stop working until they are updated, and the
/// gateway is recreated on its next start because the rotation id moved.
#[tauri::command]
pub async fn regenerate_gateway_auth_token() -> Result<String, String> {
secure::regenerate_gateway_master_key()
}
+1
View File
@@ -3,6 +3,7 @@ pub mod auth_token_commands;
pub mod aws_commands;
pub mod docker_commands;
pub mod file_commands;
pub mod gateway_commands;
pub mod help_commands;
pub mod inspect_commands;
pub mod install_helper_commands;
@@ -207,6 +207,16 @@ pub async fn start_project_container(
}
}
if project.backend == Backend::LlamaCpp {
let cfg = project.llamacpp_config.as_ref()
.ok_or_else(|| "llama.cpp backend selected but no llama.cpp configuration found.".to_string())?;
if cfg.base_url.trim().is_empty()
&& settings.global_llamacpp.base_url.as_deref().map(str::trim).unwrap_or("").is_empty()
{
return Err("llama.cpp base URL is required. Set it per-project or in global llama.cpp settings.".to_string());
}
}
if project.backend == Backend::OpenAiCompatible {
let oai_config = project.openai_compatible_config.as_ref()
.ok_or_else(|| "OpenAI Compatible backend selected but no configuration found.".to_string())?;
@@ -314,6 +324,7 @@ pub async fn start_project_container(
&project,
&settings.global_aws,
&settings.global_ollama,
&settings.global_llamacpp,
&settings.global_openai_compatible,
settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars,
@@ -356,6 +367,7 @@ pub async fn start_project_container(
aws_config_path.as_deref(),
&settings.global_aws,
&settings.global_ollama,
&settings.global_llamacpp,
&settings.global_openai_compatible,
settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars,
@@ -393,6 +405,7 @@ pub async fn start_project_container(
aws_config_path.as_deref(),
&settings.global_aws,
&settings.global_ollama,
&settings.global_llamacpp,
&settings.global_openai_compatible,
settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars,
+449 -3
View File
@@ -8,7 +8,7 @@ use std::collections::HashMap;
use sha2::{Sha256, Digest};
use super::client::get_docker;
use crate::models::{Backend, BedrockAuthMethod, ClaudeCodeSettings, ContainerInfo, EnvVar, GlobalAwsSettings, GlobalOllamaSettings, GlobalOpenAiCompatibleSettings, PortMapping, Project, ProjectPath};
use crate::models::{Backend, BedrockAuthMethod, ClaudeCodeSettings, ContainerInfo, EnvVar, GlobalAwsSettings, GlobalLlamaCppSettings, GlobalOllamaSettings, GlobalOpenAiCompatibleSettings, PortMapping, Project, ProjectPath};
const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks
@@ -194,8 +194,89 @@ const RESERVED_ENV_EXACT: &[&str] = &[
"MISSION_CONTROL_ENABLED",
"TRIPLE_C_PERMISSION_MODE",
CLAUDE_OAUTH_TOKEN_ENV,
// The model-alias vars are already covered by the `ANTHROPIC_` prefix
// above; they are listed explicitly so that a future narrowing of the
// prefix list cannot silently unreserve them, and so `is_reserved_env_key`
// reads as the single, complete statement of what Triple-C owns.
ANTHROPIC_DEFAULT_OPUS_MODEL,
ANTHROPIC_DEFAULT_SONNET_MODEL,
ANTHROPIC_DEFAULT_HAIKU_MODEL,
ANTHROPIC_DEFAULT_FABLE_MODEL,
];
/// Claude Code's model-alias env vars. Each names the concrete model id that
/// one of the `opus` / `sonnet` / `haiku` / `fable` aliases resolves to.
///
/// `ANTHROPIC_DEFAULT_HAIKU_MODEL` is the important one: it is documented as
/// *"Model ID that the `haiku` alias resolves to, also used for background
/// functionality"* — conversation titles, summarisation, and other out-of-band
/// calls. Left unset against a local server, Claude Code sends
/// Anthropic's own Haiku model id to a server that has never heard of it and
/// every background call fails, usually silently.
///
/// (`ANTHROPIC_SMALL_FAST_MODEL` is the deprecated predecessor of the Haiku
/// var and is deliberately *not* used.)
pub const ANTHROPIC_DEFAULT_OPUS_MODEL: &str = "ANTHROPIC_DEFAULT_OPUS_MODEL";
pub const ANTHROPIC_DEFAULT_SONNET_MODEL: &str = "ANTHROPIC_DEFAULT_SONNET_MODEL";
pub const ANTHROPIC_DEFAULT_HAIKU_MODEL: &str = "ANTHROPIC_DEFAULT_HAIKU_MODEL";
pub const ANTHROPIC_DEFAULT_FABLE_MODEL: &str = "ANTHROPIC_DEFAULT_FABLE_MODEL";
/// Resolve the four `ANTHROPIC_DEFAULT_*_MODEL` values for a backend that
/// points Claude Code at a custom endpoint.
///
/// All four aliases fall back to `effective_model` — the backend's configured
/// model id, already resolved per-project → global. That is the right default:
/// a local server almost always serves exactly one model, so every alias must
/// name it or the calls that use an alias (notably the background ones, which
/// use `haiku`) go to a model the server does not have.
///
/// `haiku_override` exists because that is the one alias someone might
/// legitimately want to point elsewhere — at a second, smaller server-side
/// model kept for cheap background work. A blank override falls back to
/// `effective_model` like the others.
///
/// Returns pairs in `(name, value)` form; a blank resolved value emits nothing
/// at all rather than an empty var, so an unconfigured backend is left exactly
/// as Claude Code found it.
pub fn compute_model_aliases(
effective_model: Option<&str>,
haiku_override: Option<&str>,
) -> Vec<(&'static str, String)> {
let base = effective_model.map(str::trim).filter(|s| !s.is_empty());
let haiku = haiku_override
.map(str::trim)
.filter(|s| !s.is_empty())
.or(base);
let mut out: Vec<(&'static str, String)> = Vec::new();
if let Some(m) = base {
out.push((ANTHROPIC_DEFAULT_OPUS_MODEL, m.to_string()));
out.push((ANTHROPIC_DEFAULT_SONNET_MODEL, m.to_string()));
}
if let Some(h) = haiku {
out.push((ANTHROPIC_DEFAULT_HAIKU_MODEL, h.to_string()));
}
if let Some(m) = base {
out.push((ANTHROPIC_DEFAULT_FABLE_MODEL, m.to_string()));
}
out
}
/// The fingerprint contribution of the model aliases, so that changing an
/// alias (or the model it falls back to) forces a container recreation.
/// `container_needs_recreation` is label-based and never diffs env, so an
/// env-only change is invisible without this.
fn model_alias_fingerprint_part(
effective_model: Option<&str>,
haiku_override: Option<&str>,
) -> String {
compute_model_aliases(effective_model, haiku_override)
.into_iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join(",")
}
/// 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();
@@ -360,7 +441,13 @@ fn compute_bedrock_fingerprint(project: &Project, global_aws: &GlobalAwsSettings
}
/// Compute a fingerprint for the Ollama configuration so we can detect changes.
/// Includes the resolved base_url and model_id (per-project blank → global default).
/// Includes the resolved base_url and model_id (per-project blank → global
/// default) and the resolved model aliases.
///
/// NOTE: adding the alias part changes this hash for every existing Ollama
/// container, so each will be recreated once on the next start. That is exactly
/// what is wanted — recreation is the only way to get the new
/// `ANTHROPIC_DEFAULT_*_MODEL` vars into the container's env.
fn compute_ollama_fingerprint(project: &Project, global_ollama: &GlobalOllamaSettings) -> String {
if let Some(ref ollama) = project.ollama_config {
let effective_url = resolve_with_global(
@@ -371,7 +458,43 @@ fn compute_ollama_fingerprint(project: &Project, global_ollama: &GlobalOllamaSet
ollama.model_id.as_deref(),
global_ollama.default_model_id.as_deref(),
).unwrap_or("").to_string();
let parts = vec![effective_url, effective_model];
let aliases = model_alias_fingerprint_part(
Some(&effective_model),
resolve_with_global(
ollama.haiku_model_id.as_deref(),
global_ollama.default_haiku_model_id.as_deref(),
),
);
let parts = vec![effective_url, effective_model, aliases];
sha256_hex(&parts.join("|"))
} else {
String::new()
}
}
/// Compute a fingerprint for the llama.cpp configuration so we can detect
/// changes. Mirrors [`compute_ollama_fingerprint`].
fn compute_llamacpp_fingerprint(
project: &Project,
global_llamacpp: &GlobalLlamaCppSettings,
) -> String {
if let Some(ref cfg) = project.llamacpp_config {
let effective_url = resolve_with_global(
Some(&cfg.base_url),
global_llamacpp.base_url.as_deref(),
).unwrap_or("").to_string();
let effective_model = resolve_with_global(
cfg.model_id.as_deref(),
global_llamacpp.default_model_id.as_deref(),
).unwrap_or("").to_string();
let aliases = model_alias_fingerprint_part(
Some(&effective_model),
resolve_with_global(
cfg.haiku_model_id.as_deref(),
global_llamacpp.default_haiku_model_id.as_deref(),
),
);
let parts = vec![effective_url, effective_model, aliases];
sha256_hex(&parts.join("|"))
} else {
String::new()
@@ -393,10 +516,18 @@ fn compute_openai_compatible_fingerprint(
config.model_id.as_deref(),
global_openai_compatible.default_model_id.as_deref(),
).unwrap_or("").to_string();
let aliases = model_alias_fingerprint_part(
Some(&effective_model),
resolve_with_global(
config.haiku_model_id.as_deref(),
global_openai_compatible.default_haiku_model_id.as_deref(),
),
);
let parts = vec![
effective_url,
config.api_key.as_deref().unwrap_or("").to_string(),
effective_model,
aliases,
];
sha256_hex(&parts.join("|"))
} else {
@@ -576,6 +707,7 @@ pub async fn create_container(
aws_config_path: Option<&str>,
global_aws: &GlobalAwsSettings,
global_ollama: &GlobalOllamaSettings,
global_llamacpp: &GlobalLlamaCppSettings,
global_openai_compatible: &GlobalOpenAiCompatibleSettings,
global_claude_instructions: Option<&str>,
global_custom_env_vars: &[EnvVar],
@@ -701,6 +833,14 @@ pub async fn create_container(
}
}
// ── Custom-endpoint backends ─────────────────────────────────────────────
// Ollama, llama.cpp and the OpenAI-Compatible gateway all point Claude Code
// at a non-Anthropic server via ANTHROPIC_BASE_URL. Each resolves its model
// id here; the model-alias vars are emitted once below, from
// `alias_model` / `alias_haiku`, so the three backends cannot drift apart.
let mut alias_model: Option<String> = None;
let mut alias_haiku: Option<String> = None;
// Ollama configuration
if project.backend == Backend::Ollama {
if let Some(ref ollama) = project.ollama_config {
@@ -716,7 +856,44 @@ pub async fn create_container(
global_ollama.default_model_id.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_MODEL={}", model));
alias_model = Some(model.to_string());
}
alias_haiku = resolve_with_global(
ollama.haiku_model_id.as_deref(),
global_ollama.default_haiku_model_id.as_deref(),
)
.map(str::to_string);
}
}
// llama.cpp (llama-server) configuration
if project.backend == Backend::LlamaCpp {
if let Some(ref cfg) = project.llamacpp_config {
if let Some(url) = resolve_with_global(
Some(&cfg.base_url),
global_llamacpp.base_url.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_BASE_URL={}", url));
}
// llama-server only enforces an Authorization header when it was
// started with `--api-key` (default: none), so the value here is
// ignored in the common case. Claude Code still refuses to run
// against a custom base URL with no credential at all, so a
// placeholder is always sent — same trick as the Ollama branch
// above, which sends the literal "ollama".
env_vars.push("ANTHROPIC_AUTH_TOKEN=llama.cpp".to_string());
if let Some(model) = resolve_with_global(
cfg.model_id.as_deref(),
global_llamacpp.default_model_id.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_MODEL={}", model));
alias_model = Some(model.to_string());
}
alias_haiku = resolve_with_global(
cfg.haiku_model_id.as_deref(),
global_llamacpp.default_haiku_model_id.as_deref(),
)
.map(str::to_string);
}
}
@@ -737,7 +914,27 @@ pub async fn create_container(
global_openai_compatible.default_model_id.as_deref(),
) {
env_vars.push(format!("ANTHROPIC_MODEL={}", model));
alias_model = Some(model.to_string());
}
alias_haiku = resolve_with_global(
config.haiku_model_id.as_deref(),
global_openai_compatible.default_haiku_model_id.as_deref(),
)
.map(str::to_string);
}
}
// Model aliases — the fix for background Claude Code calls against a local
// server. Only for backends that talk to a custom endpoint: Anthropic and
// Bedrock reach servers that really do host the Anthropic model ids, so
// they keep Claude Code's own defaults. Anything not emitted here is
// blanked by the MANAGED_AUTH_KEYS pass below, so switching *away* from a
// custom endpoint clears the aliases out of the snapshot image too.
if project.backend.uses_custom_endpoint() {
for (key, value) in
compute_model_aliases(alias_model.as_deref(), alias_haiku.as_deref())
{
env_vars.push(format!("{}={}", key, value));
}
}
@@ -778,6 +975,15 @@ pub async fn create_container(
"ANTHROPIC_MODEL",
"DISABLE_PROMPT_CACHING",
"ANTHROPIC_BEDROCK_SERVICE_TIER",
// Switching from a custom-endpoint backend to Anthropic or Bedrock must
// *clear* the aliases, not merely stop setting them: a stale
// ANTHROPIC_DEFAULT_HAIKU_MODEL baked into the snapshot image would
// keep pointing background calls at a model id the new backend has
// never heard of.
ANTHROPIC_DEFAULT_OPUS_MODEL,
ANTHROPIC_DEFAULT_SONNET_MODEL,
ANTHROPIC_DEFAULT_HAIKU_MODEL,
ANTHROPIC_DEFAULT_FABLE_MODEL,
// 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
@@ -1004,6 +1210,7 @@ pub async fn create_container(
labels.insert("triple-c.paths-fingerprint".to_string(), compute_paths_fingerprint(&project.paths));
labels.insert("triple-c.bedrock-fingerprint".to_string(), compute_bedrock_fingerprint(project, global_aws));
labels.insert("triple-c.ollama-fingerprint".to_string(), compute_ollama_fingerprint(project, global_ollama));
labels.insert("triple-c.llamacpp-fingerprint".to_string(), compute_llamacpp_fingerprint(project, global_llamacpp));
labels.insert("triple-c.openai-compatible-fingerprint".to_string(), compute_openai_compatible_fingerprint(project, global_openai_compatible));
labels.insert("triple-c.ports-fingerprint".to_string(), compute_ports_fingerprint(&project.port_mappings));
labels.insert("triple-c.image".to_string(), image_name.to_string());
@@ -1301,6 +1508,7 @@ pub async fn container_needs_recreation(
project: &Project,
global_aws: &GlobalAwsSettings,
global_ollama: &GlobalOllamaSettings,
global_llamacpp: &GlobalLlamaCppSettings,
global_openai_compatible: &GlobalOpenAiCompatibleSettings,
global_claude_instructions: Option<&str>,
global_custom_env_vars: &[EnvVar],
@@ -1387,6 +1595,17 @@ pub async fn container_needs_recreation(
return Ok(true);
}
// ── llama.cpp config fingerprint ─────────────────────────────────────
// A missing label means the container predates the llama.cpp backend, in
// which case the expected fingerprint is also "" (no llamacpp_config) and
// nothing is recreated needlessly.
let expected_llamacpp_fp = compute_llamacpp_fingerprint(project, global_llamacpp);
let container_llamacpp_fp = get_label("triple-c.llamacpp-fingerprint").unwrap_or_default();
if container_llamacpp_fp != expected_llamacpp_fp {
log::info!("llama.cpp config mismatch");
return Ok(true);
}
// ── OpenAI Compatible config fingerprint ────────────────────────────
let expected_oai_fp = compute_openai_compatible_fingerprint(project, global_openai_compatible);
let container_oai_fp = get_label("triple-c.openai-compatible-fingerprint").unwrap_or_default();
@@ -1635,3 +1854,230 @@ pub async fn list_sibling_containers() -> Result<Vec<ContainerSummary>, String>
Ok(siblings)
}
#[cfg(test)]
mod tests {
use super::*;
const OPUS: &str = ANTHROPIC_DEFAULT_OPUS_MODEL;
const SONNET: &str = ANTHROPIC_DEFAULT_SONNET_MODEL;
const HAIKU: &str = ANTHROPIC_DEFAULT_HAIKU_MODEL;
const FABLE: &str = ANTHROPIC_DEFAULT_FABLE_MODEL;
fn aliases(model: Option<&str>, haiku: Option<&str>) -> Vec<(&'static str, String)> {
compute_model_aliases(model, haiku)
}
#[test]
fn all_four_aliases_fall_back_to_the_configured_model() {
assert_eq!(
aliases(Some("qwen3.5:27b"), None),
vec![
(OPUS, "qwen3.5:27b".to_string()),
(SONNET, "qwen3.5:27b".to_string()),
(HAIKU, "qwen3.5:27b".to_string()),
(FABLE, "qwen3.5:27b".to_string()),
]
);
}
#[test]
fn the_haiku_override_replaces_only_the_haiku_alias() {
let got = aliases(Some("big-model"), Some("small-model"));
assert_eq!(
got,
vec![
(OPUS, "big-model".to_string()),
(SONNET, "big-model".to_string()),
(HAIKU, "small-model".to_string()),
(FABLE, "big-model".to_string()),
]
);
}
#[test]
fn a_blank_or_whitespace_haiku_override_falls_back_to_the_model() {
for override_value in [Some(""), Some(" "), None] {
let got = aliases(Some("m"), override_value);
assert_eq!(
got.iter().find(|(k, _)| *k == HAIKU).map(|(_, v)| v.as_str()),
Some("m"),
"override {:?} should fall back to the model id",
override_value
);
}
}
#[test]
fn values_are_trimmed() {
assert_eq!(
aliases(Some(" m "), Some(" h ")),
vec![
(OPUS, "m".to_string()),
(SONNET, "m".to_string()),
(HAIKU, "h".to_string()),
(FABLE, "m".to_string()),
]
);
}
#[test]
fn no_model_and_no_override_emits_nothing() {
// Nothing to point the aliases at — leave Claude Code's defaults alone
// rather than injecting empty vars.
assert!(aliases(None, None).is_empty());
assert!(aliases(Some(""), Some(" ")).is_empty());
}
#[test]
fn a_haiku_override_alone_still_fixes_background_calls() {
// No model id configured, but the user pointed haiku somewhere: emit
// just that one, because it is the alias background work uses.
assert_eq!(
aliases(None, Some("small-model")),
vec![(HAIKU, "small-model".to_string())]
);
}
#[test]
fn only_custom_endpoint_backends_get_aliases() {
assert!(!Backend::Anthropic.uses_custom_endpoint());
assert!(!Backend::Bedrock.uses_custom_endpoint());
assert!(Backend::Ollama.uses_custom_endpoint());
assert!(Backend::LlamaCpp.uses_custom_endpoint());
assert!(Backend::OpenAiCompatible.uses_custom_endpoint());
}
#[test]
fn every_alias_var_is_reserved_and_managed() {
for key in [OPUS, SONNET, HAIKU, FABLE] {
assert!(is_reserved_env_key(key), "{} must be reserved", key);
assert!(
is_reserved_env_key(&key.to_lowercase()),
"{} must be reserved case-insensitively",
key
);
}
// A user-set alias must never survive into the container env.
let fp = compute_env_fingerprint(&[EnvVar {
key: HAIKU.to_string(),
value: "sneaky".to_string(),
}]);
assert_eq!(fp, "");
}
#[test]
fn the_deprecated_small_fast_model_var_is_never_emitted() {
let rendered: Vec<String> = aliases(Some("m"), Some("h"))
.into_iter()
.map(|(k, _)| k.to_string())
.collect();
assert!(!rendered.iter().any(|k| k == "ANTHROPIC_SMALL_FAST_MODEL"));
}
#[test]
fn the_alias_fingerprint_tracks_both_the_model_and_the_override() {
let base = model_alias_fingerprint_part(Some("m"), None);
assert_eq!(base, model_alias_fingerprint_part(Some("m"), Some("")));
assert_ne!(base, model_alias_fingerprint_part(Some("m2"), None));
assert_ne!(base, model_alias_fingerprint_part(Some("m"), Some("h")));
assert_eq!(model_alias_fingerprint_part(None, None), "");
}
fn project_with_llamacpp(model: Option<&str>, haiku: Option<&str>) -> Project {
let mut p = Project::new("t".to_string(), Vec::new());
p.backend = Backend::LlamaCpp;
p.llamacpp_config = Some(crate::models::LlamaCppConfig {
base_url: "http://host.docker.internal:8080".to_string(),
model_id: model.map(str::to_string),
haiku_model_id: haiku.map(str::to_string),
});
p
}
#[test]
fn llamacpp_fingerprint_changes_when_the_haiku_override_changes() {
let g = GlobalLlamaCppSettings::default();
let a = compute_llamacpp_fingerprint(&project_with_llamacpp(Some("m"), None), &g);
let b = compute_llamacpp_fingerprint(&project_with_llamacpp(Some("m"), Some("h")), &g);
assert_ne!(a, b, "the haiku override must force a container recreation");
// No config at all -> empty, so projects on other backends are not
// flagged for recreation by this fingerprint.
let plain = Project::new("t".to_string(), Vec::new());
assert_eq!(compute_llamacpp_fingerprint(&plain, &g), "");
}
#[test]
fn llamacpp_global_defaults_fill_in_for_blank_per_project_fields() {
let g = GlobalLlamaCppSettings {
base_url: Some("http://elsewhere:8080".to_string()),
default_model_id: Some("global-model".to_string()),
default_haiku_model_id: Some("global-haiku".to_string()),
};
// The per-project base URL is set in the fixture, so only the model and
// haiku fields fall through to the globals. Filling them in from the
// globals must be indistinguishable from setting them per-project.
let with_global = compute_llamacpp_fingerprint(&project_with_llamacpp(None, None), &g);
let explicit = compute_llamacpp_fingerprint(
&project_with_llamacpp(Some("global-model"), Some("global-haiku")),
&GlobalLlamaCppSettings::default(),
);
assert_eq!(with_global, explicit);
// …and changing a global must change the fingerprint, so a global-only
// edit still forces a recreation.
assert_ne!(
with_global,
compute_llamacpp_fingerprint(
&project_with_llamacpp(None, None),
&GlobalLlamaCppSettings {
default_haiku_model_id: Some("other-haiku".to_string()),
..g.clone()
},
)
);
assert_eq!(
resolve_with_global(None, g.default_haiku_model_id.as_deref()),
Some("global-haiku")
);
assert_eq!(
resolve_with_global(Some(" "), g.default_model_id.as_deref()),
Some("global-model")
);
}
#[test]
fn backend_serde_round_trips_llamacpp_and_accepts_legacy_spellings() {
assert_eq!(
serde_json::to_string(&Backend::LlamaCpp).unwrap(),
"\"llama_cpp\""
);
for spelling in ["\"llama_cpp\"", "\"llamacpp\"", "\"llama-cpp\"", "\"llama.cpp\""] {
let parsed: Backend = serde_json::from_str(spelling).unwrap();
assert_eq!(parsed, Backend::LlamaCpp, "failed for {}", spelling);
}
}
#[test]
fn a_project_json_without_llamacpp_config_still_deserialises() {
// `projects.json` written by an older build has no llamacpp_config key.
let json = serde_json::json!({
"id": "p1",
"name": "old",
"paths": [],
"container_id": null,
"status": "stopped",
"backend": "ollama",
"bedrock_config": null,
"ollama_config": { "base_url": "http://x:11434", "model_id": "m" },
"openai_compatible_config": null,
"allow_docker_access": false,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
});
let p: Project = serde_json::from_value(json).unwrap();
assert!(p.llamacpp_config.is_none());
// The new per-backend haiku override also defaults cleanly.
assert!(p.ollama_config.unwrap().haiku_model_id.is_none());
}
}
+644
View File
@@ -0,0 +1,644 @@
//! Lifecycle for the **model gateway** container — a pinned LiteLLM proxy that
//! Triple-C runs as a sibling of the project containers.
//!
//! Shape mirrors `docker::stt`: an image that is either pulled from a registry
//! or built locally from an embedded Dockerfile, a fixed container name, a
//! named volume, and `get_* / ensure_*_running / stop_* / pull_* / build_*`.
//!
//! Two things differ from STT, both deliberate:
//!
//! * **The port is published on `0.0.0.0`, not `127.0.0.1`.** STT is consumed
//! by the Tauri host process, so loopback is enough. The gateway is consumed
//! by *project containers*, which sit on Docker's default bridge and reach
//! the host through the bridge gateway — a loopback-only bind is invisible to
//! them. See [`gateway_base_url`].
//! * **The rendered config is uploaded into the container over the Docker
//! API** rather than passed as env. It holds the provider API key, and both
//! env vars and labels are readable by anything on the host via
//! `docker inspect`.
use bollard::container::{
Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions,
StartContainerOptions, StopContainerOptions, UploadToContainerOptions,
};
use bollard::image::BuildImageOptions;
use bollard::models::{HostConfig, Mount, MountTypeEnum, PortBinding};
use futures_util::StreamExt;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::io::Write;
use super::client::get_docker;
use crate::models::gateway_settings::{GatewaySettings, GatewayStatus};
use crate::storage::secure;
const GATEWAY_CONTAINER_NAME: &str = "triple-c-gateway";
const GATEWAY_CONFIG_VOLUME: &str = "triple-c-gateway-config";
/// Upstream LiteLLM, pinned to an exact release.
///
/// LiteLLM 1.82.7 and 1.82.8 shipped credential-harvesting malware on PyPI, so
/// nothing here may float a tag or resolve `litellm` at build time. v1.96.0 is
/// also above the 1.84.0 floor set by the proxy auth-bypass CVEs — see the long
/// comment in `gateway-container/Dockerfile`, and keep the two in lockstep.
const GATEWAY_REGISTRY_IMAGE: &str = "ghcr.io/berriai/litellm:v1.96.0";
const GATEWAY_LOCAL_IMAGE: &str = "triple-c-gateway:latest";
const GATEWAY_DOCKERFILE: &str = include_str!("../../../../gateway-container/Dockerfile");
const GATEWAY_DEFAULT_CONFIG: &str = include_str!("../../../../gateway-container/config.yaml");
/// Where the generated config lands inside the container. Backed by
/// [`GATEWAY_CONFIG_VOLUME`] so the file with the provider key lives in a
/// Docker-managed volume rather than an image layer.
const GATEWAY_CONFIG_DIR: &str = "/etc/litellm";
const GATEWAY_CONFIG_PATH: &str = "/etc/litellm/config.yaml";
/// Container-side port. Only the *host* port is user-configurable.
const GATEWAY_INTERNAL_PORT: u16 = 4000;
const CONFIG_FINGERPRINT_LABEL: &str = "triple-c.gateway.config-fingerprint";
/// The value a project should use as its base URL (`ANTHROPIC_BASE_URL`).
///
/// Project containers run on Docker's default bridge with no user-defined
/// network and no `--add-host`, so the only address they share with the
/// gateway is the host itself. Publishing the gateway on `0.0.0.0:<port>`
/// makes it reachable from every container network on the machine:
///
/// * Docker Desktop (macOS / Windows / WSL2) resolves `host.docker.internal`
/// from inside containers automatically — that is the portable value and the
/// one already suggested by the existing OpenAI-compatible placeholder text.
/// * On native Linux Docker `host.docker.internal` is not injected, and the
/// equivalent address is the default bridge gateway, normally
/// `http://172.17.0.1:<port>`.
pub fn gateway_base_url(port: u16) -> String {
format!("http://host.docker.internal:{}", port)
}
fn sha256_hex(input: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(input.as_bytes());
format!("{:x}", hasher.finalize())
}
pub async fn get_gateway_status(settings: &GatewaySettings) -> Result<GatewayStatus, String> {
let image_exists = super::image::image_exists(GATEWAY_REGISTRY_IMAGE)
.await
.unwrap_or(false)
|| super::image::image_exists(GATEWAY_LOCAL_IMAGE)
.await
.unwrap_or(false);
let (container_exists, running) = match find_gateway_container().await? {
Some((_, state, _)) => (true, state == "running"),
None => (false, false),
};
Ok(GatewayStatus {
container_exists,
running,
port: settings.port,
image_exists,
model_count: settings.valid_models().len(),
has_api_key: secure::has_gateway_api_key(),
base_url: gateway_base_url(settings.port),
})
}
/// `(id, state, config fingerprint label)` for the gateway container, if any.
async fn find_gateway_container() -> Result<Option<(String, String, String)>, String> {
let docker = get_docker()?;
let filters: HashMap<String, Vec<String>> = HashMap::from([(
"name".to_string(),
vec![format!("/{}", GATEWAY_CONTAINER_NAME)],
)]);
let containers = docker
.list_containers(Some(ListContainersOptions {
all: true,
filters,
..Default::default()
}))
.await
.map_err(|e| format!("Failed to list containers: {}", e))?;
if let Some(container) = containers.first() {
let id = container.id.clone().unwrap_or_default();
let state = container.state.clone().unwrap_or_default();
let fingerprint = container
.labels
.as_ref()
.and_then(|l| l.get(CONFIG_FINGERPRINT_LABEL))
.cloned()
.unwrap_or_default();
return Ok(Some((id, state, fingerprint)));
}
Ok(None)
}
// ─────────────────────────────────────────────────────────────────────────────
// Config generation
// ─────────────────────────────────────────────────────────────────────────────
/// Render a YAML double-quoted scalar.
///
/// Everything that reaches the config comes from user input (model names, base
/// URLs, keys), so nothing may be interpolated raw — a stray `"` or newline
/// would otherwise rewrite the document.
fn yaml_str(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('"');
for c in value.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\x{:02x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
/// The parts of the config that are safe to hash into a Docker label — i.e.
/// everything except the two secrets, whose changes are tracked by the
/// keychain rotation id instead.
fn config_shape(settings: &GatewaySettings) -> String {
let models: Vec<String> = settings
.valid_models()
.iter()
.map(|m| format!("{}={}", m.name.trim(), m.model_id.trim()))
.collect();
format!(
"provider={};api_base={};port={};models={}",
settings.provider.trim(),
settings.api_base.as_deref().unwrap_or("").trim(),
settings.port,
models.join(",")
)
}
/// Render the LiteLLM config for the current settings.
///
/// `api_key` and `master_key` come from the keychain. The returned string
/// contains both — it goes straight into the Docker upload and must never be
/// logged or surfaced.
fn render_config(settings: &GatewaySettings, api_key: &str, master_key: &str) -> String {
let provider = settings.provider.trim();
let api_base = settings
.api_base
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
let mut out = String::from(
"# Generated by Triple-C — do not edit by hand; it is overwritten on every\n\
# gateway (re)start from Settings → Model Gateway.\n\
model_list:\n",
);
for model in settings.valid_models() {
out.push_str(&format!(" - model_name: {}\n", yaml_str(model.name.trim())));
out.push_str(" litellm_params:\n");
out.push_str(&format!(
" model: {}\n",
yaml_str(&format!("{}/{}", provider, model.model_id.trim()))
));
out.push_str(&format!(" api_key: {}\n", yaml_str(api_key)));
if let Some(base) = api_base {
out.push_str(&format!(" api_base: {}\n", yaml_str(base)));
}
}
out.push_str("general_settings:\n");
out.push_str(&format!(" master_key: {}\n", yaml_str(master_key)));
out.push_str("litellm_settings:\n");
// Claude Code's Anthropic-format requests carry fields some providers
// reject outright; dropping the unsupported ones is what lets the
// translation survive across providers.
out.push_str(" drop_params: true\n");
out
}
/// Upload the rendered config into the container's config volume.
///
/// Runs against a *created but not yet started* container, which is when the
/// volume already exists but LiteLLM has not read anything from it.
async fn upload_config(container_id: &str, config: &str) -> Result<(), String> {
let docker = get_docker()?;
let mut buf = Vec::new();
{
let mut archive = tar::Builder::new(&mut buf);
let mut header = tar::Header::new_gnu();
header.set_size(config.len() as u64);
// World-readable: the upstream image may run LiteLLM as a non-root
// user, and a root-owned 0600 file would simply be unreadable. The
// secret is only exposed to the gateway container itself, which is
// the one process that needs it.
header.set_mode(0o644);
header.set_cksum();
archive
.append_data(&mut header, "config.yaml", config.as_bytes())
.map_err(|e| format!("Failed to build the gateway config archive: {}", e))?;
archive
.finish()
.map_err(|e| format!("Failed to build the gateway config archive: {}", e))?;
}
let _ = buf.flush();
docker
.upload_to_container(
container_id,
Some(UploadToContainerOptions {
path: GATEWAY_CONFIG_DIR,
..Default::default()
}),
buf.into(),
)
.await
.map_err(|e| format!("Failed to upload the gateway config: {}", e))
}
// ─────────────────────────────────────────────────────────────────────────────
// Lifecycle
// ─────────────────────────────────────────────────────────────────────────────
async fn create_gateway_container(
settings: &GatewaySettings,
fingerprint: &str,
) -> Result<String, String> {
let docker = get_docker()?;
// Local build first, then the pinned upstream image — same precedence as
// the STT container.
let image = if super::image::image_exists(GATEWAY_LOCAL_IMAGE)
.await
.unwrap_or(false)
{
GATEWAY_LOCAL_IMAGE.to_string()
} else if super::image::image_exists(GATEWAY_REGISTRY_IMAGE)
.await
.unwrap_or(false)
{
GATEWAY_REGISTRY_IMAGE.to_string()
} else {
return Err(
"Gateway image not found. Please pull or build the image first.".to_string(),
);
};
let mut port_bindings = HashMap::new();
port_bindings.insert(
format!("{}/tcp", GATEWAY_INTERNAL_PORT),
Some(vec![PortBinding {
// Not loopback — project containers reach this through the host.
// See `gateway_base_url`.
host_ip: Some("0.0.0.0".to_string()),
host_port: Some(settings.port.to_string()),
}]),
);
let mut exposed_ports: HashMap<String, HashMap<(), ()>> = HashMap::new();
exposed_ports.insert(format!("{}/tcp", GATEWAY_INTERNAL_PORT), HashMap::new());
let host_config = HostConfig {
port_bindings: Some(port_bindings),
mounts: Some(vec![Mount {
target: Some(GATEWAY_CONFIG_DIR.to_string()),
source: Some(GATEWAY_CONFIG_VOLUME.to_string()),
typ: Some(MountTypeEnum::VOLUME),
..Default::default()
}]),
init: Some(true),
..Default::default()
};
// Non-secret only. Labels are readable by anything on the host.
let mut labels = HashMap::new();
labels.insert(CONFIG_FINGERPRINT_LABEL.to_string(), fingerprint.to_string());
labels.insert(
"triple-c.gateway.port".to_string(),
settings.port.to_string(),
);
labels.insert(
"triple-c.gateway.provider".to_string(),
settings.provider.trim().to_string(),
);
let config = Config {
image: Some(image),
// The upstream entrypoint (`docker/prod_entrypoint.sh`) execs
// `litellm "$@"`. Passed explicitly so the pulled upstream image and
// our locally built one behave identically.
cmd: Some(vec![
"--config".to_string(),
GATEWAY_CONFIG_PATH.to_string(),
"--host".to_string(),
"0.0.0.0".to_string(),
"--port".to_string(),
GATEWAY_INTERNAL_PORT.to_string(),
]),
exposed_ports: Some(exposed_ports),
host_config: Some(host_config),
labels: Some(labels),
..Default::default()
};
let options = CreateContainerOptions {
name: GATEWAY_CONTAINER_NAME,
..Default::default()
};
let response = docker
.create_container(Some(options), config)
.await
.map_err(|e| format!("Failed to create gateway container: {}", e))?;
Ok(response.id)
}
pub async fn ensure_gateway_running(settings: &GatewaySettings) -> Result<GatewayStatus, String> {
let docker = get_docker()?;
if settings.valid_models().is_empty() {
return Err(
"The gateway has no models configured. Add at least one model in Settings."
.to_string(),
);
}
let api_key = secure::get_gateway_api_key()?
.filter(|k| !k.trim().is_empty())
.ok_or_else(|| {
"No provider API key stored for the gateway. Add one in Settings.".to_string()
})?;
let master_key = secure::get_or_create_gateway_master_key()?;
// Rotation id, not a hash of either secret — see `storage::secure`.
let secret_version = secure::get_gateway_secret_version()?.unwrap_or_default();
let fingerprint = sha256_hex(&format!(
"{}|{}",
config_shape(settings),
secret_version
));
if let Some((id, state, existing_fingerprint)) = find_gateway_container().await? {
if existing_fingerprint == fingerprint {
if state == "running" {
return get_gateway_status(settings).await;
}
docker
.start_container(&id, None::<StartContainerOptions<String>>)
.await
.map_err(|e| format!("Failed to start gateway container: {}", e))?;
return get_gateway_status(settings).await;
}
// Config or a secret changed — recreate so the new config is uploaded.
if state == "running" {
docker
.stop_container(&id, None::<StopContainerOptions>)
.await
.map_err(|e| format!("Failed to stop gateway container: {}", e))?;
}
docker
.remove_container(
&id,
Some(RemoveContainerOptions {
force: true,
..Default::default()
}),
)
.await
.map_err(|e| format!("Failed to remove gateway container: {}", e))?;
}
let id = create_gateway_container(settings, &fingerprint).await?;
// Upload before the first start: LiteLLM reads the config once at boot.
let rendered = render_config(settings, &api_key, &master_key);
if let Err(e) = upload_config(&id, &rendered).await {
// Don't leave a half-configured container behind for the next run to
// mistake for a good one.
let _ = docker
.remove_container(
&id,
Some(RemoveContainerOptions {
force: true,
..Default::default()
}),
)
.await;
return Err(e);
}
docker
.start_container(&id, None::<StartContainerOptions<String>>)
.await
.map_err(|e| format!("Failed to start gateway container: {}", e))?;
log::info!(
"Model gateway started on port {} ({} model(s))",
settings.port,
settings.valid_models().len()
);
get_gateway_status(settings).await
}
pub async fn stop_gateway_container() -> Result<(), String> {
let docker = get_docker()?;
if let Some((id, state, _)) = find_gateway_container().await? {
if state == "running" {
docker
.stop_container(&id, None::<StopContainerOptions>)
.await
.map_err(|e| format!("Failed to stop gateway container: {}", e))?;
}
}
Ok(())
}
/// Ask the running gateway whether it is up. LiteLLM takes several seconds to
/// boot, so "container running" and "gateway answering" are not the same thing.
pub async fn check_gateway_health(port: u16) -> Result<bool, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.map_err(|e| format!("Failed to build HTTP client: {}", e))?;
match client
.get(format!("http://127.0.0.1:{}/health/liveliness", port))
.send()
.await
{
Ok(response) => Ok(response.status().is_success()),
Err(e) if e.is_connect() || e.is_timeout() => Ok(false),
Err(e) => Err(format!("Gateway health check failed: {}", e)),
}
}
pub async fn pull_gateway_image<F>(on_progress: F) -> Result<(), String>
where
F: Fn(String) + Send + 'static,
{
super::image::pull_image(GATEWAY_REGISTRY_IMAGE, on_progress).await
}
pub async fn build_gateway_image<F>(on_progress: F) -> Result<(), String>
where
F: Fn(String) + Send + 'static,
{
let docker = get_docker()?;
let tar_bytes = create_gateway_build_context()
.map_err(|e| format!("Failed to create gateway build context: {}", e))?;
let options = BuildImageOptions {
t: GATEWAY_LOCAL_IMAGE,
rm: true,
forcerm: true,
..Default::default()
};
let mut stream = docker.build_image(options, None, Some(tar_bytes.into()));
while let Some(result) = stream.next().await {
match result {
Ok(output) => {
if let Some(stream) = output.stream {
on_progress(stream);
}
if let Some(error) = output.error {
return Err(format!("Build error: {}", error));
}
}
Err(e) => return Err(format!("Build stream error: {}", e)),
}
}
Ok(())
}
fn create_gateway_build_context() -> Result<Vec<u8>, std::io::Error> {
let mut buf = Vec::new();
{
let mut archive = tar::Builder::new(&mut buf);
let mut dockerfile_header = tar::Header::new_gnu();
dockerfile_header.set_size(GATEWAY_DOCKERFILE.len() as u64);
dockerfile_header.set_mode(0o644);
dockerfile_header.set_cksum();
archive.append_data(
&mut dockerfile_header,
"Dockerfile",
GATEWAY_DOCKERFILE.as_bytes(),
)?;
let mut config_header = tar::Header::new_gnu();
config_header.set_size(GATEWAY_DEFAULT_CONFIG.len() as u64);
config_header.set_mode(0o644);
config_header.set_cksum();
archive.append_data(
&mut config_header,
"config.yaml",
GATEWAY_DEFAULT_CONFIG.as_bytes(),
)?;
archive.finish()?;
}
let _ = buf.flush();
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::gateway_settings::GatewayModel;
fn settings() -> GatewaySettings {
GatewaySettings {
enabled: true,
port: 4000,
provider: "openai".to_string(),
api_base: None,
models: vec![
GatewayModel {
name: "gpt-5.1".to_string(),
model_id: "gpt-5.1".to_string(),
},
// Half-filled rows must not reach the YAML.
GatewayModel {
name: " ".to_string(),
model_id: "gpt-4o".to_string(),
},
],
}
}
#[test]
fn valid_models_skips_incomplete_rows() {
assert_eq!(settings().valid_models().len(), 1);
}
#[test]
fn render_config_composes_provider_and_model_id() {
let yaml = render_config(&settings(), "sk-provider", "sk-master");
assert!(yaml.contains("model_name: \"gpt-5.1\""));
assert!(yaml.contains("model: \"openai/gpt-5.1\""));
assert!(yaml.contains("api_key: \"sk-provider\""));
assert!(yaml.contains("master_key: \"sk-master\""));
assert!(yaml.contains("drop_params: true"));
// The skipped row must be absent.
assert!(!yaml.contains("gpt-4o"));
}
#[test]
fn render_config_emits_api_base_only_when_set() {
let mut s = settings();
assert!(!render_config(&s, "k", "m").contains("api_base"));
s.api_base = Some("https://example.test/v1".to_string());
assert!(render_config(&s, "k", "m").contains("api_base: \"https://example.test/v1\""));
// Blank is treated as unset rather than emitted as an empty URL.
s.api_base = Some(" ".to_string());
assert!(!render_config(&s, "k", "m").contains("api_base"));
}
#[test]
fn yaml_str_escapes_injection_attempts() {
let hostile = "a\"\nmaster_key: \"pwned";
let quoted = yaml_str(hostile);
assert!(quoted.starts_with('"') && quoted.ends_with('"'));
// No raw newline can escape the scalar and start a new YAML key.
assert!(!quoted[1..quoted.len() - 1].contains('\n'));
assert!(quoted.contains("\\\""));
}
#[test]
fn config_shape_excludes_secrets_and_tracks_changes() {
let a = config_shape(&settings());
let mut s = settings();
s.models[0].model_id = "gpt-4.1".to_string();
assert_ne!(a, config_shape(&s));
assert!(!a.contains("sk-"));
}
#[test]
fn base_url_points_at_the_host_not_loopback() {
// A project container cannot reach the host's loopback interface.
let url = gateway_base_url(4000);
assert_eq!(url, "http://host.docker.internal:4000");
assert!(!url.contains("127.0.0.1"));
}
}
+3
View File
@@ -2,9 +2,12 @@ pub mod client;
pub mod container;
pub mod image;
pub mod exec;
pub mod gateway;
pub mod legacy_cleanup;
pub mod stt;
#[allow(unused_imports)]
pub use gateway::*;
#[allow(unused_imports)]
pub use stt::*;
#[allow(unused_imports)]
+39
View File
@@ -1,4 +1,5 @@
mod auth_bridge;
mod browser_view;
mod commands;
mod docker;
mod install_helper;
@@ -126,6 +127,25 @@ pub fn run() {
});
}
// Auto-start model gateway container if enabled in settings
if settings.gateway.enabled {
let gateway_settings = settings.gateway.clone();
tauri::async_runtime::spawn(async move {
match docker::gateway::ensure_gateway_running(&gateway_settings).await {
Ok(status) => {
if status.running {
log::info!("Model gateway auto-started on port {}", gateway_settings.port);
} else {
log::warn!("Model gateway auto-start: container not running after ensure_gateway_running");
}
}
Err(e) => {
log::error!("Failed to auto-start model gateway container: {}", e);
}
}
});
}
Ok(())
})
.on_window_event(|window, event| {
@@ -139,10 +159,14 @@ pub fn run() {
}
// Stop STT container
let _ = docker::stt::stop_stt_container().await;
// Stop model gateway container
let _ = docker::gateway::stop_gateway_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;
// Stop any browser-view proxies and in-container dashboards
browser_view::manager().stop_all().await;
});
}
})
@@ -165,6 +189,10 @@ pub fn run() {
// Auth bridge
commands::auth_bridge_commands::set_auth_bridge_enabled,
commands::auth_bridge_commands::get_auth_bridge_status,
// Browser view (Playwright dashboard pane)
browser_view::commands::set_browser_view_enabled,
browser_view::commands::get_browser_view_status,
browser_view::commands::check_browser_view_support,
// Shared Claude Code auth token
commands::auth_token_commands::acquire_claude_token,
commands::auth_token_commands::submit_claude_token_code,
@@ -216,6 +244,17 @@ pub fn run() {
commands::stt_commands::build_stt_image,
commands::stt_commands::pull_stt_image,
commands::stt_commands::transcribe_audio,
// Model gateway (LiteLLM)
commands::gateway_commands::get_gateway_status,
commands::gateway_commands::start_gateway,
commands::gateway_commands::stop_gateway,
commands::gateway_commands::check_gateway_health,
commands::gateway_commands::build_gateway_image,
commands::gateway_commands::pull_gateway_image,
commands::gateway_commands::set_gateway_api_key,
commands::gateway_commands::clear_gateway_api_key,
commands::gateway_commands::get_gateway_auth_token,
commands::gateway_commands::regenerate_gateway_auth_token,
// Container introspection (sessions / capabilities / scheduler)
commands::inspect_commands::list_claude_sessions,
commands::inspect_commands::resume_session_command,
+26
View File
@@ -1,5 +1,6 @@
use serde::{Deserialize, Serialize};
use super::gateway_settings::GatewaySettings;
use super::project::{ClaudeCodeSettings, EnvVar};
fn default_true() -> bool {
@@ -53,6 +54,23 @@ pub struct GlobalOllamaSettings {
pub base_url: Option<String>,
#[serde(default)]
pub default_model_id: Option<String>,
/// Global fallback for the `haiku` alias override. Blank means "use the
/// resolved model id", which is what makes background Claude Code calls
/// work against a server that only serves one model.
#[serde(default)]
pub default_haiku_model_id: Option<String>,
}
/// Global defaults for the llama.cpp (`llama-server`) backend.
/// Mirrors [`GlobalOllamaSettings`]; used when the per-project field is blank.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GlobalLlamaCppSettings {
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub default_model_id: Option<String>,
#[serde(default)]
pub default_haiku_model_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -61,6 +79,8 @@ pub struct GlobalOpenAiCompatibleSettings {
pub base_url: Option<String>,
#[serde(default)]
pub default_model_id: Option<String>,
#[serde(default)]
pub default_haiku_model_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -82,6 +102,8 @@ pub struct AppSettings {
#[serde(default)]
pub global_ollama: GlobalOllamaSettings,
#[serde(default)]
pub global_llamacpp: GlobalLlamaCppSettings,
#[serde(default)]
pub global_openai_compatible: GlobalOpenAiCompatibleSettings,
#[serde(default = "default_global_instructions")]
pub global_claude_instructions: Option<String>,
@@ -102,6 +124,8 @@ pub struct AppSettings {
#[serde(default)]
pub stt: SttSettings,
#[serde(default)]
pub gateway: GatewaySettings,
#[serde(default)]
pub global_claude_code_settings: Option<ClaudeCodeSettings>,
}
@@ -180,6 +204,7 @@ impl Default for AppSettings {
custom_image_name: None,
global_aws: GlobalAwsSettings::default(),
global_ollama: GlobalOllamaSettings::default(),
global_llamacpp: GlobalLlamaCppSettings::default(),
global_openai_compatible: GlobalOpenAiCompatibleSettings::default(),
global_claude_instructions: default_global_instructions(),
global_custom_env_vars: Vec::new(),
@@ -190,6 +215,7 @@ impl Default for AppSettings {
dismissed_image_digest: None,
web_terminal: WebTerminalSettings::default(),
stt: SttSettings::default(),
gateway: GatewaySettings::default(),
global_claude_code_settings: None,
}
}
@@ -0,0 +1,99 @@
//! Settings and status for the **model gateway** — a LiteLLM proxy container
//! Triple-C runs as a sibling of the project containers.
//!
//! Claude Code speaks only the Anthropic Messages API (`POST
//! ${ANTHROPIC_BASE_URL}/v1/messages`). OpenAI has no such route, so an OpenAI
//! key cannot drive Claude Code directly. The gateway exposes `/v1/messages`
//! in Anthropic format and translates each call to the configured provider,
//! which is what turns "OpenAI Compatible" from *bring your own proxy* into
//! something Triple-C manages itself.
//!
//! Nothing secret lives in this module. The provider API key and the gateway's
//! own master key are held in the OS keychain (see `storage::secure`); what is
//! persisted to `settings.json` is only the non-secret shape of the config.
use serde::{Deserialize, Serialize};
/// LiteLLM's own default port, and the one the existing "OpenAI Compatible"
/// placeholder text already suggests.
pub fn default_gateway_port() -> u16 {
4000
}
fn default_gateway_provider() -> String {
"openai".to_string()
}
/// One entry of LiteLLM's `model_list`.
///
/// `name` is the friendly handle a project puts in its model field — it is what
/// Claude Code sends as the `model` of a `/v1/messages` request. `model_id` is
/// the provider-side id. The gateway config composes them as
/// `<provider>/<model_id>`, which is why the shape stays generic across
/// providers instead of hard-coding OpenAI.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct GatewayModel {
/// Friendly name projects use (e.g. `gpt-5.1`).
pub name: String,
/// Provider-side model id (e.g. `gpt-5.1`).
pub model_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GatewaySettings {
/// Auto-start the gateway container with the app.
#[serde(default)]
pub enabled: bool,
/// Host port the gateway is published on.
#[serde(default = "default_gateway_port")]
pub port: u16,
/// LiteLLM provider prefix — `openai`, `azure`, `gemini`, `groq`, …
#[serde(default = "default_gateway_provider")]
pub provider: String,
/// Optional provider base URL override (Azure endpoints, proxies, …).
#[serde(default)]
pub api_base: Option<String>,
/// Models the gateway should serve.
#[serde(default)]
pub models: Vec<GatewayModel>,
}
impl Default for GatewaySettings {
fn default() -> Self {
Self {
enabled: false,
port: default_gateway_port(),
provider: default_gateway_provider(),
api_base: None,
models: Vec::new(),
}
}
}
impl GatewaySettings {
/// Models with both fields filled in. Half-typed rows in the UI must not
/// reach the generated YAML.
pub fn valid_models(&self) -> Vec<&GatewayModel> {
self.models
.iter()
.filter(|m| !m.name.trim().is_empty() && !m.model_id.trim().is_empty())
.collect()
}
}
/// What the settings UI needs to know about the gateway. Deliberately carries
/// **no** secret: `has_api_key` is a boolean, not the key.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GatewayStatus {
pub container_exists: bool,
pub running: bool,
pub port: u16,
pub image_exists: bool,
/// Number of fully-specified models in the current settings.
pub model_count: usize,
/// Whether a provider API key is present in the keychain.
pub has_api_key: bool,
/// The value a project should use for its base URL. See
/// `docker::gateway::gateway_base_url`.
pub base_url: String,
}
+2
View File
@@ -1,9 +1,11 @@
pub mod project;
pub mod container_config;
pub mod app_settings;
pub mod gateway_settings;
pub mod update_info;
pub use project::*;
pub use container_config::*;
pub use app_settings::*;
pub use gateway_settings::*;
pub use update_info::*;
+78 -7
View File
@@ -123,6 +123,8 @@ pub struct Project {
pub backend: Backend,
pub bedrock_config: Option<BedrockConfig>,
pub ollama_config: Option<OllamaConfig>,
#[serde(default, alias = "llama_cpp_config")]
pub llamacpp_config: Option<LlamaCppConfig>,
#[serde(alias = "litellm_config")]
pub openai_compatible_config: Option<OpenAiCompatibleConfig>,
pub allow_docker_access: bool,
@@ -137,6 +139,12 @@ pub struct Project {
/// because toggling it changes nothing about the container itself.
#[serde(default)]
pub auth_bridge_enabled: bool,
/// Opt in to the browser-view pane, which watches and takes over the
/// browser Claude drives with Playwright inside the container. Purely
/// host-side like `auth_bridge_enabled`, so it likewise has no
/// container-recreation label.
#[serde(default)]
pub browser_view_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
@@ -191,8 +199,10 @@ pub enum ProjectStatus {
/// - `Anthropic`: Direct Anthropic API (user runs `claude login` inside the container)
/// - `Bedrock`: AWS Bedrock with per-project AWS credentials
/// - `Ollama`: Local or remote Ollama server
/// - `OpenAiCompatible`: Any OpenAI API-compatible endpoint (e.g., LiteLLM, vLLM, etc.)
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
/// - `LlamaCpp`: A local or remote `llama-server` (llama.cpp)
/// - `OpenAiCompatible`: Any endpoint that speaks the Anthropic Messages API
/// (e.g. LiteLLM). See [`Backend::uses_custom_endpoint`].
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Backend {
/// Backward compat: old projects stored as "login" or "api_key" map to Anthropic.
@@ -200,6 +210,10 @@ pub enum Backend {
Anthropic,
Bedrock,
Ollama,
/// Serialises as `llama_cpp`; the aliases accept the spellings a
/// hand-edited `projects.json` is likely to contain.
#[serde(alias = "llamacpp", alias = "llama-cpp", alias = "llama.cpp")]
LlamaCpp,
#[serde(alias = "lite_llm", alias = "litellm")]
OpenAiCompatible,
}
@@ -210,6 +224,28 @@ impl Default for Backend {
}
}
impl Backend {
/// Whether this backend points Claude Code at a non-Anthropic HTTP endpoint
/// via `ANTHROPIC_BASE_URL`.
///
/// Those endpoints serve whatever model *they* were started with, so
/// Claude Code's built-in `opus`/`sonnet`/`haiku`/`fable` aliases resolve to
/// Anthropic model ids the server has never heard of. Every backend for
/// which this returns `true` therefore gets the
/// `ANTHROPIC_DEFAULT_*_MODEL` alias vars pinned to the configured model —
/// see `docker::container::compute_model_aliases`.
///
/// Bedrock is deliberately excluded: it talks to AWS, which does host the
/// real Anthropic model ids, so Claude Code's own defaults are correct
/// there. Anthropic is excluded for the same reason.
pub fn uses_custom_endpoint(&self) -> bool {
matches!(
self,
Backend::Ollama | Backend::LlamaCpp | Backend::OpenAiCompatible
)
}
}
/// How Bedrock authenticates with AWS.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
@@ -248,27 +284,60 @@ pub struct BedrockConfig {
}
/// Ollama configuration for a project.
/// Ollama exposes an Anthropic-compatible API endpoint.
/// Ollama natively implements the Anthropic Messages API at `/v1/messages`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OllamaConfig {
/// The base URL of the Ollama server (e.g., "http://host.docker.internal:11434" or "http://192.168.1.100:11434")
pub base_url: String,
/// Optional model override (e.g., "qwen3.5:27b")
pub model_id: Option<String>,
/// Optional override for the model the `haiku` alias resolves to.
/// Blank falls back to `model_id`. See [`Backend::uses_custom_endpoint`].
#[serde(default)]
pub haiku_model_id: Option<String>,
}
/// llama.cpp (`llama-server`) configuration for a project.
///
/// `llama-server` natively implements the Anthropic Messages API at
/// `POST /v1/messages` (plus `/v1/messages/count_tokens`), so Claude Code can
/// talk to it directly through `ANTHROPIC_BASE_URL` — exactly like Ollama, with
/// no translation shim.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlamaCppConfig {
/// The base URL of the llama-server instance. `llama-server`'s default
/// listen port is 8080 (`--port PORT | port to listen (default: 8080)`).
pub base_url: String,
/// Optional model override. `llama-server` serves whatever model it was
/// started with, so this is mostly the id Claude Code should *say* it is
/// using — but it is also what the model aliases are pinned to.
pub model_id: Option<String>,
/// Optional override for the model the `haiku` alias resolves to.
/// Blank falls back to `model_id`.
#[serde(default)]
pub haiku_model_id: Option<String>,
}
/// OpenAI Compatible endpoint configuration for a project.
/// Routes Anthropic API calls through any OpenAI API-compatible endpoint
/// (e.g., LiteLLM, vLLM, or other compatible gateways).
///
/// Despite the name (kept for backward compatibility with existing
/// `projects.json` data), the endpoint must implement the **Anthropic Messages
/// API** — Claude Code only ever speaks `POST /v1/messages`. Gateways such as
/// LiteLLM expose an Anthropic-shaped route and work; a bare
/// `/v1/chat/completions` server does not.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAiCompatibleConfig {
/// The base URL of the OpenAI-compatible endpoint (e.g., "http://host.docker.internal:4000" or "https://api.example.com")
/// The base URL of the endpoint (e.g., "http://host.docker.internal:4000" or "https://api.example.com")
pub base_url: String,
/// API key for the OpenAI-compatible endpoint
/// API key for the endpoint
#[serde(skip_serializing, default)]
pub api_key: Option<String>,
/// Optional model override
pub model_id: Option<String>,
/// Optional override for the model the `haiku` alias resolves to.
/// Blank falls back to `model_id`.
#[serde(default)]
pub haiku_model_id: Option<String>,
}
impl Project {
@@ -283,11 +352,13 @@ impl Project {
backend: Backend::default(),
bedrock_config: None,
ollama_config: None,
llamacpp_config: None,
openai_compatible_config: None,
allow_docker_access: false,
sandbox_mode_enabled: false,
mission_control_enabled: false,
auth_bridge_enabled: false,
browser_view_enabled: false,
use_shared_auth_token: default_use_shared_auth_token(),
full_permissions: false,
permission_mode: None,
+113
View File
@@ -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)
}
@@ -226,6 +226,51 @@
.scroll-bottom-btn:hover { background: var(--accent-hover); }
.scroll-bottom-btn.visible { display: flex; }
/* ── URL relay banner ───────────────────── */
.relay-banner {
position: absolute;
top: 8px;
left: 50%;
transform: translateX(-50%);
max-width: min(94%, 620px);
display: none;
align-items: center;
gap: 10px;
padding: 8px 10px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.45);
z-index: 30;
}
.relay-banner.visible { display: flex; }
.relay-banner-text { flex: 1; min-width: 0; }
.relay-banner-label {
font-size: 11px;
color: var(--text-secondary);
margin-bottom: 2px;
}
.relay-banner-url {
display: block;
font-size: 12px;
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Menlo', monospace;
color: var(--accent);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.relay-banner-dismiss {
flex-shrink: 0;
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 14px;
line-height: 1;
padding: 4px 6px;
cursor: pointer;
}
.relay-banner-dismiss:hover { color: var(--text-primary); }
/* ── Empty State ─────────────────────────── */
.empty-state {
display: flex;
@@ -272,6 +317,15 @@
<div class="hint">Use the buttons above to start a Claude or Bash session</div>
</div>
<button class="scroll-bottom-btn" id="scrollBottomBtn" title="Scroll to bottom">&#8595;</button>
<!-- URL relay: a CLI in the container asked for a browser. Tap-to-open only,
never automatic — see the OSC 7777 handler below. -->
<div class="relay-banner" id="relayBanner">
<div class="relay-banner-text">
<div class="relay-banner-label">Container asked to open a URL &mdash; tap to open here</div>
<a class="relay-banner-url" id="relayBannerLink" target="_blank" rel="noopener noreferrer"></a>
</div>
<button class="relay-banner-dismiss" id="relayBannerDismiss" aria-label="Dismiss">&#10005;</button>
</div>
</div>
<!-- Input Bar for mobile/tablet -->
@@ -309,6 +363,96 @@
const btnTab = document.getElementById('btnTab');
const btnCtrlC = document.getElementById('btnCtrlC');
const scrollBottomBtn = document.getElementById('scrollBottomBtn');
const relayBanner = document.getElementById('relayBanner');
const relayBannerLink = document.getElementById('relayBannerLink');
const relayBannerDismiss = document.getElementById('relayBannerDismiss');
// ── URL relay (OSC 7777) ───────────────────
// `container/triple-c-open` — installed in the container as xdg-open,
// $BROWSER, sensible-browser, ... — emits ESC]7777;open;<base64(url)>BEL
// when a CLI wants a browser. The desktop app turns that into a host-browser
// open; here the only browser available is the *remote viewer's*.
//
// That is a different trust situation, so this deliberately does NOT mirror
// the desktop behaviour: nothing opens by itself. The web terminal may be
// reached from a phone on the LAN or through a tunnel, and the viewer's
// browser carries their own logged-in sessions and can reach their own
// network. We surface the request as a tap-to-open link and let the human
// decide. (A popup would be blocked without a user gesture anyway.)
// The same http/https allowlist as the desktop side applies — this file is
// standalone (embedded via include_str!) so it cannot import lib/urlRelay.ts;
// the logic is kept deliberately short and identical in behaviour.
const RELAY_OSC = 7777;
const RELAY_MAX_URL = 8192;
let relayTimes = [];
let relayLastUrl = null;
let relayLastAt = 0;
let relayHideTimer = null;
function sanitizeRelayUrl(raw) {
if (typeof raw !== 'string') return null;
const s = raw.trim();
if (!s || s.length > RELAY_MAX_URL) return null;
// Control characters and whitespace first: new URL() strips tabs/newlines,
// so "java\nscript:" would otherwise slip through as javascript:.
if (/[\s\u0000-\u0020\u007f]/.test(s)) return null;
let u;
try { u = new URL(s); } catch (e) { return null; }
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
if (!u.hostname) return null;
if (u.username || u.password) return null; // origin spoofing
return u.toString();
}
function parseRelayOsc(data) {
if (typeof data !== 'string') return null;
const sep = data.indexOf(';');
if (sep === -1) return null;
if (data.slice(0, sep) !== 'open') return null;
const body = data.slice(sep + 1);
if (!body || body.length > RELAY_MAX_URL * 2) return null;
if (!/^[A-Za-z0-9+/]+=*$/.test(body)) return null;
let text;
try {
const bin = atob(body);
const bytes = Uint8Array.from(bin, c => c.charCodeAt(0));
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
} catch (e) { return null; }
return sanitizeRelayUrl(text);
}
// Cap the prompt rate so a runaway loop in the container can't bury the UI.
function relayAllowed(url) {
const now = Date.now();
if (url === relayLastUrl && now - relayLastAt < 5000) {
relayLastAt = now;
return false;
}
relayTimes = relayTimes.filter(t => now - t < 10000);
if (relayTimes.length >= 5) return false;
relayTimes.push(now);
relayLastUrl = url;
relayLastAt = now;
return true;
}
function hideRelayBanner() {
relayBanner.classList.remove('visible');
relayBannerLink.removeAttribute('href');
relayBannerLink.textContent = '';
clearTimeout(relayHideTimer);
}
function showRelayBanner(url) {
relayBannerLink.href = url;
relayBannerLink.textContent = url;
relayBanner.classList.add('visible');
clearTimeout(relayHideTimer);
relayHideTimer = setTimeout(hideRelayBanner, 60000);
}
relayBannerDismiss.addEventListener('click', hideRelayBanner);
relayBannerLink.addEventListener('click', () => hideRelayBanner());
// ── WebSocket ──────────────────────────────
function connect() {
@@ -448,6 +592,15 @@
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
term.loadAddon(webLinksAddon);
// URL relay from the container (see the OSC 7777 notes above). Always
// returns true so the sequence is consumed and never painted as garbage,
// whether or not we act on it.
term.parser.registerOscHandler(RELAY_OSC, data => {
const url = parseRelayOsc(data);
if (url && relayAllowed(url)) showRelayBanner(url);
return true;
});
// Create container div
const container = document.createElement('div');
container.className = 'terminal-container';