Skip to content
Merged
17 changes: 13 additions & 4 deletions surfsense_local/backend/modules/egress/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,23 @@


class EgressDeniedError(Exception):
def __init__(self, destination: str) -> None:
def __init__(self, destination: str, host: str | None = None) -> None:
self.destination = destination
self.host = host_of(destination)
self.host = host or host_of(destination)
super().__init__(f"sending data to {self.host} is off in Settings > Network")


def host_of(destination: str) -> str:
return HOSTS.get(destination) or destination.removeprefix(HOST_PREFIX)


def ollama_pull_host(model_name: str) -> str:
"""Ollama fetches an `hf.co/<repo>` pull straight from Hugging Face, not its own registry."""
if model_name.startswith("hf.co/"):
return "huggingface.co"
return HOSTS[OLLAMA_PULL]


def is_destination(value: str) -> bool:
return value in HOSTS or (
value.startswith(HOST_PREFIX) and len(value) > len(HOST_PREFIX)
Expand All @@ -49,12 +56,14 @@ def _is_loopback(host: str) -> bool:
return False


def require(session: Session, destination: str | None) -> None:
def require(
session: Session, destination: str | None, host: str | None = None
) -> None:
if destination is None:
return
row = session.get(EgressDestination, destination)
if row is None or not row.enabled:
raise EgressDeniedError(destination)
raise EgressDeniedError(destination, host)
row.last_call_at = datetime.now(UTC)


Expand Down
9 changes: 9 additions & 0 deletions surfsense_local/backend/modules/llm/recommendations/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from sqlalchemy.orm import Session

from api.dependencies import SessionDep, transact
from modules.egress import service as egress
from modules.llm.models import ModelRole, SelectedModel
from modules.llm.recommendations.catalog import (
InsufficientDiskError,
Expand Down Expand Up @@ -61,6 +62,7 @@ async def install_model(
payload: InstallRequest,
request: Request,
service: CatalogServiceDep,
session: SessionDep,
) -> StreamingResponse:
try:
runtime, _model, plan = await service.preflight(payload.catalog_id)
Expand All @@ -81,6 +83,13 @@ async def install_model(
except RuntimeError as error:
raise HTTPException(status.HTTP_409_CONFLICT, str(error)) from error

await transact(
session,
egress.require,
egress.OLLAMA_PULL,
egress.ollama_pull_host(plan.model_name),
)

lock = service.install_lock(runtime.name)
if lock.locked():
raise HTTPException(
Expand Down
7 changes: 6 additions & 1 deletion surfsense_local/backend/modules/llm/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,12 @@ async def pull_model(
service: CatalogServiceDep,
session: SessionDep,
) -> StreamingResponse:
await transact(session, egress.require, egress.OLLAMA_PULL)
await transact(
session,
egress.require,
egress.OLLAMA_PULL,
egress.ollama_pull_host(payload.name),
)
lock = service.install_lock(store.name)
if lock.locked():
raise HTTPException(
Expand Down
73 changes: 73 additions & 0 deletions surfsense_local/backend/tests/integration/llm/test_egress.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
"""Nothing leaves the machine until the user allows that destination."""

import json
from pathlib import Path

import pytest
from httpx import AsyncClient
from sqlalchemy import Engine

from modules.llm.models import ProviderConnection
from modules.llm.recommendations.dependencies import get_catalog_service
from shared.config import get_llm_settings
from shared.db import create_session_factory

pytestmark = pytest.mark.integration
Expand Down Expand Up @@ -95,3 +100,71 @@ async def test_unknown_destination_is_rejected(client: AsyncClient) -> None:
"""Only destinations the app contacts can be toggled."""
reply = await client.put("/egress/keygen", json={"enabled": True})
assert reply.status_code == 422


def _configure_llmfit(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
system = {"system": {"available_ram_gb": 16, "total_ram_gb": 16}}
fit = {
"models": [
{
"name": "Qwen/Qwen3-8B",
"provider": "Qwen",
"parameter_count": "8B",
"use_case": "chat",
"fit_level": "good",
"score": 85,
"runtime": "llamacpp",
"run_mode": "gpu",
"best_quant": "Q4_K_M",
"memory_required_gb": 6,
"memory_available_gb": 16,
"disk_size_gb": 5.2,
"effective_context_length": 8192,
"capability_ids": ["tool_use"],
"ollama_name": "qwen3:8b",
"gguf_sources": [],
}
]
}
executable = tmp_path / "llmfit"
executable.write_text(
"#!/usr/bin/env python3\n"
"import sys\n"
f"system = {json.dumps(system)!r}\n"
f"fit = {json.dumps(fit)!r}\n"
'print("llmfit 1.1.11" if "--version" in sys.argv '
'else system if "system" in sys.argv else fit)\n'
)
executable.chmod(0o755)
monkeypatch.setattr(get_llm_settings(), "llmfit_path", executable)
get_catalog_service.cache_clear()


async def test_catalog_install_is_refused_until_allowed(
client: AsyncClient,
ollama_server: str,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The catalog's own install route is gated the same as a manual pull."""
_configure_llmfit(tmp_path, monkeypatch)
catalog = (await client.get("/llm/catalog?refresh=true")).json()
catalog_id = next(
row["catalog_id"] for row in catalog["curated"] if row["fit"] != "unknown"
)

refused = await client.post(
"/llm/install", json={"catalog_id": catalog_id, "select": False}
)
assert refused.status_code == 403
assert refused.json()["detail"]["destination"] == "ollama_pull"
assert (await _destinations(client))["ollama_pull"]["last_call_at"] is None

await client.put("/egress/ollama_pull", json={"enabled": True})
async with client.stream(
"POST", "/llm/install", json={"catalog_id": catalog_id, "select": False}
) as reply:
assert reply.status_code == 200
await reply.aread()
assert (await _destinations(client))["ollama_pull"]["last_call_at"] is not None
get_catalog_service.cache_clear()
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,7 @@ async def test_ranked_catalog_installs_and_selects_in_one_stream(
assert [row["canonical_id"] for row in scanned_curated] == ["Qwen/Qwen3-8B"]
catalog_id = scanned_curated[0]["catalog_id"]

await client.put("/egress/ollama_pull", json={"enabled": True})
events = []
async with client.stream(
"POST",
Expand Down
15 changes: 15 additions & 0 deletions surfsense_local/backend/tests/unit/egress/test_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import pytest

from modules.egress import service as egress

pytestmark = pytest.mark.unit


def test_a_library_pull_reports_the_ollama_registry() -> None:
"""Ollama's own model library is served from its registry."""
assert egress.ollama_pull_host("qwen3:8b") == "registry.ollama.ai"


def test_an_hf_co_fallback_pull_reports_hugging_face() -> None:
"""Ollama fetches `hf.co/<repo>` pulls straight from Hugging Face, not its own registry."""
assert egress.ollama_pull_host("hf.co/Qwen/Qwen3-8B-GGUF:Q4_K_M") == "huggingface.co"
26 changes: 26 additions & 0 deletions surfsense_local/frontend/src/components/ui/select.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { ComponentProps } from "react"

import { ChevronDownIcon } from "@/components/ui/icons"
import { cn } from "@/lib/utils"

// A plain `<select>`, not Radix's or Base UI's Select: both render a custom
// popup, and the OS-native picker only comes from the real element.
function Select({ className, children, ...props }: ComponentProps<"select">) {
return (
<div className="relative">
<select
data-slot="select"
className={cn(
"h-8 w-full min-w-0 appearance-none rounded-lg border border-input bg-transparent px-2.5 pr-8 text-sm transition-colors outline-none focus-visible:border-ring/70 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
>
{children}
</select>
<ChevronDownIcon className="pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
</div>
)
}

export { Select }
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,6 @@ export function ChatsDialog({
<ScrollShadow
className="h-[32rem] min-w-0"
viewportClassName="overflow-x-hidden"
from="from-background"
>
<div className="flex w-full max-w-full min-w-0 flex-col pr-1">
{isLoading ? <SkeletonSlabs /> : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,9 @@ function WorkspaceDashboard({
<StudioPanel
workspaceId={workspace.id}
documents={sources.documents}
selectedDocumentIds={sources.includedDocumentIds}
onSelectionChange={sources.setDocumentIncluded}
onToggleAll={sources.toggleAllIncluded}
formats={studio.formats}
isCreating={studio.isCreating}
error={studio.error}
Expand Down
14 changes: 10 additions & 4 deletions surfsense_local/frontend/src/features/egress/network-settings.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useId } from "react"

import { RelativeTime } from "@/components/relative-time"
import { Checkbox } from "@/components/ui/checkbox"
import { DotIcon } from "@/components/ui/icons"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
import { SettingsSection } from "@/features/settings/settings-section"
import { useUpdatePrefs } from "@/features/updates/use-update-state"
Expand All @@ -27,6 +29,7 @@ function DestinationRow({
lastCallAt: string | null
onChange: (enabled: boolean) => void
}) {
const checkboxId = useId()
return (
<li className="flex items-center justify-between gap-6 py-3">
<div className="min-w-0">
Expand All @@ -41,13 +44,16 @@ function DestinationRow({
{lastCallAt ? <RelativeTime date={new Date(lastCallAt)} /> : "never"}
</p>
</div>
<label className="flex shrink-0 items-center gap-2 text-sm">
<div className="flex shrink-0 items-center gap-2 text-sm">
<Checkbox
id={checkboxId}
checked={enabled}
onCheckedChange={(checked) => onChange(checked === true)}
/>
<span className="sr-only">Allow {label}</span>
</label>
<Label htmlFor={checkboxId} className="sr-only">
Allow {label}
</Label>
</div>
</li>
)
}
Expand Down Expand Up @@ -88,7 +94,7 @@ export function NetworkSettings() {
return (
<SettingsSection
title="Network"
description="Every place SurfSense can send data to. Off means the call is refused, and nothing here is on until you allow it."
description="Everywhere SurfSense can send data. Off blocks the call outright. Nothing is enabled by default."
>
{destinations.isLoading ? (
<Skeleton className="h-24 w-full" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ export function LicenseSettings() {
return (
<SettingsSection
title="License"
description="Paid plugins need a license file from your SurfSense account. Its verified on this device."
description="Paid plugins need a license file tied to your email. It's verified on this device."
>
{loading ? (
<div className="flex flex-col gap-1">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { useModelSelection } from "@/features/model-selection/use-model-selectio
import { SettingsSection } from "./settings-section"

const DESCRIPTION =
"Download local models or choose the model SurfSense uses for new messages."
"Add models locally or via an OpenAI compatible endpoint."

export function ModelsSettings({
onModelUnavailable,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@ export function SourcesPanel({
<div className="mb-2 flex min-h-7 shrink-0 items-center justify-between gap-2">
<h3
id="all-sources"
className="px-1 text-xs font-medium text-muted-foreground"
className="px-1 text-sm font-medium text-muted-foreground"
>
Sources
</h3>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,7 @@ export function ArtifactList({
<div className="mb-2 flex min-h-7 shrink-0 items-center justify-between gap-2">
<h3
id="all-artifacts"
className="px-1 text-xs font-medium text-muted-foreground"
className="px-1 text-sm font-medium text-muted-foreground"
>
Artifacts
</h3>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useId, type ComponentProps } from "react"
import { useId } from "react"

import { Button } from "@/components/ui/button"
import { PlusIcon, Trash2Icon } from "@/components/ui/icons"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import { Select } from "@/components/ui/select"

import {
MAX_SPEAKERS,
Expand Down Expand Up @@ -197,7 +197,7 @@ export function PodcastBriefForm({
{brief.speakers.length > 1 ? (
<Button
type="button"
variant="ghost"
variant="destructive"
size="icon-xs"
aria-label={`Remove speaker ${index + 1}`}
onClick={() => removeSpeaker(index)}
Expand Down Expand Up @@ -235,15 +235,3 @@ function Field({
</div>
)
}

function Select({ className, ...props }: ComponentProps<"select">) {
return (
<select
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2 text-sm outline-none focus-visible:border-ring/70 disabled:opacity-50 dark:bg-input/30",
className
)}
{...props}
/>
)
}
Loading
Loading