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:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user