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
+52 -12
View File
@@ -10,6 +10,11 @@ import { useAppState } from "../../store/appState";
import { awsSsoRefresh, uploadHostFileToTerminal } from "../../lib/tauri-commands";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import { UrlDetector } from "../../lib/urlDetector";
import {
RelayRateLimiter,
URL_RELAY_OSC,
parseUrlRelayOsc,
} from "../../lib/urlRelay";
import UrlToast from "./UrlToast";
import { trimSelection } from "./trimSelection";
import TerminalContextMenu from "./TerminalContextMenu";
@@ -37,7 +42,13 @@ export default function TerminalView({ sessionId, active }: Props) {
(s) => s.sessions.find((sess) => sess.id === sessionId)?.projectId
);
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
// One toast slot, two producers: the heuristic long-URL detector and the
// container's explicit "open this in the host browser" relay (OSC 7777).
// Sharing the slot keeps them from stacking on top of each other.
const [urlPrompt, setUrlPrompt] = useState<{ url: string; label: string } | null>(
null,
);
const relayLimiterRef = useRef(new RelayRateLimiter());
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const [isAutoFollow, setIsAutoFollow] = useState(true);
@@ -212,6 +223,31 @@ export default function TerminalView({ sessionId, active }: Props) {
return true;
});
// URL relay (OSC 7777) — a CLI inside the container asked for a URL to be
// opened in a browser. The container has none; `triple-c-open` (installed
// as xdg-open / $BROWSER / sensible-browser / ...) forwards the request
// here instead.
//
// The container is untrusted, so this never opens anything by itself:
// parseUrlRelayOsc enforces the http/https allowlist and the payload is
// rate-limited, then the user gets the same confirmation toast the
// long-URL detector uses. One click is a small price for not handing a
// sandboxed agent a "make the host's logged-in browser fetch this"
// primitive.
const relayDisposable = term.parser.registerOscHandler(URL_RELAY_OSC, (data) => {
const url = parseUrlRelayOsc(data);
if (!url) {
console.warn("URL relay: rejected request from container");
return true; // consumed either way — never let it reach the screen
}
if (!relayLimiterRef.current.allow(url)) {
console.warn("URL relay: rate-limited", url);
return true;
}
setUrlPrompt({ url, label: "Container asked to open a URL" });
return true;
});
// Handle user input -> backend
const inputDisposable = term.onData((data) => {
sendInput(sessionId, data);
@@ -295,7 +331,9 @@ export default function TerminalView({ sessionId, active }: Props) {
// Handle backend output -> terminal
let aborted = false;
const detector = new UrlDetector((url) => setDetectedUrl(url));
const detector = new UrlDetector((url) =>
setUrlPrompt({ url, label: "Long URL detected" }),
);
detectorRef.current = detector;
const SSO_MARKER = "###TRIPLE_C_SSO_REFRESH###";
@@ -369,6 +407,7 @@ export default function TerminalView({ sessionId, active }: Props) {
ssoTriggeredRef.current = false;
ssoBufferRef.current = "";
osc52Disposable.dispose();
relayDisposable.dispose();
inputDisposable.dispose();
scrollDisposable.dispose();
selectionDisposable.dispose();
@@ -425,10 +464,10 @@ export default function TerminalView({ sessionId, active }: Props) {
// Auto-dismiss toast after 30 seconds
useEffect(() => {
if (!detectedUrl) return;
const timer = setTimeout(() => setDetectedUrl(null), 30_000);
if (!urlPrompt) return;
const timer = setTimeout(() => setUrlPrompt(null), 30_000);
return () => clearTimeout(timer);
}, [detectedUrl]);
}, [urlPrompt]);
// Auto-dismiss image paste message after 3 seconds
useEffect(() => {
@@ -438,13 +477,13 @@ export default function TerminalView({ sessionId, active }: Props) {
}, [imagePasteMsg]);
const handleOpenUrl = useCallback(() => {
if (detectedUrl) {
openUrl(detectedUrl).catch((e) =>
if (urlPrompt) {
openUrl(urlPrompt.url).catch((e) =>
console.error("Failed to open URL:", e),
);
setDetectedUrl(null);
setUrlPrompt(null);
}
}, [detectedUrl]);
}, [urlPrompt]);
const handleScrollToBottom = useCallback(() => {
const term = termRef.current;
@@ -516,11 +555,12 @@ export default function TerminalView({ sessionId, active }: Props) {
ref={terminalContainerRef}
className={`w-full h-full relative ${active ? "" : "hidden"}`}
>
{detectedUrl && (
{urlPrompt && (
<UrlToast
url={detectedUrl}
url={urlPrompt.url}
label={urlPrompt.label}
onOpen={handleOpenUrl}
onDismiss={() => setDetectedUrl(null)}
onDismiss={() => setUrlPrompt(null)}
/>
)}
{imagePasteMsg && (