diff --git a/app/src/App.tsx b/app/src/App.tsx index 53dc4af..21ec819 100755 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -498,6 +498,7 @@ const TRANSLATIONS = { settings_model_desc: "Qwen3.8-27B runs locally by default — your structures and conversations never leave this machine. Add a cloud API key below only if you want to switch to OpenAI, Claude, or Gemini for a chat.", settings_model_key_placeholder: "Not set", settings_model_key_hint: "Stored locally on this machine only — a key is only sent to the provider it belongs to, never anywhere else.", + settings_model_key_unavailable_shared: "Not available in the web app — download the local version to use your own API key.", settings_databases_heading: "Databases", settings_databases_desc: "Materials Project needs a free API key to search. Get one at materialsproject.org/api.", settings_compute_heading: "Compute", @@ -566,6 +567,7 @@ const TRANSLATIONS = { settings_model_desc: "Qwen3.8-27B s'exécute localement par défaut — vos structures et conversations ne quittent jamais cette machine. Ajoutez une clé API cloud ci-dessous uniquement si vous souhaitez utiliser OpenAI, Claude ou Gemini pour une conversation.", settings_model_key_placeholder: "Non définie", settings_model_key_hint: "Stockée uniquement en local sur cette machine — une clé n'est envoyée qu'au fournisseur auquel elle appartient, jamais ailleurs.", + settings_model_key_unavailable_shared: "Non disponible dans l'application web — téléchargez la version locale pour utiliser votre propre clé API.", settings_databases_heading: "Bases de données", settings_databases_desc: "Materials Project nécessite une clé API gratuite pour la recherche. Obtenez-en une sur materialsproject.org/api.", settings_compute_heading: "Calcul", @@ -634,6 +636,7 @@ const TRANSLATIONS = { settings_model_desc: "Qwen3.8-27B läuft standardmäßig lokal — Ihre Strukturen und Unterhaltungen verlassen diesen Rechner nie. Fügen Sie unten nur dann einen Cloud-API-Schlüssel hinzu, wenn Sie für einen Chat zu OpenAI, Claude oder Gemini wechseln möchten.", settings_model_key_placeholder: "Nicht festgelegt", settings_model_key_hint: "Wird nur lokal auf diesem Rechner gespeichert — ein Schlüssel wird ausschließlich an den zugehörigen Anbieter gesendet, niemals anderswohin.", + settings_model_key_unavailable_shared: "In der Web-App nicht verfügbar — laden Sie die lokale Version herunter, um Ihren eigenen API-Schlüssel zu verwenden.", settings_databases_heading: "Datenbanken", settings_databases_desc: "Materials Project benötigt einen kostenlosen API-Schlüssel für die Suche. Holen Sie sich einen unter materialsproject.org/api.", settings_compute_heading: "Rechenressourcen", @@ -702,6 +705,7 @@ const TRANSLATIONS = { settings_model_desc: "Qwen3.8-27B 默认在本地运行——你的结构和对话内容不会离开这台机器。只有当你想切换到 OpenAI、Claude 或 Gemini 进行对话时,才需要在下方添加云端 API 密钥。", settings_model_key_placeholder: "未设置", settings_model_key_hint: "仅保存在本机——密钥只会发送给其所属的服务商,绝不会发往其他任何地方。", + settings_model_key_unavailable_shared: "网页版不支持——如需使用自己的 API 密钥,请下载本地版。", settings_databases_heading: "数据库", settings_databases_desc: "搜索 Materials Project 需要一个免费的 API key,可以在 materialsproject.org/api 申请。", settings_compute_heading: "计算", @@ -770,6 +774,7 @@ const TRANSLATIONS = { settings_model_desc: "Qwen3.8-27B viene eseguito localmente per impostazione predefinita — le tue strutture e conversazioni non lasciano mai questa macchina. Aggiungi una chiave API cloud qui sotto solo se vuoi passare a OpenAI, Claude o Gemini per una chat.", settings_model_key_placeholder: "Non impostata", settings_model_key_hint: "Salvata solo localmente su questa macchina — una chiave viene inviata solo al fornitore a cui appartiene, mai altrove.", + settings_model_key_unavailable_shared: "Non disponibile nell'app web — scarica la versione locale per usare la tua chiave API.", settings_databases_heading: "Banche dati", settings_databases_desc: "Materials Project richiede una chiave API gratuita per la ricerca. Ottienine una su materialsproject.org/api.", settings_compute_heading: "Calcolo", @@ -1677,7 +1682,13 @@ export default function App() { const [newProjectChatTitle, setNewProjectChatTitle] = useState(""); const [newProjectWithSources, setNewProjectWithSources] = useState(false); const [newProjectSourcesText, setNewProjectSourcesText] = useState(""); - const [selectedModel, setSelectedModel] = useState(MODEL_GROUPS[0].items[0]); + // Web app is LLM-locked to the local model regardless of selection + // (resolve_model()'s GOLDILOCKS_AGENT_MODEL env-var override always wins + // server-side) -- defaulting to Claude there would misrepresent which + // model actually answers. + const [selectedModel, setSelectedModel] = useState( + SHARED_DEPLOYMENT ? MODEL_GROUPS[1].items[0] : MODEL_GROUPS[0].items[0], + ); // Ephemeral UI-only state (which dropdown is open, is a calc in flight) -- // fine to stay global, same as structure-search's own loading flag/open // dropdown state. Everything else MLIP-related lives in @@ -1917,12 +1928,12 @@ export default function App() { }, [resolvedTheme]); useEffect(() => { - refreshConfiguredProviders(); - // Projects/history/experience-level are all single-local-user concepts - // with no per-user isolation on the server (design doc §19.2) -- the - // shared deployment's backend 404s these routes anyway, so skip calling - // them at all rather than surfacing that as a startup error. + // Projects/history/experience-level/credentials are all single-local-user + // concepts with no per-user isolation on the server (design doc §19.2) -- + // the shared deployment's backend 404s/rejects these routes anyway, so + // skip calling them at all rather than surfacing that as a startup error. if (!SHARED_DEPLOYMENT) { + refreshConfiguredProviders(); refreshProjects(); loadConversationsOnce(); hydrateExperienceLevel(); @@ -2591,6 +2602,28 @@ export default function App() { // (to peek at it, to test something) used to commit whatever was typed the // moment focus left, silently overwriting a real saved key with no undo. function renderCredentialRow(id, label) { + if (SHARED_DEPLOYMENT) { + // Server-side, /api/credentials rejects writes here too (server.py) -- + // this deployment is LLM-locked to the fixed local model regardless of + // what's typed here (resolve_model()'s env-var override always wins). + // Shown disabled rather than hidden so visitors can see cloud models + // exist and that the web app isn't where you'd use your own key. + return ( +
+ {label} + + +
+ ); + } const canSave = Boolean(cloudApiKeys[id]?.trim()) && credentialStatus[id] !== "saving"; return (
@@ -9295,33 +9328,46 @@ export default function App() { {MODEL_GROUPS.map((group, groupIndex) => (
{groupIndex > 0 &&
} -
{group.group}
- {group.items.map((model) => ( -
{ - if (model.disabled) return; - setSelectedModel(model); - setModelOpen(false); - }} - > -
- -
-
- {model.label} - {model.disabled ? ( - {model.desc} - ) : model.tag !== "default" && configuredProviders[model.tag] ? ( - ✓ API key saved — ready to use - ) : ( - {model.desc} - )} +
+ {group.group} + {SHARED_DEPLOYMENT && groupIndex === 0 ? " — unavailable in web app" : ""} +
+ {group.items.map((model) => { + // Cloud items stay visible (so visitors know they + // exist) but unselectable here -- same reasoning + // as the Settings credential rows: no safe way to + // bring your own cloud key to a shared server. + const lockedByDeployment = SHARED_DEPLOYMENT && model.tag !== "default"; + const isDisabled = model.disabled || lockedByDeployment; + return ( +
{ + if (isDisabled) return; + setSelectedModel(model); + setModelOpen(false); + }} + > +
+ +
+
+ {model.label} + {lockedByDeployment ? ( + Unavailable in the web app + ) : model.disabled ? ( + {model.desc} + ) : model.tag !== "default" && configuredProviders[model.tag] ? ( + ✓ API key saved — ready to use + ) : ( + {model.desc} + )} +
-
- ))} + ); + })}
))}
diff --git a/src/goldilocks_agent/server.py b/src/goldilocks_agent/server.py index 029cf5e..17f44fc 100644 --- a/src/goldilocks_agent/server.py +++ b/src/goldilocks_agent/server.py @@ -346,6 +346,17 @@ async def get_chat_history(thread_id: str) -> dict: @app.post("/api/credentials") async def save_credential(request: CredentialRequest) -> dict: + # `~/.config/goldilocks/config.toml` is one file on the server, not a + # per-thread/per-user store (unlike the checkpointer) -- letting any + # visitor write to it in the shared deployment would mean the first + # person who opens Settings sets everyone else's provider key (their own + # account gets billed for the whole instance's traffic, silently). This + # deployment mode is LLM-locked to a fixed local model precisely so this + # can't come up (see resolve_model()'s env-var override) -- rejecting + # here too is defense in depth, not just the frontend happening not to + # show the field, same reasoning as `_reject_in_shared_deployment` below + # for projects/conversations. + _reject_in_shared_deployment() write_credential(request.provider, request.api_key) return {"ok": True} @@ -353,6 +364,7 @@ async def save_credential(request: CredentialRequest) -> dict: @app.get("/api/credentials") async def get_credentials_status() -> dict[str, bool]: """Presence per provider only -- never returns the key itself.""" + _reject_in_shared_deployment() return configured_providers() diff --git a/tests/test_server.py b/tests/test_server.py index 1ac813b..d527408 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -56,6 +56,22 @@ def test_shared_deployment_blocks_projects_and_conversations_routes( assert client.delete("/api/conversations/some-id").status_code == 404 +def test_shared_deployment_blocks_credentials_routes(tmp_path, monkeypatch) -> None: + """`~/.config/goldilocks/config.toml` is one file on the server, not a + per-thread/per-user store -- letting any visitor write to it on a shared + server would mean the first person who opens Settings sets everyone + else's provider key (their own account gets billed for the whole + instance's traffic, silently). Refused at the route, not just left + unused by the shared-mode frontend build.""" + monkeypatch.setenv("GOLDILOCKS_AGENT_SHARED_DEPLOYMENT", "1") + with _make_client(tmp_path, monkeypatch) as client: + assert client.get("/api/credentials").status_code == 404 + save_response = client.post( + "/api/credentials", json={"provider": "anthropic", "api_key": "sk-test"} + ) + assert save_response.status_code == 404 + + def test_shared_deployment_skips_conversation_indexing(tmp_path, monkeypatch) -> None: monkeypatch.setenv("GOLDILOCKS_AGENT_SHARED_DEPLOYMENT", "1") calls = []