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
@@ -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);
}
}