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:
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user