diff --git a/src/backend/ai/egress.ts b/src/backend/ai/egress.ts index 700b11ce..c2be8aeb 100644 --- a/src/backend/ai/egress.ts +++ b/src/backend/ai/egress.ts @@ -51,8 +51,7 @@ function normalizeHost(hostname: string): string { /** * True when the URL names a destination the SSRF guard would refuse. A bare * hostname that is not an IP literal (e.g. "ollama.internal") is treated as - * private only if it is "localhost" -- anything else resolves through DNS and - * is caught at connect time by the guard instead. + * private only if it is "localhost" -- anything else needs DNS resolution. */ export function isPrivateDestination(rawUrl: string): boolean { let url: URL; @@ -98,12 +97,18 @@ export function evaluateEgress( const host = normalizeHost(url.hostname); const isPrivate = isPrivateDestination(rawUrl); + const normalized = allowlist.map((entry) => entry.trim().toLowerCase()); + + // An explicitly allowlisted hostname may resolve to a private address. It + // must use the private fetch path; sending it through safeOutboundFetch + // would reject it after DNS resolution and make hostname allowlist entries + // ineffective. Only administrators can write this list. + if (normalized.includes(host)) { + return { allowed: true, isPrivate: true }; + } if (!isPrivate) return { allowed: true, isPrivate: false }; - const normalized = allowlist.map((entry) => entry.trim().toLowerCase()); - if (normalized.includes(host)) return { allowed: true, isPrivate: true }; - return { allowed: false, isPrivate: true, diff --git a/src/backend/tests/ai/egress.test.ts b/src/backend/tests/ai/egress.test.ts index 577f94e0..b0dafc35 100644 --- a/src/backend/tests/ai/egress.test.ts +++ b/src/backend/tests/ai/egress.test.ts @@ -61,6 +61,16 @@ describe("evaluateEgress", () => { expect(decision.isPrivate).toBe(true); }); + it.each(["llm.internal", "host.docker.internal"])( + "routes allowlisted private DNS name %s through the private path", + (host) => { + expect(evaluateEgress(`http://${host}:8000/v1`, [host])).toEqual({ + allowed: true, + isPrivate: true, + }); + }, + ); + it("matches the allowlist case-insensitively", () => { expect( evaluateEgress("http://LOCALHOST:11434", ["localhost"]).allowed, diff --git a/src/ui/api/ai-api.ts b/src/ui/api/ai-api.ts index 094aca0a..b634e439 100644 --- a/src/ui/api/ai-api.ts +++ b/src/ui/api/ai-api.ts @@ -114,7 +114,11 @@ export async function probeAiModels(input: { baseUrl?: string | null; apiKey?: string | null; providerId?: number | null; -}): Promise<{ models: string[]; source: "live" | "fallback" | "none" }> { +}): Promise<{ + models: string[]; + source: "live" | "fallback" | "none"; + warning?: string; +}> { try { return (await authApi.post("/ai/probe-models", input)).data; } catch (error) { diff --git a/src/ui/features/ai/AiProviderSettings.tsx b/src/ui/features/ai/AiProviderSettings.tsx index 040a5b5d..7aad6870 100644 --- a/src/ui/features/ai/AiProviderSettings.tsx +++ b/src/ui/features/ai/AiProviderSettings.tsx @@ -85,21 +85,24 @@ function AiProviderEditForm({ const [models, setModels] = useState([]); const [customModel, setCustomModel] = useState(false); const [detecting, setDetecting] = useState(false); + const [detectWarning, setDetectWarning] = useState(null); const [saving, setSaving] = useState(false); const detectModels = useCallback(async () => { setDetecting(true); + setDetectWarning(null); try { const detected = await getAiProviderModels(provider.id); setModels(detected); setCustomModel(!!defaultModel && !detected.includes(defaultModel)); - } catch { + } catch (error) { setModels([]); setCustomModel(true); + setDetectWarning(getErrorMessage(error, t("ai.modelDetectFailed"))); } finally { setDetecting(false); } - }, [provider.id, defaultModel]); + }, [provider.id, defaultModel, t]); useEffect(() => { void detectModels(); @@ -203,6 +206,11 @@ function AiProviderEditForm({ placeholder={t("ai.defaultModelPlaceholder")} /> )} + {detectWarning && ( +

+ {detectWarning} +

+ )}
@@ -269,8 +277,8 @@ export function AiProviderSettings({ apiKey: apiKey.trim() || null, }); setModels(result.models); - if (result.source === "fallback") { - setDetectWarning(t("ai.modelDetectFailed")); + if (result.source !== "live") { + setDetectWarning(result.warning || t("ai.modelDetectFailed")); } // Pick the first suggestion so the field is never left empty. setDefaultModel((current) => current || result.models[0] || ""); diff --git a/src/ui/tests/features/ai/AiProviderSettings.test.tsx b/src/ui/tests/features/ai/AiProviderSettings.test.tsx index 9a1f9a39..c49a3e49 100644 --- a/src/ui/tests/features/ai/AiProviderSettings.test.tsx +++ b/src/ui/tests/features/ai/AiProviderSettings.test.tsx @@ -82,4 +82,17 @@ describe("AiProviderSettings", () => { screen.getByRole("combobox", { name: "ai.defaultModel" }), ).toBeTruthy(); }); + + it("shows the provider error when refreshing models fails", async () => { + api.getAiProviderModels.mockRejectedValue( + new Error("Add llm.internal to the AI endpoint allowlist"), + ); + render( {}} />); + + fireEvent.click(screen.getByRole("button", { name: "ai.editProvider" })); + + expect( + await screen.findByText("Add llm.internal to the AI endpoint allowlist"), + ).toBeTruthy(); + }); });