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>
179 lines
6.1 KiB
TypeScript
179 lines
6.1 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||
import BrowserTab from "./BrowserTab";
|
||
import type { BrowserViewStatus, Project } from "../../../lib/types";
|
||
|
||
const getBrowserViewStatus = vi.fn<() => Promise<BrowserViewStatus>>();
|
||
const setBrowserViewEnabled = vi.fn<() => Promise<BrowserViewStatus>>();
|
||
const pushToast = vi.fn();
|
||
|
||
vi.mock("../../../lib/tauri-commands", () => ({
|
||
getBrowserViewStatus: () => getBrowserViewStatus(),
|
||
setBrowserViewEnabled: () => setBrowserViewEnabled(),
|
||
}));
|
||
|
||
vi.mock("@tauri-apps/api/event", () => ({
|
||
listen: vi.fn(async () => () => {}),
|
||
}));
|
||
|
||
vi.mock("../../../store/appState", () => ({
|
||
useAppState: (selector: (s: unknown) => unknown) => selector({ pushToast }),
|
||
}));
|
||
|
||
const OFF: BrowserViewStatus = {
|
||
enabled: false,
|
||
state: "off",
|
||
url: null,
|
||
host_port: null,
|
||
container_port: null,
|
||
started_at: null,
|
||
detection: null,
|
||
message: null,
|
||
};
|
||
|
||
const project: Project = {
|
||
id: "p1",
|
||
name: "api-server",
|
||
paths: [{ host_path: "/home/user/api", mount_name: "api" }],
|
||
container_id: "c1",
|
||
status: "running",
|
||
backend: "anthropic",
|
||
bedrock_config: null,
|
||
ollama_config: null,
|
||
openai_compatible_config: null,
|
||
allow_docker_access: false,
|
||
sandbox_mode_enabled: true,
|
||
mission_control_enabled: false,
|
||
auth_bridge_enabled: false,
|
||
use_shared_auth_token: true,
|
||
full_permissions: false,
|
||
permission_mode: "bypass",
|
||
ssh_key_path: null,
|
||
git_token: null,
|
||
git_user_name: null,
|
||
git_user_email: null,
|
||
custom_env_vars: [],
|
||
port_mappings: [],
|
||
claude_instructions: null,
|
||
claude_code_settings: null,
|
||
renamed_session_names: {},
|
||
created_at: "2026-01-01T00:00:00Z",
|
||
updated_at: "2026-01-01T00:00:00Z",
|
||
} as unknown as Project;
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
getBrowserViewStatus.mockResolvedValue(OFF);
|
||
});
|
||
|
||
describe("BrowserTab", () => {
|
||
it("does not offer to start anything while the container is stopped", async () => {
|
||
render(<BrowserTab project={{ ...project, status: "stopped" }} active />);
|
||
expect(await screen.findByText(/container isn’t running/i)).toBeInTheDocument();
|
||
expect(screen.queryByRole("button", { name: /start browser view/i })).toBeNull();
|
||
expect(getBrowserViewStatus).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("starts off, and never starts a view without being asked", async () => {
|
||
render(<BrowserTab project={project} active />);
|
||
await waitFor(() => expect(getBrowserViewStatus).toHaveBeenCalled());
|
||
expect(screen.getByText("Off")).toBeInTheDocument();
|
||
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
|
||
expect(setBrowserViewEnabled).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("shows the live pane, pointed at loopback with a token, once started", async () => {
|
||
setBrowserViewEnabled.mockResolvedValue({
|
||
...OFF,
|
||
enabled: true,
|
||
state: "running",
|
||
url: "http://127.0.0.1:47820/index.html?ws=abc&token=SEKRIT",
|
||
host_port: 47820,
|
||
container_port: 39321,
|
||
started_at: "2026-08-09T10:00:00Z",
|
||
});
|
||
|
||
render(<BrowserTab project={project} active />);
|
||
await waitFor(() => expect(getBrowserViewStatus).toHaveBeenCalled());
|
||
await act(async () => {
|
||
fireEvent.click(screen.getByRole("button", { name: /start browser view/i }));
|
||
});
|
||
|
||
const frame = await screen.findByTitle("Playwright browser view for api-server");
|
||
expect(frame).toHaveAttribute(
|
||
"src",
|
||
"http://127.0.0.1:47820/index.html?ws=abc&token=SEKRIT",
|
||
);
|
||
expect(screen.getByText("Live")).toBeInTheDocument();
|
||
expect(screen.getByText(/127\.0\.0\.1:47820 → container :39321/)).toBeInTheDocument();
|
||
expect(screen.getByRole("button", { name: "Stop" })).toBeInTheDocument();
|
||
});
|
||
|
||
it("explains precisely what is missing instead of spinning", async () => {
|
||
getBrowserViewStatus.mockResolvedValue({
|
||
...OFF,
|
||
enabled: true,
|
||
state: "unavailable",
|
||
message:
|
||
"Playwright isn't installed in this container. Install it with `npm i -D playwright`.",
|
||
detection: {
|
||
node_version: "22.11.0",
|
||
playwright_version: null,
|
||
playwright_path: null,
|
||
has_bind: false,
|
||
cli_version: null,
|
||
cli_entry: null,
|
||
searched: ["/workspace", "/usr/lib/node_modules"],
|
||
},
|
||
});
|
||
|
||
render(<BrowserTab project={project} active />);
|
||
|
||
expect(await screen.findByText(/npm i -D playwright/)).toBeInTheDocument();
|
||
expect(screen.getByText("Unavailable")).toBeInTheDocument();
|
||
// The probe's findings are shown, so the user can see why.
|
||
expect(screen.getByText("22.11.0")).toBeInTheDocument();
|
||
expect(screen.getByText("not in this build")).toBeInTheDocument();
|
||
expect(screen.getByText(/usr\/lib\/node_modules/)).toBeInTheDocument();
|
||
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
|
||
});
|
||
|
||
it("surfaces a start failure rather than leaving the pane blank", async () => {
|
||
setBrowserViewEnabled.mockRejectedValue("container went away");
|
||
|
||
render(<BrowserTab project={project} active />);
|
||
await waitFor(() => expect(getBrowserViewStatus).toHaveBeenCalled());
|
||
await act(async () => {
|
||
fireEvent.click(screen.getByRole("button", { name: /start browser view/i }));
|
||
});
|
||
|
||
expect(await screen.findByText(/didn’t start/i)).toBeInTheDocument();
|
||
expect(screen.getByText(/container went away/)).toBeInTheDocument();
|
||
expect(pushToast).toHaveBeenCalledWith(
|
||
expect.objectContaining({ kind: "error" }),
|
||
);
|
||
});
|
||
|
||
it("stops the view when asked", async () => {
|
||
getBrowserViewStatus.mockResolvedValue({
|
||
...OFF,
|
||
enabled: true,
|
||
state: "running",
|
||
url: "http://127.0.0.1:47821/?token=T",
|
||
host_port: 47821,
|
||
container_port: 39321,
|
||
});
|
||
setBrowserViewEnabled.mockResolvedValue(OFF);
|
||
|
||
render(<BrowserTab project={project} active />);
|
||
const stop = await screen.findByRole("button", { name: "Stop" });
|
||
await act(async () => {
|
||
fireEvent.click(stop);
|
||
});
|
||
|
||
await waitFor(() => expect(setBrowserViewEnabled).toHaveBeenCalled());
|
||
expect(await screen.findByText("Off")).toBeInTheDocument();
|
||
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
|
||
});
|
||
});
|