mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
fix(security): keep ALLOW_LOCAL_AI_PRIVATE_HOSTS=true for the operator's own AI, deprecated
Making `=true` grant nothing would stop a single-user self-host's local model on a patch update. It is deprecated for one release cycle instead, in a narrowed and safe form: it still grants a private host, but only to the operator's own configurations (the instance-wide admin provider and AI settings saved on an admin account), and only through the pinned operator-approved dispatcher, so redirects stay forbidden and metadata, link-local and unspecified addresses stay refused. A non-admin account's own base URL needs an exact origin in AI_PRIVATE_ORIGINS. Provider resolution passes the owning account's role to the client (`operatorTrusted`); the save route and the connection test apply the same rule to the account being configured. The boot summary now says the setting is deprecated and names the replacement, and the admin AI settings page shows the exact AI_PRIVATE_ORIGINS line for the private origins saved on the instance (every account's Local and gateway URL and the admin base URL, scheme, host and port only), so the operator can copy it and drop the old setting.
This commit is contained in:
1 parent
76398c80c2
commit
c86f23c2c0
28 files changed
+863
-55
No files matched your search
+2
-1
@@ -116,7 +116,8 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
|
||||
# addresses stay refused even when listed. See docs/integrations/ai-providers.md.
|
||||
# AI_PRIVATE_ORIGINS=""
|
||||
# Older form, kept for existing installs: an exact host list still works
|
||||
# (e.g. "ollama.lan,10.0.0.5"). "true" no longer opens every private host.
|
||||
# (e.g. "ollama.lan,10.0.0.5"). "true" is deprecated: it now covers only the
|
||||
# admin AI key and admin accounts' own AI settings, and will be removed.
|
||||
# ALLOW_LOCAL_AI_PRIVATE_HOSTS=""
|
||||
|
||||
# Daily-briefing output-token ceiling (optional). Default 2500, clamped to
|
||||
|
||||
@@ -274,7 +274,8 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# addresses stay refused even when listed. See docs/integrations/ai-providers.md.
|
||||
# AI_PRIVATE_ORIGINS=""
|
||||
# Older form, kept for existing installs: an exact host list still works
|
||||
# (e.g. "ollama.lan,10.0.0.5"). "true" no longer opens every private host.
|
||||
# (e.g. "ollama.lan,10.0.0.5"). "true" is deprecated: it now covers only the
|
||||
# admin AI key and admin accounts' own AI settings, and will be removed.
|
||||
# ALLOW_LOCAL_AI_PRIVATE_HOSTS=""
|
||||
|
||||
# Daily-briefing output-token ceiling (optional). Default 2500, clamped to
|
||||
|
||||
+3
-2
@@ -261,8 +261,9 @@ x-healthlog-env: &healthlog_env
|
||||
# docs/integrations/ai-providers.md.
|
||||
AI_PRIVATE_ORIGINS: "${AI_PRIVATE_ORIGINS:-}"
|
||||
# Older form of the above, kept for existing installs: an exact host list
|
||||
# (e.g. "ollama.lan,10.0.0.5") still works. Its "true" form no longer
|
||||
# opens every private host; move to AI_PRIVATE_ORIGINS.
|
||||
# (e.g. "ollama.lan,10.0.0.5") still works. "true" is deprecated: it now
|
||||
# covers only the admin AI key and admin accounts' own AI settings, and
|
||||
# will be removed in a later release; move to AI_PRIVATE_ORIGINS.
|
||||
ALLOW_LOCAL_AI_PRIVATE_HOSTS: "${ALLOW_LOCAL_AI_PRIVATE_HOSTS:-}"
|
||||
# Extra hosts the admin-configured AI base URL may point at, on top of the
|
||||
# built-in public providers. Comma-separated bare hostnames. Set it when
|
||||
|
||||
@@ -158,10 +158,17 @@ unreachable even when listed or when a listed name resolves to one.
|
||||
|
||||
`ALLOW_LOCAL_AI_PRIVATE_HOSTS`, the older setting, still works as a
|
||||
host list (`ollama.lan,10.0.0.5`, any port), with the same pinning.
|
||||
|
||||
Its `true` form used to open every private host on the network to
|
||||
every user and no longer grants anything since v1.39.3; the boot
|
||||
summary names it when it is still set. Replace it with the one origin
|
||||
your endpoint listens on.
|
||||
every user. It is deprecated since v1.39.3 and will be removed in a
|
||||
later release. Until then it still covers the operator's own AI
|
||||
configurations, the server key in the admin panel and AI settings
|
||||
saved on an admin account, over the same pinned connection, so the
|
||||
metadata address and link-local addresses stay unreachable. Any other
|
||||
account's base URL needs its origin in `AI_PRIVATE_ORIGINS`. The boot
|
||||
summary names the setting while it is set, and the admin AI settings
|
||||
page shows the exact `AI_PRIVATE_ORIGINS` line for the endpoints saved
|
||||
on the instance.
|
||||
|
||||
**Model sizing.** Roughly: 7-8B for 8 GB GPU / Apple Silicon base
|
||||
(briefings fine, deeper Coach reasoning gets terse), 14-24B for
|
||||
@@ -454,9 +461,9 @@ upstream error.
|
||||
- **Local endpoint rejected as "internal/private host".** Add the
|
||||
endpoint's exact origin to `AI_PRIVATE_ORIGINS` in the container
|
||||
environment (e.g. `http://ollama.lan:11434`; comma-separate several)
|
||||
and restart. If it stopped working after an update and you had
|
||||
`ALLOW_LOCAL_AI_PRIVATE_HOSTS=true`, that form no longer grants
|
||||
anything; list the origin instead.
|
||||
and restart. With `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` an admin
|
||||
account's endpoint still works, but no other account's does; the
|
||||
admin AI settings page lists the origins to add.
|
||||
- **Codex disconnects after a few weeks of inactivity.** ChatGPT
|
||||
refresh tokens lapse after extended idle periods. Reconnect via
|
||||
the **Connect ChatGPT** button — HealthLog re-runs the device-code
|
||||
|
||||
+5
-1
@@ -6746,7 +6746,11 @@
|
||||
"removeBody": "Konten ohne eigenen Schlüssel verlieren die KI-Funktionen sofort. Der Schlüssel wird nach dem Speichern nirgends mehr angezeigt — zum Wiederherstellen brauchst du das Original.",
|
||||
"removeConfirm": "Schlüssel entfernen",
|
||||
"saved": "KI-Server-Schlüssel gespeichert",
|
||||
"error": "Speichern des KI-Server-Schlüssels fehlgeschlagen"
|
||||
"error": "Speichern des KI-Server-Schlüssels fehlgeschlagen",
|
||||
"legacyGrantTitle": "ALLOW_LOCAL_AI_PRIVATE_HOSTS=true ist veraltet",
|
||||
"legacyGrantBody": "Die Einstellung erlaubt dem Server-Schlüssel oben und KI-Einstellungen, die in einem Admin-Konto gespeichert sind, weiterhin Zugriff auf dein Netzwerk und entfällt in einer späteren Version. Andere Konten brauchen ihren KI-Endpunkt schon jetzt in AI_PRIVATE_ORIGINS. Setze AI_PRIVATE_ORIGINS in der Server-Umgebung, starte neu und entferne dann ALLOW_LOCAL_AI_PRIVATE_HOSTS.",
|
||||
"legacyGrantOrigins": "Diese auf dieser Instanz gespeicherten KI-Adressen zeigen in dein Netzwerk:",
|
||||
"legacyGrantNoOrigins": "Keine auf dieser Instanz gespeicherte KI-Adresse zeigt in dein Netzwerk, du kannst ALLOW_LOCAL_AI_PRIVATE_HOSTS also jetzt entfernen."
|
||||
},
|
||||
"providerHealth": {
|
||||
"title": "Zustand der KI-Provider",
|
||||
|
||||
+5
-1
@@ -6746,7 +6746,11 @@
|
||||
"removeBody": "Accounts without their own key lose AI features straight away. The key is not shown anywhere after it is stored, so you will need the original to put it back.",
|
||||
"removeConfirm": "Remove key",
|
||||
"saved": "AI server key settings saved",
|
||||
"error": "Saving the AI server key failed"
|
||||
"error": "Saving the AI server key failed",
|
||||
"legacyGrantTitle": "ALLOW_LOCAL_AI_PRIVATE_HOSTS=true is deprecated",
|
||||
"legacyGrantBody": "It still lets the server key above and AI settings saved on an admin account reach your network, and will be removed in a later release. Other accounts need their AI endpoint listed in AI_PRIVATE_ORIGINS already. Set AI_PRIVATE_ORIGINS in the server environment, restart, then remove ALLOW_LOCAL_AI_PRIVATE_HOSTS.",
|
||||
"legacyGrantOrigins": "The AI base URLs saved on this instance that point at your network:",
|
||||
"legacyGrantNoOrigins": "No AI base URL saved on this instance points at your network, so you can remove ALLOW_LOCAL_AI_PRIVATE_HOSTS now."
|
||||
},
|
||||
"providerHealth": {
|
||||
"title": "AI provider health",
|
||||
|
||||
+5
-1
@@ -6746,7 +6746,11 @@
|
||||
"removeBody": "Las cuentas sin clave propia pierden las funciones de IA de inmediato. La clave no se muestra en ningún sitio una vez guardada, así que necesitarás el original para volver a ponerla.",
|
||||
"removeConfirm": "Eliminar clave",
|
||||
"saved": "Ajustes de la clave de IA del servidor guardados",
|
||||
"error": "No se pudo guardar la clave de IA del servidor"
|
||||
"error": "No se pudo guardar la clave de IA del servidor",
|
||||
"legacyGrantTitle": "ALLOW_LOCAL_AI_PRIVATE_HOSTS=true está obsoleto",
|
||||
"legacyGrantBody": "Sigue permitiendo que la clave del servidor de arriba y la configuración de IA guardada en una cuenta de administrador lleguen a tu red, y se eliminará en una versión posterior. Las demás cuentas ya necesitan su endpoint de IA en AI_PRIVATE_ORIGINS. Define AI_PRIVATE_ORIGINS en el entorno del servidor, reinicia y después elimina ALLOW_LOCAL_AI_PRIVATE_HOSTS.",
|
||||
"legacyGrantOrigins": "Las URL de IA guardadas en esta instancia que apuntan a tu red:",
|
||||
"legacyGrantNoOrigins": "Ninguna URL de IA guardada en esta instancia apunta a tu red, así que ya puedes eliminar ALLOW_LOCAL_AI_PRIVATE_HOSTS."
|
||||
},
|
||||
"providerHealth": {
|
||||
"title": "Estado de los proveedores de IA",
|
||||
|
||||
+5
-1
@@ -6746,7 +6746,11 @@
|
||||
"removeBody": "Les comptes sans clé personnelle perdent aussitôt les fonctions IA. La clé n’est affichée nulle part une fois enregistrée : il vous faudra l’original pour la remettre.",
|
||||
"removeConfirm": "Supprimer la clé",
|
||||
"saved": "Paramètres de la clé IA du serveur enregistrés",
|
||||
"error": "Échec de l'enregistrement de la clé IA du serveur"
|
||||
"error": "Échec de l'enregistrement de la clé IA du serveur",
|
||||
"legacyGrantTitle": "ALLOW_LOCAL_AI_PRIVATE_HOSTS=true est obsolète",
|
||||
"legacyGrantBody": "Ce réglage permet encore à la clé serveur ci-dessus et aux réglages d'IA enregistrés sur un compte administrateur d'atteindre votre réseau, et sera supprimé dans une version ultérieure. Les autres comptes ont déjà besoin que leur point d'accès IA figure dans AI_PRIVATE_ORIGINS. Définissez AI_PRIVATE_ORIGINS dans l'environnement du serveur, redémarrez, puis supprimez ALLOW_LOCAL_AI_PRIVATE_HOSTS.",
|
||||
"legacyGrantOrigins": "Les URL d'IA enregistrées sur cette instance qui pointent vers votre réseau :",
|
||||
"legacyGrantNoOrigins": "Aucune URL d'IA enregistrée sur cette instance ne pointe vers votre réseau, vous pouvez donc supprimer ALLOW_LOCAL_AI_PRIVATE_HOSTS dès maintenant."
|
||||
},
|
||||
"providerHealth": {
|
||||
"title": "État des fournisseurs d'IA",
|
||||
|
||||
+5
-1
@@ -6746,7 +6746,11 @@
|
||||
"removeBody": "Gli account senza una chiave propria perdono subito le funzioni IA. Dopo il salvataggio la chiave non è più visibile da nessuna parte: per rimetterla ti servirà l’originale.",
|
||||
"removeConfirm": "Rimuovi chiave",
|
||||
"saved": "Impostazioni della chiave IA del server salvate",
|
||||
"error": "Salvataggio della chiave IA del server non riuscito"
|
||||
"error": "Salvataggio della chiave IA del server non riuscito",
|
||||
"legacyGrantTitle": "ALLOW_LOCAL_AI_PRIVATE_HOSTS=true è deprecato",
|
||||
"legacyGrantBody": "Consente ancora alla chiave del server qui sopra e alle impostazioni IA salvate su un account amministratore di raggiungere la tua rete, e verrà rimosso in una versione successiva. Gli altri account hanno già bisogno del loro endpoint IA in AI_PRIVATE_ORIGINS. Imposta AI_PRIVATE_ORIGINS nell'ambiente del server, riavvia e poi rimuovi ALLOW_LOCAL_AI_PRIVATE_HOSTS.",
|
||||
"legacyGrantOrigins": "Gli URL IA salvati su questa istanza che puntano alla tua rete:",
|
||||
"legacyGrantNoOrigins": "Nessun URL IA salvato su questa istanza punta alla tua rete, quindi puoi rimuovere ALLOW_LOCAL_AI_PRIVATE_HOSTS già ora."
|
||||
},
|
||||
"providerHealth": {
|
||||
"title": "Stato dei provider IA",
|
||||
|
||||
+5
-1
@@ -6746,7 +6746,11 @@
|
||||
"removeBody": "자기 키가 없는 계정은 바로 AI 기능을 쓸 수 없게 돼요. 저장한 뒤에는 키를 어디에도 다시 보여주지 않으니, 되돌리려면 원본 키가 필요해요.",
|
||||
"removeConfirm": "키 삭제",
|
||||
"saved": "AI 서버 키 설정을 저장했어요",
|
||||
"error": "AI 서버 키를 저장하지 못했어요"
|
||||
"error": "AI 서버 키를 저장하지 못했어요",
|
||||
"legacyGrantTitle": "ALLOW_LOCAL_AI_PRIVATE_HOSTS=true는 더 이상 권장되지 않습니다",
|
||||
"legacyGrantBody": "위의 서버 키와 관리자 계정에 저장된 AI 설정은 아직 이 설정으로 네트워크에 접근할 수 있지만, 이후 버전에서 제거됩니다. 다른 계정은 이미 AI 엔드포인트를 AI_PRIVATE_ORIGINS에 등록해야 합니다. 서버 환경에 AI_PRIVATE_ORIGINS를 설정하고 재시작한 뒤 ALLOW_LOCAL_AI_PRIVATE_HOSTS를 제거하세요.",
|
||||
"legacyGrantOrigins": "이 인스턴스에 저장된 AI 주소 중 내부 네트워크를 가리키는 주소:",
|
||||
"legacyGrantNoOrigins": "이 인스턴스에 저장된 AI 주소 중 내부 네트워크를 가리키는 주소가 없으므로 지금 ALLOW_LOCAL_AI_PRIVATE_HOSTS를 제거해도 됩니다."
|
||||
},
|
||||
"aiQuality": {
|
||||
"title": "인사이트 품질",
|
||||
|
||||
+5
-1
@@ -6746,7 +6746,11 @@
|
||||
"removeBody": "Konta bez własnego klucza natychmiast tracą funkcje AI. Po zapisaniu klucz nie jest nigdzie pokazywany, więc do przywrócenia potrzebny będzie oryginał.",
|
||||
"removeConfirm": "Usuń klucz",
|
||||
"saved": "Zapisano ustawienia serwerowego klucza AI",
|
||||
"error": "Nie udało się zapisać serwerowego klucza AI"
|
||||
"error": "Nie udało się zapisać serwerowego klucza AI",
|
||||
"legacyGrantTitle": "ALLOW_LOCAL_AI_PRIVATE_HOSTS=true jest przestarzałe",
|
||||
"legacyGrantBody": "Nadal pozwala kluczowi serwera powyżej i ustawieniom AI zapisanym na koncie administratora sięgać do Twojej sieci i zostanie usunięte w jednej z kolejnych wersji. Pozostałe konta już teraz potrzebują swojego punktu końcowego AI w AI_PRIVATE_ORIGINS. Ustaw AI_PRIVATE_ORIGINS w środowisku serwera, uruchom go ponownie, a potem usuń ALLOW_LOCAL_AI_PRIVATE_HOSTS.",
|
||||
"legacyGrantOrigins": "Adresy AI zapisane na tej instancji, które wskazują na Twoją sieć:",
|
||||
"legacyGrantNoOrigins": "Żaden adres AI zapisany na tej instancji nie wskazuje na Twoją sieć, więc możesz już usunąć ALLOW_LOCAL_AI_PRIVATE_HOSTS."
|
||||
},
|
||||
"providerHealth": {
|
||||
"title": "Stan dostawców AI",
|
||||
|
||||
@@ -124,11 +124,11 @@
|
||||
"variables": [
|
||||
{
|
||||
"name": "AI_PRIVATE_ORIGINS",
|
||||
"purpose": "AI endpoints on the operator's own network (Ollama, LM Studio, LiteLLM). Comma-separated exact origins (scheme://host[:port]), e.g. \"http://ollama.lan:11434\". Unset refuses every private AI base URL. Link-local, metadata and unspecified addresses stay refused even when listed; a malformed entry is logged once and grants nothing."
|
||||
"purpose": "AI endpoints on the operator's own network (Ollama, LM Studio, LiteLLM). Comma-separated exact origins (scheme://host[:port]), e.g. \"http://ollama.lan:11434\". Unset, a private AI base URL is refused (apart from what the deprecated ALLOW_LOCAL_AI_PRIVATE_HOSTS=true still covers). Link-local, metadata and unspecified addresses stay refused even when listed; a malformed entry is logged once and grants nothing."
|
||||
},
|
||||
{
|
||||
"name": "ALLOW_LOCAL_AI_PRIVATE_HOSTS",
|
||||
"purpose": "Older form of AI_PRIVATE_ORIGINS, kept for existing installs: an exact comma-separated host list still grants those hosts. \"true\" grants nothing since v1.39.3 and is reported at boot."
|
||||
"purpose": "Older form of AI_PRIVATE_ORIGINS, kept for existing installs: an exact comma-separated host list still grants those hosts. \"true\" is deprecated since v1.39.3: it covers only the admin AI key and AI settings saved on an admin account (pinned; metadata and link-local refused), is reported at boot and on the admin AI settings page, and will be removed in a later release."
|
||||
},
|
||||
{
|
||||
"name": "INSIGHTS_MAX_TOKENS",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* HTTPS + hostname allowlist on the base URL, and the masked
|
||||
* `keyPreview` in every response.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
@@ -15,6 +15,7 @@ vi.mock("@/lib/db", () => ({
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
user: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -100,6 +101,7 @@ describe("GET /api/admin/ai-settings", () => {
|
||||
keyPreview: null,
|
||||
model: "gpt-4o",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
legacyPrivateHostGrant: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -216,3 +218,52 @@ describe("PUT /api/admin/ai-settings", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.39.3 — while the deprecated `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` is set,
|
||||
* the page names it and lists the origins it is standing in for, so the
|
||||
* operator can copy them into `AI_PRIVATE_ORIGINS`.
|
||||
*/
|
||||
describe("GET /api/admin/ai-settings — deprecated private-host grant", () => {
|
||||
beforeEach(async () => {
|
||||
const { _resetAiGrantsForTests } =
|
||||
await import("@/lib/ai/local-host-allowlist");
|
||||
_resetAiGrantsForTests();
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: null,
|
||||
adminAiModel: null,
|
||||
adminAiBaseUrl: "http://gateway.lan:4000/v1",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findMany).mockResolvedValue([
|
||||
{ aiBaseUrl: "http://10.0.0.5:11434/v1", aiCompatBaseUrl: null },
|
||||
{
|
||||
aiBaseUrl: "http://10.0.0.5:11434/v1/",
|
||||
aiCompatBaseUrl: "https://openrouter.ai/api/v1",
|
||||
},
|
||||
{ aiBaseUrl: "http://169.254.169.254/latest", aiCompatBaseUrl: null },
|
||||
] as never);
|
||||
vi.stubEnv("AI_PRIVATE_ORIGINS", "");
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("lists the private origins in use, once each, when `true` is set", async () => {
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
|
||||
const res = await GET();
|
||||
const body = await res.json();
|
||||
expect(body.data.legacyPrivateHostGrant).toEqual({
|
||||
originsInUse: ["http://10.0.0.5:11434", "http://gateway.lan:4000"],
|
||||
});
|
||||
});
|
||||
|
||||
it("is null when `true` is not set", async () => {
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "ollama.lan");
|
||||
const res = await GET();
|
||||
expect((await res.json()).data.legacyPrivateHostGrant).toBeNull();
|
||||
expect(prisma.user.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { apiSuccess, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { legacyPrivateHostReport } from "@/lib/ai/legacy-private-host-report";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -28,6 +29,11 @@ export const GET = apiHandler(async () => {
|
||||
: null,
|
||||
model,
|
||||
baseUrl,
|
||||
// v1.39.3 — present while the deprecated ALLOW_LOCAL_AI_PRIVATE_HOSTS=true
|
||||
// is set, naming the origins to move into AI_PRIVATE_ORIGINS.
|
||||
legacyPrivateHostGrant: await legacyPrivateHostReport(
|
||||
settings?.adminAiBaseUrl,
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* v1.39.3 — `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` is deprecated and narrowed,
|
||||
* not removed. Saving a private base URL under it is still accepted for the
|
||||
* operator's own configuration (an admin account) and refused for any other
|
||||
* account, which needs an exact origin in `AI_PRIVATE_ORIGINS`. Metadata and
|
||||
* link-local are refused for everyone. The real policy and the real public-host
|
||||
* check run here; only persistence is stubbed.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({
|
||||
user: { id: "u-1" },
|
||||
session: { id: "s-1" },
|
||||
})),
|
||||
HttpError: class HttpError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: vi.fn(), update: vi.fn() } },
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((v: string) => `dec:${v}`),
|
||||
encrypt: vi.fn((v: string) => `enc:${v}`),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProviderAvailability: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/ai/server-provider-health", () => ({
|
||||
readServerProviderHealth: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/feature-flags", async () =>
|
||||
(
|
||||
await import("@/__tests__/helpers/assistant-switches-mock")
|
||||
).mockAssistantSwitches(vi.fn()),
|
||||
);
|
||||
vi.mock("@/lib/sharing/provider-work-authority", () => ({
|
||||
providerWorkAuthorityForRecord: vi.fn(() => ({ origin: "owner" })),
|
||||
providerCredentialPolicy: vi.fn(() => "personal"),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
hasActiveConsentForSurface: vi.fn(),
|
||||
}));
|
||||
|
||||
import { PATCH } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { _resetAiGrantsForTests } from "@/lib/ai/local-host-allowlist";
|
||||
|
||||
const patch = PATCH as (req: Request) => Promise<Response>;
|
||||
|
||||
function save(body: Record<string, string>) {
|
||||
return patch(
|
||||
new Request("http://localhost/api/user/ai-provider", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function asRole(role: "ADMIN" | "USER") {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({ role } as never);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.user.findUnique).mockReset();
|
||||
vi.mocked(prisma.user.update).mockReset();
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
_resetAiGrantsForTests();
|
||||
vi.stubEnv("AI_PRIVATE_ORIGINS", "");
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("PATCH /api/user/ai-provider under ALLOW_LOCAL_AI_PRIVATE_HOSTS=true", () => {
|
||||
it.each([
|
||||
["baseUrl", "http://10.0.0.5:11434/v1"],
|
||||
["compatBaseUrl", "http://192.168.1.20:4000/v1"],
|
||||
])("refuses a non-admin account's private %s", async (field, url) => {
|
||||
asRole("USER");
|
||||
const res = await save({ [field]: url });
|
||||
expect(res.status).toBe(422);
|
||||
expect((await res.json()).error).toContain("AI_PRIVATE_ORIGINS");
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["baseUrl", "http://10.0.0.5:11434/v1"],
|
||||
["compatBaseUrl", "http://192.168.1.20:4000/v1"],
|
||||
])("accepts an admin account's private %s", async (field, url) => {
|
||||
asRole("ADMIN");
|
||||
const res = await save({ [field]: url });
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://[fe80::1]:11434/v1",
|
||||
"http://0.0.0.0:11434/v1",
|
||||
])("refuses %s even for an admin account", async (url) => {
|
||||
asRole("ADMIN");
|
||||
const res = await save({ baseUrl: url });
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a non-admin account's private URL once its exact origin is granted", async () => {
|
||||
vi.stubEnv("AI_PRIVATE_ORIGINS", "http://10.0.0.5:11434");
|
||||
asRole("USER");
|
||||
const res = await save({ baseUrl: "http://10.0.0.5:11434/v1" });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -179,6 +179,26 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
const body = parsed.data;
|
||||
|
||||
// v1.39.3 — whose configuration this is. Settings saved on an admin account
|
||||
// are the operator's own and are still covered by the deprecated
|
||||
// `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true`; any other account needs an exact
|
||||
// origin in `AI_PRIVATE_ORIGINS`. Read from the saved row, the same rule
|
||||
// the provider resolution applies when it later dials the URL.
|
||||
const needsHostPolicy =
|
||||
(typeof body.baseUrl === "string" && body.baseUrl !== "") ||
|
||||
(typeof body.compatBaseUrl === "string" && body.compatBaseUrl !== "");
|
||||
const owner = needsHostPolicy
|
||||
? {
|
||||
operatorTrusted:
|
||||
(
|
||||
await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { role: true },
|
||||
})
|
||||
)?.role === "ADMIN",
|
||||
}
|
||||
: {};
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
|
||||
if (body.provider !== undefined) {
|
||||
@@ -199,9 +219,10 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
// compromised user account cannot point the server at the cloud
|
||||
// metadata endpoint or internal admin panels. The operator grants a
|
||||
// private endpoint by exact origin (`AI_PRIVATE_ORIGINS`, or the legacy
|
||||
// host list); the retired `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` grants
|
||||
// nothing since v1.39.3, and no grant can open metadata or link-local.
|
||||
const allowPrivate = isLocalAiHostAllowed(trimmed);
|
||||
// host list); the deprecated `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` covers
|
||||
// an admin account's own settings only (`owner` above), and no grant
|
||||
// can open metadata or link-local.
|
||||
const allowPrivate = isLocalAiHostAllowed(trimmed, owner);
|
||||
if (!allowPrivate && !isPublicUrl(trimmed)) {
|
||||
return apiError(
|
||||
"Base URL points to an internal/private host. The operator must allow its exact origin on this instance via AI_PRIVATE_ORIGINS (e.g. http://ollama.lan:11434) for a self-hosted Ollama / LM Studio.",
|
||||
@@ -226,7 +247,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
// private one only when the operator allowlisted it. Gateways on a LAN
|
||||
// are the normal case for LiteLLM / vLLM, so the escape hatch matters
|
||||
// here as much as it does for Ollama.
|
||||
if (!isLocalAiHostAllowed(trimmed) && !isPublicUrl(trimmed)) {
|
||||
if (!isLocalAiHostAllowed(trimmed, owner) && !isPublicUrl(trimmed)) {
|
||||
return apiError(
|
||||
"Base URL points to an internal/private host. The operator must allow its exact origin on this instance via AI_PRIVATE_ORIGINS (e.g. http://litellm.lan:4000) for a self-hosted gateway.",
|
||||
422,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.39.3 — the admin AI card names the deprecated
|
||||
* `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` and gives the exact
|
||||
* `AI_PRIVATE_ORIGINS` line to set instead, built from the origins the server
|
||||
* reports in use. Absent when the server reports no such grant.
|
||||
*/
|
||||
|
||||
const state = vi.hoisted(() => ({ data: null as null | object }));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: state.data }),
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { AiServerKeySection } from "../ai-server-key-section";
|
||||
|
||||
function render(): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<AiServerKeySection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const base = {
|
||||
hasKey: false,
|
||||
keyPreview: null,
|
||||
model: "gpt-4o",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
};
|
||||
|
||||
describe("<AiServerKeySection> deprecated private-host notice", () => {
|
||||
it("shows the exact line to set, with every origin in use", () => {
|
||||
state.data = {
|
||||
...base,
|
||||
legacyPrivateHostGrant: {
|
||||
originsInUse: ["http://10.0.0.5:11434", "http://gateway.lan:4000"],
|
||||
},
|
||||
};
|
||||
const html = render();
|
||||
expect(html).toContain('data-slot="admin-ai-legacy-private-hosts"');
|
||||
expect(html).toContain("is deprecated");
|
||||
expect(html).toContain(
|
||||
"AI_PRIVATE_ORIGINS="http://10.0.0.5:11434,http://gateway.lan:4000"",
|
||||
);
|
||||
});
|
||||
|
||||
it("says the setting can go when no saved URL needs it", () => {
|
||||
state.data = { ...base, legacyPrivateHostGrant: { originsInUse: [] } };
|
||||
const html = render();
|
||||
expect(html).toContain("you can remove ALLOW_LOCAL_AI_PRIVATE_HOSTS now");
|
||||
expect(html).not.toContain("admin-ai-legacy-private-hosts-line");
|
||||
});
|
||||
|
||||
it("is absent when the server reports no deprecated grant", () => {
|
||||
state.data = { ...base, legacyPrivateHostGrant: null };
|
||||
expect(render()).not.toContain("admin-ai-legacy-private-hosts");
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { KeyRound, Loader2 } from "lucide-react";
|
||||
import { AlertTriangle, KeyRound, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -49,6 +49,12 @@ interface AiServerKeyResponse {
|
||||
keyPreview: string | null;
|
||||
model: string;
|
||||
baseUrl: string;
|
||||
/**
|
||||
* v1.39.3 — present while the deprecated
|
||||
* `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` is set: the origins it is standing in
|
||||
* for, to copy into `AI_PRIVATE_ORIGINS`.
|
||||
*/
|
||||
legacyPrivateHostGrant?: { originsInUse: string[] } | null;
|
||||
}
|
||||
|
||||
export function AiServerKeySection() {
|
||||
@@ -120,6 +126,11 @@ export function AiServerKeySection() {
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
{settings?.legacyPrivateHostGrant ? (
|
||||
<LegacyPrivateHostNotice
|
||||
originsInUse={settings.legacyPrivateHostGrant.originsInUse}
|
||||
/>
|
||||
) : null}
|
||||
<div className="space-y-2 text-sm">
|
||||
<p>{t("admin.aiServerKey.byokHint")}</p>
|
||||
<p>{t("admin.aiServerKey.consentHint")}</p>
|
||||
@@ -201,3 +212,43 @@ export function AiServerKeySection() {
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.39.3 — the deprecated `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true`, with the exact
|
||||
* line to set instead. The origins come from the saved base URLs on this
|
||||
* instance; the operator copies the line into the server environment.
|
||||
*/
|
||||
function LegacyPrivateHostNotice({ originsInUse }: { originsInUse: string[] }) {
|
||||
const { t } = useTranslations();
|
||||
const line = `AI_PRIVATE_ORIGINS="${originsInUse.join(",")}"`;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
data-slot="admin-ai-legacy-private-hosts"
|
||||
className="border-warning/40 bg-warning/10 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="font-medium">
|
||||
{t("admin.aiServerKey.legacyGrantTitle")}
|
||||
</p>
|
||||
<p>{t("admin.aiServerKey.legacyGrantBody")}</p>
|
||||
{originsInUse.length > 0 ? (
|
||||
<>
|
||||
<p>{t("admin.aiServerKey.legacyGrantOrigins")}</p>
|
||||
<code
|
||||
data-slot="admin-ai-legacy-private-hosts-line"
|
||||
className="bg-muted block overflow-x-auto rounded px-2 py-1 font-mono text-xs break-all select-all"
|
||||
>
|
||||
{line}
|
||||
</code>
|
||||
</>
|
||||
) : (
|
||||
<p>{t("admin.aiServerKey.legacyGrantNoOrigins")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ describe("SSRF requirePublicHost pin inventory", () => {
|
||||
it("openai-client routes person-typed base URLs through the allowlist with a pinned floor", () => {
|
||||
const src = read("ai/openai-client.ts");
|
||||
expect(src).toMatch(
|
||||
/this\.isGateway \|\| this\.type === "admin-key"\s*\?\s*aiEgressPolicyFor\(url\)\s*:\s*\{\s*requirePublicHost:\s*true as const\s*\}/,
|
||||
/this\.isGateway \|\| this\.type === "admin-key"\s*\?\s*aiEgressPolicyFor\(url,\s*\{\s*operatorTrusted:\s*this\.config\.operatorTrusted,?\s*\}\)\s*:\s*\{\s*requirePublicHost:\s*true as const\s*\}/,
|
||||
);
|
||||
expect(src).not.toMatch(/requirePublicHost:\s*false/);
|
||||
});
|
||||
@@ -87,7 +87,9 @@ describe("SSRF requirePublicHost pin inventory", () => {
|
||||
const src = read("ai/local-client.ts");
|
||||
// Both the buffered and the streaming call derive the policy …
|
||||
expect([
|
||||
...src.matchAll(/const egress = aiEgressPolicyFor\(url\)/g),
|
||||
...src.matchAll(
|
||||
/const egress = aiEgressPolicyFor\(url,\s*\{\s*operatorTrusted:\s*this\.config\.operatorTrusted,?\s*\}\)/g,
|
||||
),
|
||||
]).toHaveLength(2);
|
||||
// … and spread it into safeFetch …
|
||||
expect([...src.matchAll(/\.\.\.egress,/g)]).toHaveLength(2);
|
||||
|
||||
@@ -67,12 +67,26 @@ describe("Local AI egress policy", () => {
|
||||
expect(opts).not.toHaveProperty("operatorApprovedPrivateOrigin");
|
||||
});
|
||||
|
||||
it("the retired `=true` grants nothing any more", async () => {
|
||||
it("the deprecated `=true` no longer covers a non-admin account's endpoint", async () => {
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
|
||||
await client("http://10.0.0.5:11434/v1").generateCompletion(call);
|
||||
expect(safeFetch.mock.calls[0][2].requirePublicHost).toBe(true);
|
||||
});
|
||||
|
||||
it("the deprecated `=true` still covers the operator's own endpoint, pinned", async () => {
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
|
||||
await new LocalOpenAICompatibleClient({
|
||||
apiKey: null,
|
||||
model: "llama3:8b",
|
||||
baseUrl: "http://10.0.0.5:11434/v1",
|
||||
operatorTrusted: true,
|
||||
}).generateCompletion(call);
|
||||
expect(safeFetch.mock.calls[0][2]).toMatchObject({
|
||||
requirePublicHost: false,
|
||||
operatorApprovedPrivateOrigin: "http://10.0.0.5:11434",
|
||||
});
|
||||
});
|
||||
|
||||
it("streams through the same policy", async () => {
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "ollama.lan");
|
||||
await client("http://ollama.lan:11434/v1")
|
||||
|
||||
@@ -7,13 +7,15 @@ import {
|
||||
isLocalAiHostAllowed,
|
||||
legacyAnyHostConfigured,
|
||||
LEGACY_ANY_HOST_WARNING,
|
||||
originNeedingAiGrant,
|
||||
} from "../local-host-allowlist";
|
||||
|
||||
/**
|
||||
* v1.39.3 — AI base URLs on a private network are granted by exact origin
|
||||
* (`AI_PRIVATE_ORIGINS`), the grammar the notification and Nightscout grants
|
||||
* use, and dialled through the pinned operator-approved dispatcher. The legacy
|
||||
* host list keeps working; its `=true` form grants nothing.
|
||||
* host list keeps working; its deprecated `=true` form covers only the
|
||||
* operator's own configurations.
|
||||
*/
|
||||
|
||||
let warn: ReturnType<typeof vi.spyOn>;
|
||||
@@ -72,12 +74,50 @@ describe("ALLOW_LOCAL_AI_PRIVATE_HOSTS (legacy)", () => {
|
||||
expect(isLocalAiHostAllowed("http://10.0.0.6/v1")).toBe(false);
|
||||
});
|
||||
|
||||
it("no longer opens every private host with `true`, and says so", () => {
|
||||
it("`true` still covers an operator-owned configuration, pinned, and says it is deprecated", () => {
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
|
||||
expect(
|
||||
aiEgressPolicyFor("http://10.0.0.5:11434/v1", { operatorTrusted: true }),
|
||||
).toEqual({
|
||||
requirePublicHost: false,
|
||||
operatorApprovedPrivateOrigin: "http://10.0.0.5:11434",
|
||||
});
|
||||
expect(warn).toHaveBeenCalledWith(LEGACY_ANY_HOST_WARNING);
|
||||
expect(LEGACY_ANY_HOST_WARNING).toContain("deprecated");
|
||||
expect(LEGACY_ANY_HOST_WARNING).toContain("AI_PRIVATE_ORIGINS=");
|
||||
expect(legacyAnyHostConfigured()).toBe(true);
|
||||
});
|
||||
|
||||
it("`true` no longer covers a non-admin account's own base URL", () => {
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
|
||||
expect(isLocalAiHostAllowed("http://10.0.0.5:11434/v1")).toBe(false);
|
||||
expect(isLocalAiHostAllowed("http://169.254.169.254/latest/")).toBe(false);
|
||||
expect(warn).toHaveBeenCalledWith(LEGACY_ANY_HOST_WARNING);
|
||||
expect(legacyAnyHostConfigured()).toBe(true);
|
||||
expect(
|
||||
isLocalAiHostAllowed("http://10.0.0.5:11434/v1", {
|
||||
operatorTrusted: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(aiEgressPolicyFor("http://10.0.0.5:11434/v1")).toEqual({
|
||||
requirePublicHost: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("`true` never opens metadata, link-local or the unspecified address, even for the operator", () => {
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
|
||||
for (const url of [
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://[fe80::1]/v1",
|
||||
"http://0.0.0.0:11434/v1",
|
||||
"http://[::ffff:169.254.169.254]/v1",
|
||||
]) {
|
||||
expect(isLocalAiHostAllowed(url, { operatorTrusted: true }), url).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("an exact origin grant covers every account, with or without `true`", () => {
|
||||
vi.stubEnv("AI_PRIVATE_ORIGINS", "http://ollama.lan:11434");
|
||||
expect(isLocalAiHostAllowed("http://ollama.lan:11434/v1")).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a metadata literal and a host:port entry instead of granting a guess", () => {
|
||||
@@ -114,3 +154,30 @@ describe("aiEgressPolicyFor", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("originNeedingAiGrant", () => {
|
||||
it("names the origin of a LAN endpoint no grant covers yet", () => {
|
||||
for (const [url, origin] of [
|
||||
["http://10.0.0.5:11434/v1", "http://10.0.0.5:11434"],
|
||||
["http://ollama.lan:11434/v1", "http://ollama.lan:11434"],
|
||||
["http://ollama:11434/v1", "http://ollama:11434"],
|
||||
["http://nas.local:1234/v1", "http://nas.local:1234"],
|
||||
["http://localhost:11434/v1", "http://localhost:11434"],
|
||||
]) {
|
||||
expect(originNeedingAiGrant(url), url).toBe(origin);
|
||||
}
|
||||
});
|
||||
|
||||
it("skips hosted endpoints, never-grantable addresses and origins already granted", () => {
|
||||
vi.stubEnv("AI_PRIVATE_ORIGINS", "http://10.0.0.5:11434");
|
||||
for (const url of [
|
||||
"https://openrouter.ai/api/v1",
|
||||
"https://api.openai.com/v1",
|
||||
"http://169.254.169.254/latest",
|
||||
"http://10.0.0.5:11434/v1",
|
||||
"not a url",
|
||||
]) {
|
||||
expect(originNeedingAiGrant(url), url).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* v1.39.3 — the deprecated `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` at dial time.
|
||||
*
|
||||
* Provider resolution decides whose configuration a base URL is, and the
|
||||
* client dials accordingly. Under `=true` a private endpoint is still reached
|
||||
* for the operator's own configurations (the instance-wide admin provider and
|
||||
* settings saved on an admin account), always through the pinned
|
||||
* operator-approved dispatcher; any other account's saved URL is pinned to
|
||||
* public hosts, so `safeFetch` refuses it. Checked at the wire: the options
|
||||
* each resolved provider hands to `safeFetch`.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { safeFetch } = vi.hoisted(() => ({ safeFetch: vi.fn() }));
|
||||
vi.mock("@/lib/safe-fetch", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/safe-fetch")>()),
|
||||
safeFetch,
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((v: string) => `decrypted:${v}`),
|
||||
encrypt: vi.fn((v: string) => `encrypted:${v}`),
|
||||
}));
|
||||
vi.mock("@/lib/ai/codex-oauth", () => ({
|
||||
refreshDeviceTokens: vi.fn(),
|
||||
encryptCodexCreds: vi.fn(),
|
||||
decryptCodexCreds: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
import {
|
||||
AITestConfigError,
|
||||
resolveProvider,
|
||||
resolveProviderForTest,
|
||||
} from "../provider";
|
||||
import { _resetAiGrantsForTests } from "../local-host-allowlist";
|
||||
import { singleUserTurn } from "../types";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
function row(role: "ADMIN" | "USER", overrides: Record<string, unknown>) {
|
||||
return {
|
||||
aiProvider: null,
|
||||
aiModel: "m",
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: null,
|
||||
aiCompatBaseUrl: null,
|
||||
aiCompatKeyEncrypted: null,
|
||||
aiCompatModel: null,
|
||||
managedProfileAt: null,
|
||||
role,
|
||||
...overrides,
|
||||
} as never;
|
||||
}
|
||||
|
||||
async function dialOptions(): Promise<Record<string, unknown>> {
|
||||
const provider = await resolveProvider("u-1");
|
||||
await provider
|
||||
.generateCompletion(singleUserTurn({ system: "s", user: "u" }))
|
||||
.catch(() => undefined);
|
||||
return safeFetch.mock.calls[0][2] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
_resetAiGrantsForTests();
|
||||
safeFetch.mockReset();
|
||||
safeFetch.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({ choices: [{ message: { content: "ok" } }] }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
),
|
||||
);
|
||||
vi.mocked(prisma.user.findUnique).mockReset();
|
||||
vi.mocked(prisma.appSettings.findUnique).mockReset();
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
|
||||
vi.stubEnv("AI_PRIVATE_ORIGINS", "");
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("ALLOW_LOCAL_AI_PRIVATE_HOSTS=true at dial time", () => {
|
||||
it.each([
|
||||
["LOCAL", { aiProvider: "LOCAL", aiBaseUrl: "http://10.0.0.5:11434/v1" }],
|
||||
[
|
||||
"OPENAI_COMPATIBLE",
|
||||
{
|
||||
aiProvider: "OPENAI_COMPATIBLE",
|
||||
aiCompatBaseUrl: "http://10.0.0.5:4000/v1",
|
||||
aiCompatModel: "m",
|
||||
},
|
||||
],
|
||||
])(
|
||||
"an admin account's %s endpoint goes through the operator-approved pin",
|
||||
async (_label, overrides) => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
||||
row("ADMIN", overrides),
|
||||
);
|
||||
const opts = await dialOptions();
|
||||
expect(opts.requirePublicHost).toBe(false);
|
||||
expect(opts.operatorApprovedPrivateOrigin).toMatch(
|
||||
/^http:\/\/10\.0\.0\.5:/,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["LOCAL", { aiProvider: "LOCAL", aiBaseUrl: "http://10.0.0.5:11434/v1" }],
|
||||
[
|
||||
"OPENAI_COMPATIBLE",
|
||||
{
|
||||
aiProvider: "OPENAI_COMPATIBLE",
|
||||
aiCompatBaseUrl: "http://10.0.0.5:4000/v1",
|
||||
aiCompatModel: "m",
|
||||
},
|
||||
],
|
||||
])(
|
||||
"a non-admin account's %s endpoint stays on the public pin",
|
||||
async (_label, overrides) => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
||||
row("USER", overrides),
|
||||
);
|
||||
const opts = await dialOptions();
|
||||
expect(opts.requirePublicHost).toBe(true);
|
||||
expect(opts).not.toHaveProperty("operatorApprovedPrivateOrigin");
|
||||
},
|
||||
);
|
||||
|
||||
it("the metadata address stays on the public pin even for an admin account", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
||||
row("ADMIN", {
|
||||
aiProvider: "LOCAL",
|
||||
aiBaseUrl: "http://169.254.169.254/latest/v1",
|
||||
}),
|
||||
);
|
||||
const opts = await dialOptions();
|
||||
expect(opts.requirePublicHost).toBe(true);
|
||||
});
|
||||
|
||||
it("the instance-wide admin provider is the operator's own", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
||||
row("USER", { aiProvider: null }),
|
||||
);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: "k",
|
||||
adminAiModel: "m",
|
||||
adminAiBaseUrl: "http://10.0.0.7:8080/v1",
|
||||
} as never);
|
||||
const opts = await dialOptions();
|
||||
expect(opts).toMatchObject({
|
||||
requirePublicHost: false,
|
||||
operatorApprovedPrivateOrigin: "http://10.0.0.7:8080",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("the connection test under ALLOW_LOCAL_AI_PRIVATE_HOSTS=true", () => {
|
||||
it("refuses a non-admin account's private endpoint", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
||||
row("USER", {
|
||||
aiProvider: "LOCAL",
|
||||
aiBaseUrl: "http://10.0.0.5:11434/v1",
|
||||
}),
|
||||
);
|
||||
await expect(resolveProviderForTest("u-1")).rejects.toBeInstanceOf(
|
||||
AITestConfigError,
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves an admin account's private endpoint", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
||||
row("ADMIN", {
|
||||
aiProvider: "LOCAL",
|
||||
aiBaseUrl: "http://10.0.0.5:11434/v1",
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
resolveProviderForTest("u-1", { provider: "LOCAL" }),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* v1.39.3 — what the deprecated `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` is
|
||||
* currently standing in for, for the admin AI settings page.
|
||||
*
|
||||
* Null when `true` is not set. Otherwise the distinct origins of every saved
|
||||
* AI base URL on the instance (each account's Local and gateway URL, and the
|
||||
* instance-wide admin base URL) that point at the operator's network and that
|
||||
* no `AI_PRIVATE_ORIGINS` or legacy host-list entry covers yet. Listing them is
|
||||
* the migration path: the operator copies them into `AI_PRIVATE_ORIGINS`, and
|
||||
* `=true` can go. A non-admin account's URL is included on purpose; `=true` no
|
||||
* longer covers it, so it is exactly the one that needs an entry.
|
||||
*
|
||||
* An origin is scheme, host and port, nothing more, so no path or credential
|
||||
* a person typed into a base URL reaches the page.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
legacyAnyHostConfigured,
|
||||
originNeedingAiGrant,
|
||||
} from "@/lib/ai/local-host-allowlist";
|
||||
|
||||
export interface LegacyPrivateHostReport {
|
||||
originsInUse: string[];
|
||||
}
|
||||
|
||||
export async function legacyPrivateHostReport(
|
||||
adminBaseUrl: string | null | undefined,
|
||||
): Promise<LegacyPrivateHostReport | null> {
|
||||
if (!legacyAnyHostConfigured()) return null;
|
||||
const rows = await prisma.user.findMany({
|
||||
where: {
|
||||
OR: [{ aiBaseUrl: { not: null } }, { aiCompatBaseUrl: { not: null } }],
|
||||
},
|
||||
select: { aiBaseUrl: true, aiCompatBaseUrl: true },
|
||||
});
|
||||
const origins = new Set<string>();
|
||||
for (const url of [
|
||||
adminBaseUrl,
|
||||
...rows.flatMap((r) => [r.aiBaseUrl, r.aiCompatBaseUrl]),
|
||||
]) {
|
||||
if (!url) continue;
|
||||
const origin = originNeedingAiGrant(url);
|
||||
if (origin) origins.add(origin);
|
||||
}
|
||||
return { originsInUse: [...origins].sort() };
|
||||
}
|
||||
@@ -27,6 +27,13 @@ interface LocalClientConfig {
|
||||
apiKey?: string | null;
|
||||
model: string;
|
||||
baseUrl: string;
|
||||
/**
|
||||
* v1.39.3 — true when the base URL belongs to the operator: the
|
||||
* instance-wide admin provider, or provider settings saved on an admin
|
||||
* account. Only such a URL is still covered by the deprecated
|
||||
* `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` (see `local-host-allowlist.ts`).
|
||||
*/
|
||||
operatorTrusted?: boolean;
|
||||
}
|
||||
|
||||
const STRICT_JSON_PREFIX =
|
||||
@@ -198,7 +205,9 @@ export class LocalOpenAICompatibleClient implements AIProvider {
|
||||
// operator-approved dispatcher, never unpinned, so the metadata range and
|
||||
// link-local stay unreachable even for a granted name (v1.39.3). Enforced
|
||||
// at write-time in /api/user/ai-provider too.
|
||||
const egress = aiEgressPolicyFor(url);
|
||||
const egress = aiEgressPolicyFor(url, {
|
||||
operatorTrusted: this.config.operatorTrusted,
|
||||
});
|
||||
const res = await safeFetch(
|
||||
url,
|
||||
{
|
||||
@@ -336,7 +345,9 @@ export class LocalOpenAICompatibleClient implements AIProvider {
|
||||
onDelta: (delta: string) => void,
|
||||
): Promise<CompletionResult> {
|
||||
const { url, headers, body } = this.buildRequest(params, true);
|
||||
const egress = aiEgressPolicyFor(url);
|
||||
const egress = aiEgressPolicyFor(url, {
|
||||
operatorTrusted: this.config.operatorTrusted,
|
||||
});
|
||||
|
||||
// Idle controller: aborts the upstream when no chunk arrives within the
|
||||
// per-idle window. Composed with the caller's cancel signal so a client
|
||||
|
||||
@@ -18,12 +18,20 @@
|
||||
* time, and a literal in those ranges cannot be granted at all. Nothing in a
|
||||
* request or a settings field can confer a grant.
|
||||
*
|
||||
* `ALLOW_LOCAL_AI_PRIVATE_HOSTS` (v1.18.7) stays readable for the one form that
|
||||
* can be made safe: a comma-separated host list (`ollama.lan,10.0.0.5`) still
|
||||
* grants those exact hosts, on any port, dialled the same pinned way. Its
|
||||
* `=true` form granted every private host on the network to every user and
|
||||
* cannot be made safe, so from v1.39.3 it grants nothing; the boot readiness
|
||||
* summary and a one-time warning say so and name the replacement.
|
||||
* `ALLOW_LOCAL_AI_PRIVATE_HOSTS` (v1.18.7) stays readable. A comma-separated
|
||||
* host list (`ollama.lan,10.0.0.5`) still grants those exact hosts, on any
|
||||
* port, dialled the same pinned way.
|
||||
*
|
||||
* Its `=true` form used to open every private host to every user of the
|
||||
* instance. From v1.39.3 it is deprecated and narrowed for one release cycle
|
||||
* rather than removed, so a single-user self-host with Ollama on the LAN keeps
|
||||
* working after a patch update: it still grants a private host, but only to
|
||||
* the configurations the operator owns (the instance-wide admin provider, and
|
||||
* provider settings saved on an admin account), and only through the same
|
||||
* pinned dispatcher, so the metadata range, link-local and the unspecified
|
||||
* address stay unreachable. A non-admin account's own base URL needs an exact
|
||||
* origin in `AI_PRIVATE_ORIGINS`. The boot summary and the admin AI settings
|
||||
* page say so and name the origins in use.
|
||||
*/
|
||||
import { isIP } from "node:net";
|
||||
|
||||
@@ -32,15 +40,27 @@ import {
|
||||
originOfUrl,
|
||||
parsePrivateOrigins,
|
||||
} from "@/lib/private-origin-policy";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
|
||||
const ORIGINS_ENV = "AI_PRIVATE_ORIGINS";
|
||||
const LEGACY_ENV = "ALLOW_LOCAL_AI_PRIVATE_HOSTS";
|
||||
|
||||
/** The operator-facing sentence for a legacy `=true`. Shared with the boot summary. */
|
||||
export const LEGACY_ANY_HOST_WARNING =
|
||||
`${LEGACY_ENV}=true no longer opens every private host (it let any user ` +
|
||||
`reach any service on the operator's network). List the AI endpoint ` +
|
||||
`instead: ${ORIGINS_ENV}="http://ollama.lan:11434".`;
|
||||
`${LEGACY_ENV}=true is deprecated, set ${ORIGINS_ENV}=<origin> ` +
|
||||
`(for example ${ORIGINS_ENV}="http://ollama.lan:11434"); =true will be ` +
|
||||
`removed in a later release. Until then it only covers the admin AI ` +
|
||||
`provider and AI settings saved on an admin account.`;
|
||||
|
||||
/**
|
||||
* Who a base URL belongs to. `operatorTrusted` is true for the instance-wide
|
||||
* admin provider and for provider settings saved on an admin account: the
|
||||
* configurations the deprecated `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` still
|
||||
* covers. Everything else (any non-admin account's own base URL) is not.
|
||||
*/
|
||||
export interface AiUrlOwner {
|
||||
operatorTrusted?: boolean;
|
||||
}
|
||||
|
||||
interface Grants {
|
||||
origins: ReadonlySet<string>;
|
||||
@@ -67,6 +87,8 @@ export function legacyAnyHostConfigured(
|
||||
function parseLegacyHosts(raw: string | undefined): ReadonlySet<string> {
|
||||
const value = raw?.trim() ?? "";
|
||||
if (!value || value.toLowerCase() === "false") return new Set();
|
||||
// `true` is handled per call in `grantedAiOrigin` (operator-owned
|
||||
// configurations only); it is not a host list.
|
||||
if (value.toLowerCase() === "true") {
|
||||
warnOnce(LEGACY_ANY_HOST_WARNING);
|
||||
return new Set();
|
||||
@@ -117,7 +139,10 @@ function grants(): Grants {
|
||||
* legacy host list. A host no grant may name is never granted, whatever the
|
||||
* lists say.
|
||||
*/
|
||||
export function grantedAiOrigin(url: string): string | null {
|
||||
export function grantedAiOrigin(
|
||||
url: string,
|
||||
owner: AiUrlOwner = {},
|
||||
): string | null {
|
||||
// Parse the grants first, so a malformed entry is reported on the first
|
||||
// call that could have used it rather than never.
|
||||
const { origins, legacyHosts } = grants();
|
||||
@@ -129,6 +154,16 @@ export function grantedAiOrigin(url: string): string | null {
|
||||
// dropped by the pinned dispatcher at dial time.
|
||||
if (origins.has(origin)) return origin;
|
||||
if (legacyHosts.has(hostname.replace(/^\[|\]$/g, ""))) return origin;
|
||||
// Deprecated `=true`: an operator-owned configuration only, and never a
|
||||
// literal no grant may name. A name that resolves into those ranges is
|
||||
// dropped by the pinned dispatcher at dial time.
|
||||
if (
|
||||
owner.operatorTrusted === true &&
|
||||
legacyAnyHostConfigured() &&
|
||||
!isNeverGrantableHost(hostname)
|
||||
) {
|
||||
return origin;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -137,8 +172,11 @@ export function grantedAiOrigin(url: string): string | null {
|
||||
* host be saved and called?", never "is it public?": callers still accept a
|
||||
* public URL without any grant.
|
||||
*/
|
||||
export function isLocalAiHostAllowed(url: string): boolean {
|
||||
return grantedAiOrigin(url) !== null;
|
||||
export function isLocalAiHostAllowed(
|
||||
url: string,
|
||||
owner: AiUrlOwner = {},
|
||||
): boolean {
|
||||
return grantedAiOrigin(url, owner) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,15 +187,52 @@ export function isLocalAiHostAllowed(url: string): boolean {
|
||||
*/
|
||||
export function aiEgressPolicyFor(
|
||||
url: string,
|
||||
owner: AiUrlOwner = {},
|
||||
):
|
||||
| { requirePublicHost: true }
|
||||
| { requirePublicHost: false; operatorApprovedPrivateOrigin: string } {
|
||||
const origin = grantedAiOrigin(url);
|
||||
const origin = grantedAiOrigin(url, owner);
|
||||
return origin === null
|
||||
? { requirePublicHost: true }
|
||||
: { requirePublicHost: false, operatorApprovedPrivateOrigin: origin };
|
||||
}
|
||||
|
||||
/**
|
||||
* Host names that only resolve on a local network. `isPublicUrl` judges the
|
||||
* text of a name, not where it resolves, so `ollama.lan` reads as public to it
|
||||
* while the connect-time pin would refuse it.
|
||||
*/
|
||||
const LAN_SUFFIXES = [
|
||||
".lan",
|
||||
".local",
|
||||
".home",
|
||||
".home.arpa",
|
||||
".internal",
|
||||
".intranet",
|
||||
".localdomain",
|
||||
".corp",
|
||||
];
|
||||
|
||||
/**
|
||||
* The origin an operator would add to `AI_PRIVATE_ORIGINS` for this saved
|
||||
* base URL, or null when it needs none: a hosted endpoint, an address no grant
|
||||
* can open, an origin the exact list or the legacy host list already covers,
|
||||
* or an unparseable value. Used by the admin AI settings page to name what the
|
||||
* deprecated `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` is currently standing in for.
|
||||
*/
|
||||
export function originNeedingAiGrant(url: string): string | null {
|
||||
const origin = originOfUrl(url);
|
||||
if (!origin) return null;
|
||||
const hostname = new URL(origin).hostname.toLowerCase();
|
||||
if (isNeverGrantableHost(hostname)) return null;
|
||||
const lanName =
|
||||
!hostname.includes(".") ||
|
||||
LAN_SUFFIXES.some((suffix) => hostname.endsWith(suffix));
|
||||
if (isPublicUrl(url) && !lanName) return null;
|
||||
if (grantedAiOrigin(url) !== null) return null;
|
||||
return origin;
|
||||
}
|
||||
|
||||
/** Test helper: forget the parsed grants and the warnings already printed. */
|
||||
export function _resetAiGrantsForTests(): void {
|
||||
cachedKey = null;
|
||||
|
||||
@@ -46,6 +46,13 @@ interface OpenAIClientConfig {
|
||||
* `codex` keeps the fully pinned posture.
|
||||
*/
|
||||
providerType?: OpenAIProviderType;
|
||||
/**
|
||||
* v1.39.3 — true when the base URL belongs to the operator: the
|
||||
* instance-wide admin provider, or provider settings saved on an admin
|
||||
* account. Only such a URL is still covered by the deprecated
|
||||
* `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` (see `local-host-allowlist.ts`).
|
||||
*/
|
||||
operatorTrusted?: boolean;
|
||||
}
|
||||
|
||||
type OpenAIProviderType = "admin-key" | "codex" | "openai-compatible";
|
||||
@@ -169,7 +176,9 @@ export class OpenAIClient implements AIProvider {
|
||||
{
|
||||
timeoutMs: params.timeoutMs ?? 60_000,
|
||||
...(this.isGateway || this.type === "admin-key"
|
||||
? aiEgressPolicyFor(url)
|
||||
? aiEgressPolicyFor(url, {
|
||||
operatorTrusted: this.config.operatorTrusted,
|
||||
})
|
||||
: { requirePublicHost: true as const }),
|
||||
signal: params.signal,
|
||||
},
|
||||
|
||||
+32
-2
@@ -55,8 +55,20 @@ type UserAIRow = {
|
||||
aiCompatBaseUrl: string | null;
|
||||
aiCompatKeyEncrypted: string | null;
|
||||
aiCompatModel: string | null;
|
||||
/**
|
||||
* v1.39.3 — the owning account's role. Provider settings saved on an admin
|
||||
* account are the operator's own, and only those (plus the instance-wide
|
||||
* admin provider) are still covered by the deprecated
|
||||
* `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true`.
|
||||
*/
|
||||
role: string;
|
||||
};
|
||||
|
||||
/** v1.39.3 — see `UserAIRow.role`. */
|
||||
function isOperatorOwned(row: { role: string } | null | undefined): boolean {
|
||||
return row?.role === "ADMIN";
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.33.1 (#470) — build the OpenAI-compatible gateway client from the user's
|
||||
* three dedicated columns. The signature names every column this provider is
|
||||
@@ -83,6 +95,7 @@ function buildCompatProvider(row: {
|
||||
aiCompatBaseUrl: string | null;
|
||||
aiCompatKeyEncrypted: string | null;
|
||||
aiCompatModel: string | null;
|
||||
role: string;
|
||||
}): AIProvider | null {
|
||||
const model = row.aiCompatModel?.trim() || row.aiModel?.trim() || null;
|
||||
if (!row.aiCompatBaseUrl || !model) return null;
|
||||
@@ -91,6 +104,7 @@ function buildCompatProvider(row: {
|
||||
model,
|
||||
baseUrl: row.aiCompatBaseUrl,
|
||||
providerType: "openai-compatible",
|
||||
operatorTrusted: isOperatorOwned(row),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -122,6 +136,7 @@ function buildUserProvider(row: UserAIRow): AIProvider | null {
|
||||
: null,
|
||||
model: row.aiModel ?? "local-model",
|
||||
baseUrl: row.aiBaseUrl,
|
||||
operatorTrusted: isOperatorOwned(row),
|
||||
});
|
||||
}
|
||||
case "OPENAI_COMPATIBLE": {
|
||||
@@ -212,6 +227,8 @@ async function resolveAdminProvider(): Promise<AIProvider> {
|
||||
apiKey: decrypt(settings.adminAiKeyEncrypted),
|
||||
model: settings.adminAiModel ?? "gpt-4o",
|
||||
baseUrl,
|
||||
// The instance-wide provider is the operator's own configuration.
|
||||
operatorTrusted: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -480,6 +497,7 @@ export async function resolveProvider(userId: string): Promise<AIProvider> {
|
||||
aiCompatBaseUrl: true,
|
||||
aiCompatKeyEncrypted: true,
|
||||
aiCompatModel: true,
|
||||
role: true,
|
||||
managedProfileAt: true,
|
||||
},
|
||||
});
|
||||
@@ -548,6 +566,7 @@ export async function resolveProviderChain(
|
||||
aiCompatBaseUrl: true,
|
||||
aiCompatKeyEncrypted: true,
|
||||
aiCompatModel: true,
|
||||
role: true,
|
||||
aiProviderChain: true,
|
||||
useCentralCodex: true,
|
||||
managedProfileAt: true,
|
||||
@@ -1023,6 +1042,7 @@ async function resolveProviderForType(
|
||||
aiCompatBaseUrl: string | null;
|
||||
aiCompatKeyEncrypted: string | null;
|
||||
aiCompatModel: string | null;
|
||||
role: string;
|
||||
} | null;
|
||||
policy: ProviderCredentialPolicy;
|
||||
},
|
||||
@@ -1062,6 +1082,7 @@ async function resolveProviderForType(
|
||||
: null,
|
||||
model: ctx.userRow.aiModel ?? "local-model",
|
||||
baseUrl: ctx.userRow.aiBaseUrl,
|
||||
operatorTrusted: isOperatorOwned(ctx.userRow),
|
||||
});
|
||||
}
|
||||
case "openai-compatible": {
|
||||
@@ -1138,8 +1159,12 @@ export async function resolveProviderForTest(
|
||||
aiCompatBaseUrl: true,
|
||||
aiCompatKeyEncrypted: true,
|
||||
aiCompatModel: true,
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
// v1.39.3 — an unsaved override is tested under the same owner rule as a
|
||||
// saved one: it belongs to the calling account.
|
||||
const owner = { operatorTrusted: isOperatorOwned(stored) };
|
||||
|
||||
const provider = (override.provider ?? stored?.aiProvider ?? "")
|
||||
.toString()
|
||||
@@ -1191,7 +1216,7 @@ export async function resolveProviderForTest(
|
||||
}
|
||||
// A private host only when the operator granted its origin
|
||||
// (`AI_PRIVATE_ORIGINS` or the legacy host list; v1.39.3).
|
||||
const allowPrivate = isLocalAiHostAllowed(baseUrl);
|
||||
const allowPrivate = isLocalAiHostAllowed(baseUrl, owner);
|
||||
if (!allowPrivate && !isPublicUrl(baseUrl)) {
|
||||
throw new AITestConfigError(
|
||||
422,
|
||||
@@ -1207,6 +1232,7 @@ export async function resolveProviderForTest(
|
||||
apiKey,
|
||||
model: model || "local-model",
|
||||
baseUrl,
|
||||
...owner,
|
||||
});
|
||||
}
|
||||
case "OPENAI_COMPATIBLE": {
|
||||
@@ -1227,7 +1253,10 @@ export async function resolveProviderForTest(
|
||||
"OpenAI-compatible provider requires a base URL",
|
||||
);
|
||||
}
|
||||
if (!isLocalAiHostAllowed(compatBaseUrl) && !isPublicUrl(compatBaseUrl)) {
|
||||
if (
|
||||
!isLocalAiHostAllowed(compatBaseUrl, owner) &&
|
||||
!isPublicUrl(compatBaseUrl)
|
||||
) {
|
||||
throw new AITestConfigError(
|
||||
422,
|
||||
"Base URL points to an internal/private host",
|
||||
@@ -1253,6 +1282,7 @@ export async function resolveProviderForTest(
|
||||
model: compatModel,
|
||||
baseUrl: compatBaseUrl,
|
||||
providerType: "openai-compatible",
|
||||
...owner,
|
||||
});
|
||||
}
|
||||
case "CHATGPT_OAUTH": {
|
||||
|
||||
@@ -126,11 +126,11 @@ export function collectReadiness(env: Env = process.env): ReadinessReport {
|
||||
: "Secure cookie flag OFF — fine for plain-HTTP LAN; do NOT expose plain HTTP to the internet",
|
||||
});
|
||||
|
||||
// --- Retired AI grant (a warning, never a blocker) ----------------------
|
||||
// --- Deprecated AI grant (a warning, never a blocker) -------------------
|
||||
// `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` used to open every private host on
|
||||
// the network to every user's AI base URL. It grants nothing since
|
||||
// v1.39.3; an operator who still has it set has a local AI endpoint that
|
||||
// just stopped answering, and this is the line that says why.
|
||||
// the network to every user's AI base URL. Since v1.39.3 it only covers the
|
||||
// operator's own AI configurations and is on its way out; this line names
|
||||
// the replacement. The admin AI settings page lists the origins in use.
|
||||
if (legacyAnyHostConfigured(env)) {
|
||||
lines.push({
|
||||
label: "ALLOW_LOCAL_AI_PRIVATE_HOSTS",
|
||||
|
||||
Reference in new issue
Block a user