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:
@@ -226,6 +226,51 @@
|
||||
.scroll-bottom-btn:hover { background: var(--accent-hover); }
|
||||
.scroll-bottom-btn.visible { display: flex; }
|
||||
|
||||
/* ── URL relay banner ───────────────────── */
|
||||
.relay-banner {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
max-width: min(94%, 620px);
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.45);
|
||||
z-index: 30;
|
||||
}
|
||||
.relay-banner.visible { display: flex; }
|
||||
.relay-banner-text { flex: 1; min-width: 0; }
|
||||
.relay-banner-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.relay-banner-url {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Menlo', monospace;
|
||||
color: var(--accent);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.relay-banner-dismiss {
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.relay-banner-dismiss:hover { color: var(--text-primary); }
|
||||
|
||||
/* ── Empty State ─────────────────────────── */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
@@ -272,6 +317,15 @@
|
||||
<div class="hint">Use the buttons above to start a Claude or Bash session</div>
|
||||
</div>
|
||||
<button class="scroll-bottom-btn" id="scrollBottomBtn" title="Scroll to bottom">↓</button>
|
||||
<!-- URL relay: a CLI in the container asked for a browser. Tap-to-open only,
|
||||
never automatic — see the OSC 7777 handler below. -->
|
||||
<div class="relay-banner" id="relayBanner">
|
||||
<div class="relay-banner-text">
|
||||
<div class="relay-banner-label">Container asked to open a URL — tap to open here</div>
|
||||
<a class="relay-banner-url" id="relayBannerLink" target="_blank" rel="noopener noreferrer"></a>
|
||||
</div>
|
||||
<button class="relay-banner-dismiss" id="relayBannerDismiss" aria-label="Dismiss">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input Bar for mobile/tablet -->
|
||||
@@ -309,6 +363,96 @@
|
||||
const btnTab = document.getElementById('btnTab');
|
||||
const btnCtrlC = document.getElementById('btnCtrlC');
|
||||
const scrollBottomBtn = document.getElementById('scrollBottomBtn');
|
||||
const relayBanner = document.getElementById('relayBanner');
|
||||
const relayBannerLink = document.getElementById('relayBannerLink');
|
||||
const relayBannerDismiss = document.getElementById('relayBannerDismiss');
|
||||
|
||||
// ── URL relay (OSC 7777) ───────────────────
|
||||
// `container/triple-c-open` — installed in the container as xdg-open,
|
||||
// $BROWSER, sensible-browser, ... — emits ESC]7777;open;<base64(url)>BEL
|
||||
// when a CLI wants a browser. The desktop app turns that into a host-browser
|
||||
// open; here the only browser available is the *remote viewer's*.
|
||||
//
|
||||
// That is a different trust situation, so this deliberately does NOT mirror
|
||||
// the desktop behaviour: nothing opens by itself. The web terminal may be
|
||||
// reached from a phone on the LAN or through a tunnel, and the viewer's
|
||||
// browser carries their own logged-in sessions and can reach their own
|
||||
// network. We surface the request as a tap-to-open link and let the human
|
||||
// decide. (A popup would be blocked without a user gesture anyway.)
|
||||
// The same http/https allowlist as the desktop side applies — this file is
|
||||
// standalone (embedded via include_str!) so it cannot import lib/urlRelay.ts;
|
||||
// the logic is kept deliberately short and identical in behaviour.
|
||||
const RELAY_OSC = 7777;
|
||||
const RELAY_MAX_URL = 8192;
|
||||
let relayTimes = [];
|
||||
let relayLastUrl = null;
|
||||
let relayLastAt = 0;
|
||||
let relayHideTimer = null;
|
||||
|
||||
function sanitizeRelayUrl(raw) {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const s = raw.trim();
|
||||
if (!s || s.length > RELAY_MAX_URL) return null;
|
||||
// Control characters and whitespace first: new URL() strips tabs/newlines,
|
||||
// so "java\nscript:" would otherwise slip through as javascript:.
|
||||
if (/[\s\u0000-\u0020\u007f]/.test(s)) return null;
|
||||
let u;
|
||||
try { u = new URL(s); } catch (e) { return null; }
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
|
||||
if (!u.hostname) return null;
|
||||
if (u.username || u.password) return null; // origin spoofing
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
function parseRelayOsc(data) {
|
||||
if (typeof data !== 'string') return null;
|
||||
const sep = data.indexOf(';');
|
||||
if (sep === -1) return null;
|
||||
if (data.slice(0, sep) !== 'open') return null;
|
||||
const body = data.slice(sep + 1);
|
||||
if (!body || body.length > RELAY_MAX_URL * 2) return null;
|
||||
if (!/^[A-Za-z0-9+/]+=*$/.test(body)) return null;
|
||||
let text;
|
||||
try {
|
||||
const bin = atob(body);
|
||||
const bytes = Uint8Array.from(bin, c => c.charCodeAt(0));
|
||||
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||
} catch (e) { return null; }
|
||||
return sanitizeRelayUrl(text);
|
||||
}
|
||||
|
||||
// Cap the prompt rate so a runaway loop in the container can't bury the UI.
|
||||
function relayAllowed(url) {
|
||||
const now = Date.now();
|
||||
if (url === relayLastUrl && now - relayLastAt < 5000) {
|
||||
relayLastAt = now;
|
||||
return false;
|
||||
}
|
||||
relayTimes = relayTimes.filter(t => now - t < 10000);
|
||||
if (relayTimes.length >= 5) return false;
|
||||
relayTimes.push(now);
|
||||
relayLastUrl = url;
|
||||
relayLastAt = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
function hideRelayBanner() {
|
||||
relayBanner.classList.remove('visible');
|
||||
relayBannerLink.removeAttribute('href');
|
||||
relayBannerLink.textContent = '';
|
||||
clearTimeout(relayHideTimer);
|
||||
}
|
||||
|
||||
function showRelayBanner(url) {
|
||||
relayBannerLink.href = url;
|
||||
relayBannerLink.textContent = url;
|
||||
relayBanner.classList.add('visible');
|
||||
clearTimeout(relayHideTimer);
|
||||
relayHideTimer = setTimeout(hideRelayBanner, 60000);
|
||||
}
|
||||
|
||||
relayBannerDismiss.addEventListener('click', hideRelayBanner);
|
||||
relayBannerLink.addEventListener('click', () => hideRelayBanner());
|
||||
|
||||
// ── WebSocket ──────────────────────────────
|
||||
function connect() {
|
||||
@@ -448,6 +592,15 @@
|
||||
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
|
||||
term.loadAddon(webLinksAddon);
|
||||
|
||||
// URL relay from the container (see the OSC 7777 notes above). Always
|
||||
// returns true so the sequence is consumed and never painted as garbage,
|
||||
// whether or not we act on it.
|
||||
term.parser.registerOscHandler(RELAY_OSC, data => {
|
||||
const url = parseRelayOsc(data);
|
||||
if (url && relayAllowed(url)) showRelayBanner(url);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Create container div
|
||||
const container = document.createElement('div');
|
||||
container.className = 'terminal-container';
|
||||
|
||||
Reference in New Issue
Block a user