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 && (