ADR 0012 β Local AI server connectivity: route localhost HTTP through the Tauri HTTP plugin (web stays fetch)
- Status: Accepted
- Date: 2026-07-25
- Deciders: Maintainer + Kimi Code
- Context tags: ai, ollama, lm-studio, tauri, cors, csp, private-network-access
- Fixes: #266 Β· extends ADR 0004
WorldScript Studio can use server-grade local models through the Ollama provider and can scan
for local OpenAI-compatible servers (Ollama :11434, LM Studio :1234, vLLM :8000). Two failure
modes were reported in #266:
- Desktop build (.deb / Tauri) does not see Ollama or LM Studio even though both are running.
- The installed PWA logs loud CORS errors against
localhost:11434even whenOLLAMA_ORIGINSis configured.
services/ollamaService.ts(testOllamaConnection,listOllamaModels,streamOllama,pullOllamaModel) andscanLocalOpenAiCompatibleEndpoints()(services/aiProviderService.ts) all used the browserfetchβ including inside the Tauri WebView.- The Tauri WebView origin is
http://tauri.localhost(Linux/Windows) resp.tauri://localhost(macOS). Requests tohttp://localhost:11434etc. are therefore cross-origin. Ollama sends noAccess-Control-Allow-Originheader unless the server is started withOLLAMA_ORIGINScovering the WebView origin β the WebView then blocks the response (CORS). LM Studio and vLLM behave similarly depending on their own CORS configuration. WebView2/WKWebView additionally apply Private-Network-Access / mixed-content style restrictions tolocalhosttargets. - The Tauri CSP (
src-tauri/tauri.conf.jsonconnect-src) already listed all three ports forlocalhostand127.0.0.1. CSP was never the blocker β CORS was. CSP can only forbid a connection; it can never permit a cross-origin read that the server does not allow. - In the PWA,
AiProviderCard's auto-effect firedlistOllamaModels()directly (bypassing the desktop-only guard insidetestAIConnection) whenever the Ollama provider was selected. Each settings visit produced CORS preflight failures in the console.OLLAMA_ORIGINScan silence this only if the PWA origin is explicitly listed β but the browser path is desktop-only by policy anyway, so auto-probing localhost from the PWA is simply wrong. @tauri-apps/plugin-http(JS) andtauri-plugin-http(Rust) were already dependencies, and thehttp:defaultcapability was already granted β but nothing on the Ollama path used them. The plugin executes requests via the native Rust HTTP stack (reqwest), which is not subject to WebView CORS or PNA rules at all.
- Introduce a thin runtime-aware HTTP layer (
services/localServerHttp.ts):- Under
isTauriRuntime()(canonical__TAURI_INTERNALS__-aware detection, seeservices/tauriRuntime.ts) it dynamically imports@tauri-apps/plugin-httpand uses itsfetchβ native, CORS-free. - Otherwise it uses the global
fetch. The dynamic import keeps the web bundle lean and matches the existing@tauri-apps/*externalization for web builds. - The layer owns base-URL normalization (trailing-slash strip,
localhost:11434default), timeout composition (AbortSignal.timeoutmerged with the caller's signal viaAbortSignal.any), and error classification (unreachable|timeoutvs. userAbortError, which is rethrown unchanged so cancel-vs-failure stays distinguishable).
- Under
- Route all local-server traffic through it: all four
ollamaServicefunctions andscanLocalOpenAiCompatibleEndpoints(). Public signatures and legacy error strings are preserved so orchestration-layer contracts (singleonErrorfiring, AbortError propagation) are untouched. - Classified scan results: the scan returns a
state(ok|unreachable|timeout|http) plus the legacy numericstatus, so the UI can render actionable badges instead of a bare "no response". - PWA stays strictly desktop-only (product decision): no reachability probing from the
browser. The
AiProviderCardauto-effect and model loading are gated onisTauriRuntime(), so the PWA performs zero localhost requests β the CORS console noise disappears by construction. Instead the PWA shows a quiet banner explaining the restriction plus a "Download the desktop app" CTA. - CSP is explicit and runtime-checked. The WebView CSP is bypassed for plugin-http traffic
(native stack), but
localServerFetchstill checks every native and browser endpoint against the sharedconfig/csp-connect-src.jsonpolicy before transport. Tauri capability scope remains a second native enforcement boundary. Unlisted BYOK origins and local endpoints fail with an actionable policy error rather than an opaque network error. The same origin source generates the web headers and Tauri CSP; arbitrary BYOK origins require an explicit policy update. - Capability scope is pinned explicitly in
src-tauri/capabilities/default.json. Audit finding during this work:http:defaultalone grants no URL scope at all (the plugin'sScope::is_allowedrequires a matching allow entry), so every plugin-http call β including the existing AI-SDKfetchAdaptercloud calls on desktop β was silently denied. The scope now allowshttp://localhost:*/*+http://127.0.0.1:*/*as a defense-in-depth envelope plus the enumerated cloud endpoints that mirror the Tauri CSPconnect-src(Gemini, OpenAI, x.ai, OpenRouter, Groq). The shared runtime policy narrows local requests to the explicitly supported ports before plugin-http is called. Known limitation (status quo, unchanged): LAN-IP servers (http://192.168.β¦) and arbitrary BYOK cloud base URLs remain outside the scope; widening is a separate, deliberate decision.
- Positive: Desktop Ollama/LM Studio/vLLM discovery and inference work out of the box β no
OLLAMA_ORIGINSsetup required on desktop. PWA console stays clean. Timeouts, aborts, and error classes are consistent across all local-server calls. Scan results are actionable (per-endpoint badge + one-click "use this URL"). - Negative / accepted: A second HTTP code path exists (plugin vs. browser fetch). It is
isolated in one ~100-line module and covered by unit tests that mock both branches.
streamOllama/pullOllamaModelrely on the plugin's streamingResponse.bodyreader β covered by mocked-reader tests; a native smoke check rides on thetauri-build.ymlworkflow dispatch. - Security posture: unchanged-or-better. No new cloud endpoints, no secrets, localhost-only capability scope, privacy-first (no call happens without a user-visible provider selection or button press).
- Document
OLLAMA_ORIGINS=tauri://localhost,http://tauri.localhostas the fix β rejected: pushes setup burden onto every desktop user, still breaks on LM Studio/vLLM CORS configs, and does nothing for PNA/mixed-content quirks in WebView2/WKWebView. - Rust command wrapper (
invoke) for Ollama β rejected: duplicates an already-shipped plugin, adds Rust surface for zero capability gain; plugin-http is the canonical Tauri v2 answer. - Allow PWA status-only probing with Private Network Access permission β rejected (product decision): keeps CORS noise possible, adds a second-class UX path, and conflicts with the privacy-first "desktop-only" policy for localhost servers.
This ADR's decision (route local-server HTTP through @tauri-apps/plugin-http) was correct, but
the packaged desktop build never actually exercised it. vite.config.ts's rollupOptions.external
unconditionally externalized every @tauri-apps/* package from every vite build, including
the exact build Tauri's beforeBuildCommand invokes to produce the .deb/.msi. Since
services/localServerHttp.ts's @tauri-apps/plugin-http import is dynamic (await import(...)),
externalizing it left an unresolvable bare module specifier in the shipped bundle β confirmed with
a real build + real-Chromium repro, producing TypeError: Failed to resolve module specifier '@tauri-apps/plugin-http' the instant resolveFetch() ran, before any network request. Every
caller's catch classified this identically to a genuinely-down server, matching the exact symptom
reported on issue #266 after #269 merged: no CORS console noise (nothing reached the network
layer), and no Ollama/LM Studio discovery despite both running.
Root cause: resolveViteBase.ts already had the right Tauri-vs-web build detection (via
TAURI_ENV_PLATFORM/TAURI_PLATFORM), used for the base config, but rollupOptions.external
was never given the same treatment. tauri dev was unaffected (Vite's dev server doesn't apply
rollupOptions), so the regression only surfaced in packaged builds β and the unit test suite
mocks @tauri-apps/plugin-http via vi.mock, which bypasses real module resolution entirely and
structurally cannot catch this class of bug.
Fix: extracted the Tauri-build check into a shared isTauriBuild() export in resolveViteBase.ts
and made rollupOptions.external conditional on it β the desktop build now bundles
@tauri-apps/plugin-http correctly; the web/PWA build is unaffected (those code paths are gated
by isTauriRuntime() and never exercised there). services/localServerHttp.ts's resolveFetch()
also now wraps the dynamic import in its own try/catch, classifying a load failure as a distinct
LocalServerError('plugin_unavailable') and logging it, so a future regression of this class fails
loudly and distinctly instead of silently misclassifying as "unreachable."
[[0017-pwa-browser-ollama-opt-in]] adds an explicit, default-off enableBrowserOllama flag
letting a user who has separately configured their own Ollama server's OLLAMA_ORIGINS attempt a
direct browser fetch from the web/PWA build. This is a narrow widening, not a reversal: the default
behavior described in decision #4 above β the PWA performs zero localhost requests unless a user
explicitly opts in β is unchanged, and the "Allow PWA status-only probing with Private Network
Access permission" alternative rejected above stays rejected (0017 doesn't use PNA at all; it relies
on real, server-configured CORS instead).