diff --git a/desktop/src/electron/csp.ts b/desktop/src/electron/csp.ts
index 06414b71e..f7070d369 100644
--- a/desktop/src/electron/csp.ts
+++ b/desktop/src/electron/csp.ts
@@ -2,13 +2,14 @@
// SPDX-License-Identifier: Apache-2.0
export function buildCspHeader(): string {
- const connectSrc = ["'self'", 'http://127.0.0.1:*', 'http://localhost:*']
+ // https://cheatsheetseries.owasp.org/cheatsheets/Content_Security_Policy_Cheat_Sheet.html
+ // https://www.w3.org/TR/CSP/
return (
[
"default-src 'none'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
- `connect-src ${connectSrc.join(' ')}`,
+ "connect-src 'self'",
// `blob:` is needed so the SVG rasterizer can assign a blob URL
// to `` for decode (see `extract-svg.ts`). `data:` covers
// the inline previews we generate from base64 attachments.
@@ -18,10 +19,7 @@ export function buildCspHeader(): string {
// fallbacks only, so no remote font origin is needed.
"font-src 'self'",
"manifest-src 'self'",
- // `'self'` covers the Vite-served pdf.js worker module; `blob:`
- // is the pdf.js fallback when the worker module can't be loaded
- // directly (some Chromium builds spin up a blob-URL shim).
- "worker-src 'self' blob:",
+ "worker-src 'none'",
"object-src 'none'",
"frame-src 'none'",
"base-uri 'self'",
diff --git a/desktop/src/electron/model-hub/index.ts b/desktop/src/electron/model-hub/index.ts
index d1fda727c..ef6882e0b 100644
--- a/desktop/src/electron/model-hub/index.ts
+++ b/desktop/src/electron/model-hub/index.ts
@@ -2,8 +2,13 @@
// SPDX-License-Identifier: Apache-2.0
import type { EngineType } from '@/shared/types/engines'
-import type { EngineHubModel, EngineHubSearchResponse } from '@/shared/types/engine-api'
+import type {
+ EngineHubLookupResponse,
+ EngineHubModel,
+ EngineHubSearchResponse
+} from '@/shared/types/engine-api'
import { loadOllamaModels, type OllamaTagsModel } from '@/electron/model-hub/ollama-library'
+import { lookupOllamaModel } from '@/electron/model-hub/ollama-registry'
import {
lmStudioCatalogCache,
type LmStudioCatalogModel
@@ -15,11 +20,16 @@ function ollamaToHubModel(m: OllamaTagsModel): EngineHubModel {
id: m.name,
name: m.name,
author: '',
- url: `https://ollama.com/library/${base}`,
+ // A namespaced name addresses that namespace's page; a bare one is a
+ // first-party model under `library`.
+ url: base.includes('/')
+ ? `https://ollama.com/${base}`
+ : `https://ollama.com/library/${base}`,
size: m.size > 0 ? m.size : undefined,
downloads: 0,
likes: 0,
- updatedAt: m.modified_at || new Date().toISOString(),
+ // Empty means the source reported no date; the row omits its age.
+ updatedAt: m.modified_at,
tags: [],
family: m.details.family || undefined,
parameterSize: m.details.parameter_size || undefined
@@ -57,6 +67,30 @@ export async function getEngineHubModels(engineType: EngineType): Promise {
+ switch (engineType) {
+ case 'ollama': {
+ const found = await lookupOllamaModel(name)
+ return { model: found ? ollamaToHubModel(found) : null }
+ }
+ default:
+ return { model: null }
+ }
+}
+
/**
* Kick a background refresh of the live engine hub caches so the first modal
* open is instant. Ollama needs no warming (it is a committed static list);
diff --git a/desktop/src/electron/model-hub/ollama-registry.ts b/desktop/src/electron/model-hub/ollama-registry.ts
new file mode 100644
index 000000000..f8f96ee78
--- /dev/null
+++ b/desktop/src/electron/model-hub/ollama-registry.ts
@@ -0,0 +1,246 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import axios, { isAxiosError } from 'axios'
+import { createStructuredLogger } from '@/shared/utils/log'
+import getErrorString from '@/shared/utils/get-error-string'
+import type { OllamaTagsModel } from '@/electron/model-hub/ollama-library'
+import type { JsonValue } from '@/shared/types/json'
+
+const log = createStructuredLogger('ollama-registry')
+
+/**
+ * Resolve one Ollama model by exact name, for names the committed catalog
+ * snapshot (`ollama-models.json`) does not carry.
+ *
+ * `registry.ollama.ai` speaks the OCI distribution API that `ollama pull` uses,
+ * so this asks the same source the engine would. It only reads manifests: the
+ * registry answers 404 for `/tags/list`, so enumerating a catalog still needs
+ * the snapshot and a lookup is an exact-name check rather than a search.
+ */
+
+const REGISTRY_BASE = 'https://registry.ollama.ai/v2'
+const USER_AGENT = 'PAIR/1.0'
+
+/**
+ * Bounds the whole request rather than just the response. `axios`'s `timeout` is
+ * a socket timeout and does not start until a socket exists, so it misses a
+ * wedged DNS resolver; an `AbortSignal` covers every phase. `lmstudio-catalog.ts`
+ * carries the same reasoning at more length.
+ */
+const HTTP_TIMEOUT_MS = 8_000
+
+/** What `ollama pull ` resolves a bare name to. */
+const DEFAULT_TAG = 'latest'
+
+/** Ollama's namespace for first-party models, implied by a bare name. */
+const DEFAULT_NAMESPACE = 'library'
+
+/**
+ * Characters a repository segment or tag may contain. This is the only guard on
+ * what reaches the request URL, so it has to hold on its own: the renderer runs
+ * its own check before asking, but a lookup arrives over IPC and main-process
+ * code does not treat the renderer as trusted. Without it, `foo/..` walks out of
+ * the namespace and `foo/bar#z` truncates the path.
+ */
+const SAFE_SEGMENT = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/
+
+/**
+ * Positive and negative results both cached, because the hub looks names up as
+ * the user types and would otherwise re-request on every keystroke past a
+ * resolved name. Bounded so a long session cannot grow it without limit.
+ */
+const CACHE_LIMIT = 256
+const cache = new Map()
+
+function remember(key: string, value: OllamaTagsModel | null): OllamaTagsModel | null {
+ if (cache.size >= CACHE_LIMIT) {
+ // Map iteration is insertion-ordered, so the first key is the oldest.
+ const oldest = cache.keys().next()
+ if (!oldest.done) cache.delete(oldest.value)
+ }
+ cache.set(key, value)
+ return value
+}
+
+/**
+ * Split `[namespace/]name[:tag]` into a registry path and tag, or null for a
+ * reference this registry cannot answer.
+ *
+ * OCI clients read a dot in the first of *several* segments as a registry
+ * hostname, which is how `hf.co//` is recognized as someone else's
+ * registry and declined rather than reported as missing. That rule must not
+ * reach a lone segment: bare model names legitimately carry dots — `qwen3.8`,
+ * `llama3.1`, `phi3.5` — and are always first-party names under `library`.
+ */
+function parseReference(name: string): { path: string; tag: string } | null {
+ const colon = name.lastIndexOf(':')
+ const slash = name.lastIndexOf('/')
+ // A colon ahead of the last slash belongs to a host:port, not a tag.
+ const hasTag = colon > slash
+ const repository = hasTag ? name.slice(0, colon) : name
+ const tag = hasTag ? name.slice(colon + 1) : DEFAULT_TAG
+ if (!SAFE_SEGMENT.test(tag)) return null
+
+ const segments = repository.split('/')
+ if (!segments.every(segment => SAFE_SEGMENT.test(segment))) return null
+ if (segments.length === 1) return { path: `${DEFAULT_NAMESPACE}/${segments[0]}`, tag }
+ if (segments.length > 2 || segments[0].includes('.')) return null
+ return { path: repository, tag }
+}
+
+type JsonObject = { [key: string]: JsonValue }
+
+function isRecord(v: JsonValue | undefined): v is JsonObject {
+ return v !== null && v !== undefined && typeof v === 'object' && !Array.isArray(v)
+}
+
+function manifestLayers(raw: JsonValue): JsonObject[] {
+ if (!isRecord(raw) || !Array.isArray(raw.layers)) return []
+ return raw.layers.filter(isRecord)
+}
+
+/**
+ * Total bytes the engine downloads: every layer, not just the weights. The
+ * others (template, params, license) are a few kilobytes each, but the sum is
+ * what lands on disk.
+ */
+function totalLayerBytes(raw: JsonValue): number {
+ let total = 0
+ for (const layer of manifestLayers(raw)) {
+ if (typeof layer.size === 'number' && layer.size > 0) total += layer.size
+ }
+ return total
+}
+
+/**
+ * Capability chip for the row. `details.family` carries a capability rather than
+ * a model family here, because that is what the catalog scraper puts there and
+ * what the row renders.
+ *
+ * Vision is the only one a manifest proves: a multimodal model carries an extra
+ * `projector` layer for its vision encoder and nothing else does. Tools,
+ * thinking and embedding exist only on ollama.com's rendered page, so a model
+ * with those and no projector gets no chip instead of a wrong one.
+ */
+function capabilityFromLayers(raw: JsonValue): string {
+ const vision = manifestLayers(raw).some(
+ layer => layer.mediaType === 'application/vnd.ollama.image.projector'
+ )
+ return vision ? 'vision' : ''
+}
+
+function configDigest(raw: JsonValue): string {
+ if (!isRecord(raw)) return ''
+ const config = raw.config
+ if (!isRecord(config)) return ''
+ return typeof config.digest === 'string' ? config.digest : ''
+}
+
+/** The 12-character form Ollama shows in its own model lists. */
+function shortenDigest(digest: string): string {
+ return digest.replace(/^sha256:/, '').slice(0, 12)
+}
+
+/**
+ * What the model says about itself, read from the config blob the manifest
+ * points at. Worth the extra request because a tag like `27b-coding-mxfp8` is a
+ * label the publisher chose, while these are the real figures.
+ */
+interface OllamaModelConfig {
+ format: string
+ families: string[] | null
+ parameterSize: string
+ quantizationLevel: string
+}
+
+/** Resolves to null on any failure; the row still renders, with less on it. */
+async function fetchConfig(path: string, digest: string): Promise {
+ if (!digest) return null
+ try {
+ const { data } = await axios.get(`${REGISTRY_BASE}/${path}/blobs/${digest}`, {
+ headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' },
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
+ })
+ if (!isRecord(data)) return null
+ const families = Array.isArray(data.model_families)
+ ? data.model_families.filter((f): f is string => typeof f === 'string')
+ : []
+ return {
+ format: typeof data.model_format === 'string' ? data.model_format : '',
+ families: families.length > 0 ? families : null,
+ parameterSize: typeof data.model_type === 'string' ? data.model_type : '',
+ quantizationLevel: typeof data.file_type === 'string' ? data.file_type : ''
+ }
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Look one exact model name up in Ollama's registry.
+ *
+ * Null covers every "not available" case alike — a reference this registry does
+ * not serve, a model or tag that does not exist, a timeout, an offline machine.
+ * None of them surface as an error: the lookup is speculative, and the user
+ * asked to search rather than to make this request.
+ */
+export async function lookupOllamaModel(name: string): Promise {
+ const cached = cache.get(name)
+ if (cached !== undefined) return cached
+
+ const reference = parseReference(name)
+ if (!reference) return remember(name, null)
+
+ try {
+ const { data } = await axios.get(
+ `${REGISTRY_BASE}/${reference.path}/manifests/${reference.tag}`,
+ {
+ headers: {
+ 'User-Agent': USER_AGENT,
+ Accept: 'application/vnd.docker.distribution.manifest.v2+json'
+ },
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
+ }
+ )
+ const size = totalLayerBytes(data)
+ if (size === 0) return remember(name, null)
+
+ const digest = configDigest(data)
+ const config = await fetchConfig(reference.path, digest)
+
+ log.info({ sublevel: 'lookup', message: `resolved ${name} outside the catalog snapshot` })
+ return remember(name, {
+ name,
+ model: name,
+ // Neither the manifest nor its headers carry a publication date.
+ // Empty means unknown, and the row omits its age rather than
+ // claiming the model was updated just now.
+ modified_at: '',
+ size,
+ digest: shortenDigest(digest),
+ details: {
+ parent_model: '',
+ format: config?.format ?? '',
+ family: capabilityFromLayers(data),
+ families: config?.families ?? null,
+ // Falls back to the tag, which is what the catalog shows here.
+ parameter_size:
+ config?.parameterSize || (reference.tag === DEFAULT_TAG ? '' : reference.tag),
+ quantization_level: config?.quantizationLevel ?? ''
+ }
+ })
+ } catch (err) {
+ const status = isAxiosError(err) ? err.response?.status : undefined
+ // 404 is the ordinary answer for a name that does not exist.
+ // 404 is the ordinary answer for a name that does not exist, and is
+ // remembered. Anything else (a timeout, an offline machine, a 5xx) may
+ // pass, so it is not cached and the next search asks again.
+ if (status === 404) return remember(name, null)
+ log.warn({
+ sublevel: 'http',
+ message: `lookup for ${name} failed: ${status ?? ''} ${getErrorString(err)}`.trim()
+ })
+ return null
+ }
+}
diff --git a/desktop/src/electron/service-bridge/empty-handlers.ts b/desktop/src/electron/service-bridge/empty-handlers.ts
index 0bae0f5a5..1a4c6303d 100644
--- a/desktop/src/electron/service-bridge/empty-handlers.ts
+++ b/desktop/src/electron/service-bridge/empty-handlers.ts
@@ -20,7 +20,7 @@ import {
MODULAR_ENGINE_LIFECYCLE_CALL_TIMEOUT_MS
} from '@/shared/constants/modular-runtime'
import getErrorString from '@/shared/utils/get-error-string'
-import { getEngineHubModels } from '@/electron/model-hub'
+import { getEngineHubModels, lookupEngineHubModel } from '@/electron/model-hub'
import { getModularSupervisor } from './modular-supervisor'
import {
getModularBridgeState,
@@ -1180,6 +1180,8 @@ const EMPTY_SERVICE_BRIDGE_HANDLERS: BridgeHandlerMap = {
'engine:command': payload => handleEngineCommand(payload),
'engine:search-hub': payload =>
payload ? getEngineHubModels(payload.engineType) : { models: [] },
+ 'engine:lookup-hub-model': payload =>
+ payload ? lookupEngineHubModel(payload.engineType, payload.name) : { model: null },
'errors:get-initial': () => handleErrorsGetInitial(),
'errors:clear': payload => (payload ? handleErrorsClear(payload) : null),
diff --git a/desktop/src/shared/types/engine-api.ts b/desktop/src/shared/types/engine-api.ts
index 351f5c5be..208caf2ab 100644
--- a/desktop/src/shared/types/engine-api.ts
+++ b/desktop/src/shared/types/engine-api.ts
@@ -37,6 +37,15 @@ export interface EngineHubSearchResponse {
models: EngineHubModel[]
}
+/**
+ * Response for the `engine:lookup-hub-model` channel. `null` means the name is
+ * not available, whether because it does not exist, the engine has no registry
+ * to ask, or that registry could not be reached.
+ */
+export interface EngineHubLookupResponse {
+ model: EngineHubModel | null
+}
+
/** Engine command discriminator. */
export type EngineCommandType =
| 'toggle'
diff --git a/desktop/src/shared/types/ws-channels.ts b/desktop/src/shared/types/ws-channels.ts
index 7e66c85f2..111a34719 100644
--- a/desktop/src/shared/types/ws-channels.ts
+++ b/desktop/src/shared/types/ws-channels.ts
@@ -25,6 +25,7 @@
*/
import type {
EngineCommandPayload,
+ EngineHubLookupResponse,
EngineHubSearchResponse,
EngineInitialState,
EngineStatePatch
@@ -116,6 +117,12 @@ export interface WsInvokeChannelMap {
'engines:get-initial': { request: void; response: EngineInitialState }
'engine:command': { request: EngineCommandPayload; response: null }
'engine:search-hub': { request: { engineType: EngineType }; response: EngineHubSearchResponse }
+ // Exact-name lookup, used only when a search matches nothing, so the
+ // catalog the app ships with cannot hide a model the engine could pull.
+ 'engine:lookup-hub-model': {
+ request: { engineType: EngineType; name: string }
+ response: EngineHubLookupResponse
+ }
// Errors
'errors:get-initial': { request: void; response: ServiceError[] }
diff --git a/desktop/src/shared/utils/log.ts b/desktop/src/shared/utils/log.ts
index b3c8ff7fd..40131827c 100644
--- a/desktop/src/shared/utils/log.ts
+++ b/desktop/src/shared/utils/log.ts
@@ -97,11 +97,18 @@ function writeEntry(scope: string, level: LogLevel, payload: StructuredLogPayloa
}
}
- // Console output with scope prefix
+ // Console output with scope prefix. Best-effort: a closed stdout (parent
+ // process gone, launched from a pipe that later closed) raises write EPIPE
+ // from afterWriteDispatched. That is an uncaughtException in Electron and
+ // surfaces as "A JavaScript error occurred in the main process".
const prefix = `(${scope})`.padEnd(24)
const msg = payload.message ?? ''
const dataStr = payload.data ? ` ${JSON.stringify(payload.data)}` : ''
- process.stdout.write(`${now.toLocaleTimeString()} ${prefix} > ${msg}${dataStr}\n`)
+ try {
+ process.stdout.write(`${now.toLocaleTimeString()} ${prefix} > ${msg}${dataStr}\n`)
+ } catch {
+ /* best-effort */
+ }
}
// ---------------------------------------------------------------------------
@@ -141,6 +148,15 @@ export function initFileLogger(paths: PathProvider): void {
} catch {
approxFileSize = 0
}
+
+ // Swallow async write errors on stdio. try/catch around write() only
+ // covers the synchronous throw; EPIPE from a broken pipe is emitted later.
+ if (process.stdout.listenerCount('error') === 0) {
+ process.stdout.on('error', () => {})
+ }
+ if (process.stderr.listenerCount('error') === 0) {
+ process.stderr.on('error', () => {})
+ }
}
export function createStructuredLogger(scope: string): StructuredLogger {
diff --git a/desktop/src/ui/api/engine-api.ts b/desktop/src/ui/api/engine-api.ts
index 69fd71a24..e691ba981 100644
--- a/desktop/src/ui/api/engine-api.ts
+++ b/desktop/src/ui/api/engine-api.ts
@@ -9,6 +9,7 @@
*/
import type { PreloadServiceTransport as ServiceTransport } from '@/shared/types/service-bridge'
import type {
+ EngineHubLookupResponse,
EngineHubSearchResponse,
EngineInitialState,
EngineStatePatch,
@@ -49,6 +50,8 @@ export interface IEngineApi {
setModelExpiry(engineType: EngineType, nodeId: string, model: string, expiry: string): void
/** Search the model registry/hub for available models. */
searchHub(engineType: EngineType): Promise
+ /** Resolve one exact model name the hub catalog does not list. */
+ lookupHubModel(engineType: EngineType, name: string): Promise
/** Durable engine state changed. Prefer this for new renderer state. */
onStateChanged(callback: (patch: EngineStatePatch) => void): () => void
@@ -103,6 +106,8 @@ export function createEngineApi(transport: ServiceTransport): IEngineApi {
expiry
}),
searchHub: engineType => transport.invoke('engine:search-hub', { engineType }),
+ lookupHubModel: (engineType, name) =>
+ transport.invoke('engine:lookup-hub-model', { engineType, name }),
onStateChanged: cb => transport.subscribePush('engines:state-changed', cb),
onProgress: cb => transport.subscribePush('engines:progress-changed', cb),
diff --git a/desktop/src/ui/components/ModelHub/EngineHubListRow.tsx b/desktop/src/ui/components/ModelHub/EngineHubListRow.tsx
index 10c0ccdb2..17bba0431 100644
--- a/desktop/src/ui/components/ModelHub/EngineHubListRow.tsx
+++ b/desktop/src/ui/components/ModelHub/EngineHubListRow.tsx
@@ -75,12 +75,14 @@ const EngineHubListRow = memo(function EngineHubListRow({
)}
-
- Updated {formatRelativeTime(new Date(model.updatedAt).getTime())}
-
+ {!!model.updatedAt && (
+
+ Updated {formatRelativeTime(model.updatedAt.getTime())}
+
+ )}
{!!model.url && (