From 5bfc6dbe8ef9f7bb384ee2bd7643130619753794 Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Wed, 7 Oct 2026 22:53:44 +0800 Subject: [PATCH 1/5] feat(server): add shared checksum-verified download utility --- packages/server/src/ai/download.test.ts | 74 +++++++++++++++++++++++++ packages/server/src/ai/download.ts | 74 +++++++++++++++++++++++++ packages/server/src/ai/service.ts | 22 +------- 3 files changed, 151 insertions(+), 19 deletions(-) create mode 100644 packages/server/src/ai/download.test.ts create mode 100644 packages/server/src/ai/download.ts diff --git a/packages/server/src/ai/download.test.ts b/packages/server/src/ai/download.test.ts new file mode 100644 index 00000000..29586ae6 --- /dev/null +++ b/packages/server/src/ai/download.test.ts @@ -0,0 +1,74 @@ +import {createHash} from 'node:crypto'; +import {existsSync, readFileSync} from 'node:fs'; +import {mkdtemp, readFile, rm, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {downloadFile, downloadVerified} from './download'; + +const bytes = Buffer.from('verified model'); +const pin = {sha256: createHash('sha256').update(bytes).digest('hex'), size: bytes.length}; +let dir: string; +let dest: string; +beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), 'verified-download-')); + dest = path.join(dir, 'model'); +}); +afterEach(async () => { + vi.unstubAllGlobals(); + await rm(dir, {recursive: true, force: true}); +}); + +function respond(body: Uint8Array) { + vi.stubGlobal('fetch', vi.fn(async () => new Response(new Uint8Array(body)))); +} + +describe('downloadVerified', () => { + it('verifies before atomic replacement and reports progress without content-length', async () => { + await writeFile(dest, 'old'); + respond(bytes); + const progress = vi.fn(); + await downloadVerified('https://example.test/model', dest, pin, (state) => { + expect(readFileSync(dest, 'utf8')).toBe('old'); + progress(state); + }); + expect(await readFile(dest)).toEqual(bytes); + expect(progress).toHaveBeenLastCalledWith({received: bytes.length, total: bytes.length}); + expect(existsSync(`${dest}.part`)).toBe(false); + }); + + it.each(['corrupt', 'truncated', 'oversized'] as const)('rejects %s content and cleans partial, preserving the old file', async (kind) => { + await writeFile(dest, 'old'); + respond(kind === 'corrupt' ? Buffer.alloc(bytes.length) : kind === 'truncated' ? bytes.subarray(0, 3) : Buffer.concat([bytes, bytes])); + await expect(downloadVerified('https://example.test/model', dest, pin)).rejects.toThrow(kind === 'corrupt' ? 'SHA-256 mismatch' : 'size mismatch'); + expect(await readFile(dest, 'utf8')).toBe('old'); + expect(existsSync(`${dest}.part`)).toBe(false); + }); + + it('cleans partial files after a transport failure', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(new ReadableStream({ + start(controller) { controller.enqueue(bytes.subarray(0, 3)); }, + pull(controller) { controller.error(new Error('connection terminated')); }, + })))); + await expect(downloadVerified('https://example.test/model', dest, pin)).rejects.toThrow('connection terminated'); + expect(existsSync(dest)).toBe(false); + expect(existsSync(`${dest}.part`)).toBe(false); + }); + + it('handles HTTP and filesystem errors without publishing a destination', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('missing', {status: 404}))); + await expect(downloadVerified('https://example.test/model', dest, pin)).rejects.toThrow('HTTP 404'); + respond(bytes); + await expect(downloadVerified('https://example.test/model', path.join(dir, 'missing', 'model'), pin)).rejects.toThrow(); + expect(existsSync(dest)).toBe(false); + expect(existsSync(`${dest}.part`)).toBe(false); + }); + + it('retains unverified arbitrary llama bytes and progress', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(bytes, {headers: {'content-length': String(bytes.length)}}))); + const progress = vi.fn(); + await downloadFile('https://example.test/custom.gguf', dest, progress); + expect(await readFile(dest)).toEqual(bytes); + expect(progress).toHaveBeenLastCalledWith({received: bytes.length, total: bytes.length}); + }); +}); diff --git a/packages/server/src/ai/download.ts b/packages/server/src/ai/download.ts new file mode 100644 index 00000000..105e4e7b --- /dev/null +++ b/packages/server/src/ai/download.ts @@ -0,0 +1,74 @@ +import {createHash} from 'node:crypto'; +import {createWriteStream} from 'node:fs'; +import {rename, unlink} from 'node:fs/promises'; +import {Readable} from 'node:stream'; +import {pipeline} from 'node:stream/promises'; + +export interface DownloadProgress { + received: number; + total: number | null; +} + +export interface DownloadIntegrity { + sha256: string; + size: number; +} + +/** Shared streamer for arbitrary llama URLs and pinned, verified artifacts. + * The caller owns serialization of downloads to the same destination. */ +export async function downloadFile( + url: string, + dest: string, + onProgress?: (progress: DownloadProgress) => void, + integrity?: DownloadIntegrity, +): Promise { + const partial = `${dest}.part`; + try { + const res = await fetch(url, {redirect: 'follow'}); + if (!res.ok || !res.body) { + await res.body?.cancel(); + throw new Error(`Download failed: HTTP ${res.status}`); + } + const total = integrity?.size ?? (Number(res.headers.get('content-length')) || null); + const hash = integrity ? createHash('sha256') : null; + let received = 0; + onProgress?.({received, total}); + const reader = res.body.getReader(); + async function* chunks() { + try { + for (;;) { + const {done, value} = await reader.read(); + if (done) break; + received += value.byteLength; + if (integrity && received > integrity.size) throw new Error(`Download size mismatch: expected ${integrity.size} bytes, received at least ${received}`); + hash?.update(value); + onProgress?.({received, total}); + yield value; + } + } finally { + await reader.cancel().catch(() => undefined); + reader.releaseLock(); + } + } + // pipeline propagates disk/read errors and closes the file before cleanup or rename. + await pipeline(Readable.from(chunks()), createWriteStream(partial)); + if (integrity) { + if (received !== integrity.size) throw new Error(`Download size mismatch: expected ${integrity.size} bytes, received ${received}`); + if (hash!.digest('hex') !== integrity.sha256) throw new Error('Download SHA-256 mismatch'); + } + await rename(partial, dest); + } catch (error) { + await unlink(partial).catch(() => undefined); + throw error; + } +} + +/** Stream → verify size and SHA-256 → atomically replace the destination. */ +export async function downloadVerified( + url: string, + dest: string, + integrity: DownloadIntegrity, + onProgress?: (progress: DownloadProgress) => void, +): Promise { + await downloadFile(url, dest, onProgress, integrity); +} diff --git a/packages/server/src/ai/service.ts b/packages/server/src/ai/service.ts index 07aaf66a..8b0660d8 100644 --- a/packages/server/src/ai/service.ts +++ b/packages/server/src/ai/service.ts @@ -1,5 +1,4 @@ -import {createWriteStream, existsSync, mkdirSync} from 'node:fs'; -import {rename, unlink} from 'node:fs/promises'; +import {existsSync, mkdirSync} from 'node:fs'; import path from 'node:path'; import {providerSettings, type AiConfig, type AiProvider, type AiProviderSettings, type AiSearchResponse, type AiStatus, type AiTasksResponse} from '@book.dev/sdk'; import type {Db} from '../db'; @@ -7,6 +6,7 @@ import {createEngine, MockEngine, OpenAiCompatEngine, type TranscriptionEngine, import {assembleSearchResults, bm25Scores, buildIndex, cosine, pageRowsToDocs, parseTaskList, type Bm25Index} from './search'; import {WHISPER_MODEL, WHISPER_MODEL_URL} from './whisper'; import {SkillStore} from './skills'; +import {downloadFile} from './download'; /** * The optional local-AI subsystem: holds the configured engine, the note @@ -353,27 +353,12 @@ export class AiService { this.download = state; void (async () => { - const partial = `${dest}.part`; try { if (existsSync(dest)) { state.done = true; state.received = state.total ?? 0; } else { - const res = await fetch(url, {redirect: 'follow'}); - if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`); - state.total = Number(res.headers.get('content-length')) || null; - const out = createWriteStream(partial); - const reader = res.body.getReader(); - for (;;) { - const {done, value} = await reader.read(); - if (done) break; - state.received += value.byteLength; - await new Promise((resolve, reject) => { - out.write(value, (err) => (err ? reject(err) : resolve())); - }); - } - await new Promise((resolve, reject) => out.end((err: Error | null | undefined) => (err ? reject(err) : resolve()))); - await rename(partial, dest); + await downloadFile(url, dest, (progress) => Object.assign(state, progress)); state.done = true; } // Auto-select the downloaded model for the llama provider. @@ -382,7 +367,6 @@ export class AiService { } } catch (err) { state.error = err instanceof Error ? err.message : String(err); - await unlink(partial).catch(() => undefined); } })(); From aa1056d47df3a19200ff2d66c61695d7ddfa6a4a Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Wed, 7 Oct 2026 22:56:47 +0800 Subject: [PATCH 2/5] feat(server): pin transcription artifacts and verify model upgrades --- packages/server/src/ai/pinnedDownload.test.ts | 56 ++++++++++++ packages/server/src/ai/pinnedDownload.ts | 33 +++++++ .../server/src/ai/runtimeManifest.test.ts | 39 +++++++++ packages/server/src/ai/runtimeManifest.ts | 85 +++++++++++++++++++ packages/server/src/ai/service.ts | 12 ++- .../server/src/ai/serviceDownload.test.ts | 77 +++++++++++++++++ packages/server/src/ai/whisper.ts | 5 +- packages/server/src/transcription.test.ts | 10 +++ 8 files changed, 312 insertions(+), 5 deletions(-) create mode 100644 packages/server/src/ai/pinnedDownload.test.ts create mode 100644 packages/server/src/ai/pinnedDownload.ts create mode 100644 packages/server/src/ai/runtimeManifest.test.ts create mode 100644 packages/server/src/ai/runtimeManifest.ts create mode 100644 packages/server/src/ai/serviceDownload.test.ts diff --git a/packages/server/src/ai/pinnedDownload.test.ts b/packages/server/src/ai/pinnedDownload.test.ts new file mode 100644 index 00000000..b2f3b10b --- /dev/null +++ b/packages/server/src/ai/pinnedDownload.test.ts @@ -0,0 +1,56 @@ +import {createHash} from 'node:crypto'; +import {existsSync} from 'node:fs'; +import {mkdtemp, readFile, rm, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {afterEach, beforeEach, expect, it, vi} from 'vitest'; +import {downloadPinned} from './pinnedDownload'; + +const bytes = 'model bytes'; +const pin = {version: 'v1', url: 'https://example.test/model.bin', sha256: createHash('sha256').update(bytes).digest('hex'), size: Buffer.byteLength(bytes)}; +let dir: string; +let dest: string; +const fetchMock = vi.fn(async () => new Response(bytes)); +beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), 'pinned-download-')); + dest = path.join(dir, 'model.bin'); + fetchMock.mockClear(); + vi.stubGlobal('fetch', fetchMock); +}); +afterEach(async () => { + vi.unstubAllGlobals(); + await rm(dir, {recursive: true, force: true}); +}); + +it('skips the same file and version, then downloads on a version-only pin bump', async () => { + await downloadPinned(pin, dest); + const progress = vi.fn(); + await downloadPinned(pin, dest, progress); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(progress).toHaveBeenLastCalledWith({received: pin.size, total: pin.size}); + await downloadPinned({...pin, version: 'v2'}, dest); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(JSON.parse(await readFile(`${dest}.verified.json`, 'utf8')).version).toBe('v2'); +}); + +it.each(['missing', 'legacy', 'truncated', 'bad-receipt'])('downloads %s installations', async (kind) => { + await downloadPinned(pin, dest); + if (kind === 'missing') await rm(dest); + if (kind === 'legacy') await rm(`${dest}.verified.json`); + if (kind === 'truncated') await writeFile(dest, 'short'); + if (kind === 'bad-receipt') await writeFile(`${dest}.verified.json`, '{'); + await downloadPinned(pin, dest); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(await readFile(dest, 'utf8')).toBe(bytes); +}); + +it('does not leave a valid receipt or partial after a failed upgrade, and allows retry', async () => { + await downloadPinned(pin, dest); + const next = {...pin, version: 'v2'}; + fetchMock.mockResolvedValueOnce(new Response('corrupt')); + await expect(downloadPinned(next, dest)).rejects.toThrow('size mismatch'); + expect(await readFile(dest, 'utf8')).toBe(bytes); + for (const suffix of ['.part', '.verified.json', '.verified.json.part']) expect(existsSync(`${dest}${suffix}`)).toBe(false); + await downloadPinned(next, dest); + expect(fetchMock).toHaveBeenCalledTimes(3); +}); diff --git a/packages/server/src/ai/pinnedDownload.ts b/packages/server/src/ai/pinnedDownload.ts new file mode 100644 index 00000000..aee1f2d4 --- /dev/null +++ b/packages/server/src/ai/pinnedDownload.ts @@ -0,0 +1,33 @@ +import {readFile, rename, stat, unlink, writeFile} from 'node:fs/promises'; +import {downloadVerified, type DownloadProgress} from './download'; +import type {ArtifactPin} from './runtimeManifest'; + +/** A receipt is written only after verification. Legacy files without a receipt + * are re-downloaded. Compare the entire pin so hash/URL changes also invalidate. + * Callers serialize writes to dest; extraction and runtime installs are separate. */ +export async function downloadPinned( + pin: ArtifactPin, + dest: string, + onProgress?: (progress: DownloadProgress) => void, +): Promise { + const receipt = `${dest}.verified.json`; + const identity = JSON.stringify({version: pin.version, url: pin.url, sha256: pin.sha256, size: pin.size}); + const current = await readFile(receipt, 'utf8').catch(() => null); + const file = await stat(dest).catch(() => null); + if (current === identity && file?.isFile() && file.size === pin.size) { + onProgress?.({received: pin.size, total: pin.size}); + return; + } + // Invalidate first: an interrupted replacement must never inherit an old receipt. + await unlink(receipt).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') throw error; + }); + try { + await downloadVerified(pin.url, dest, pin, onProgress); + await writeFile(`${receipt}.part`, identity); + await rename(`${receipt}.part`, receipt); + } catch (error) { + await unlink(`${receipt}.part`).catch(() => undefined); + throw error; + } +} diff --git a/packages/server/src/ai/runtimeManifest.test.ts b/packages/server/src/ai/runtimeManifest.test.ts new file mode 100644 index 00000000..4604ddd6 --- /dev/null +++ b/packages/server/src/ai/runtimeManifest.test.ts @@ -0,0 +1,39 @@ +import {describe, expect, it} from 'vitest'; +import {RELEASE_TARGETS, RUNTIME_MANIFEST, WHISPER_MODEL_PIN, type ArtifactPin} from './runtimeManifest'; + +function checkPin(pin: ArtifactPin) { + expect(new URL(pin.url).protocol).toBe('https:'); + expect(pin.url).not.toMatch(/latest|\/main\//i); + expect(pin.version.length).toBeGreaterThan(0); + expect(pin.url).toContain(pin.version); + expect(pin.sha256).toMatch(/^[a-f0-9]{64}$/); + expect(Number.isSafeInteger(pin.size) && pin.size > 0).toBe(true); +} + +describe('pinned runtime manifest', () => { + it('represents all four release targets and both tools with pins or typed reasons', () => { + expect(Object.keys(RUNTIME_MANIFEST).sort()).toEqual([...RELEASE_TARGETS].sort()); + expect(RELEASE_TARGETS).toHaveLength(4); + for (const target of RELEASE_TARGETS) { + expect(Object.keys(RUNTIME_MANIFEST[target]).sort()).toEqual(['ffmpeg', 'whisper-cli']); + for (const artifact of Object.values(RUNTIME_MANIFEST[target])) { + if (artifact.status === 'unsupported') { + expect(['no-upstream-cli', 'no-native-prebuilt']).toContain(artifact.reason); + expect(artifact.detail.length).toBeGreaterThan(0); + } else { + expect(artifact.status).toBe('supported'); + checkPin(artifact); + expect(['zip', 'tar.xz']).toContain(artifact.archive); + expect(artifact.binaryPath).not.toMatch(/^\/|\\|(^|\/)\.\.(\/|$)/); + expect(artifact.binaryPath).toMatch(/(?:ffmpeg|whisper-cli)(?:\.exe)?$/); + } + } + } + }); + + it('pins the base model to an immutable repository revision', () => { + checkPin(WHISPER_MODEL_PIN); + expect(WHISPER_MODEL_PIN.fileName).toBe('ggml-base.bin'); + expect(WHISPER_MODEL_PIN.version).toMatch(/^[a-f0-9]{40}$/); + }); +}); diff --git a/packages/server/src/ai/runtimeManifest.ts b/packages/server/src/ai/runtimeManifest.ts new file mode 100644 index 00000000..3d9079d8 --- /dev/null +++ b/packages/server/src/ai/runtimeManifest.ts @@ -0,0 +1,85 @@ +/** Pins verified by downloading and hashing the actual artifacts (WSP-1). + * Unsupported is per tool: consumers must require BOTH tools for a ready runtime. + * Windows whisper needs the DLLs alongside the executable from the same archive. + * Extraction/provisioning belongs to WSP-2. */ +export const RELEASE_TARGETS = [ + 'aarch64-apple-darwin', + 'x86_64-apple-darwin', + 'x86_64-unknown-linux-gnu', + 'x86_64-pc-windows-msvc', +] as const; +export type ReleaseTarget = typeof RELEASE_TARGETS[number]; +export type RuntimeTool = 'whisper-cli' | 'ffmpeg'; + +export interface ArtifactPin { + version: string; + url: string; + sha256: string; + size: number; +} +export type RuntimeArtifact = (ArtifactPin & { + status: 'supported'; + archive: 'zip' | 'tar.xz'; + binaryPath: string; +}) | { + status: 'unsupported'; + reason: 'no-upstream-cli' | 'no-native-prebuilt'; + detail: string; +}; + +const noWhisperCli: RuntimeArtifact = { + status: 'unsupported', + reason: 'no-upstream-cli', + detail: 'whisper.cpp v1.8.2 publishes Windows CLI archives and an Apple XCFramework, but no macOS/Linux whisper-cli prebuilt. Owner must select a trusted builder or publish builds.', +}; + +export const RUNTIME_MANIFEST: Record> = { + 'aarch64-apple-darwin': { + 'whisper-cli': noWhisperCli, + ffmpeg: { + status: 'unsupported', + reason: 'no-native-prebuilt', + detail: 'The selected macOS provider (evermeet.cx) explicitly supplies Intel binaries only. A trusted native ARM provider is an owner decision; do not silently require Rosetta.', + }, + }, + 'x86_64-apple-darwin': { + 'whisper-cli': noWhisperCli, + ffmpeg: { + status: 'supported', version: '7.1.1', + url: 'https://evermeet.cx/ffmpeg/ffmpeg-7.1.1.zip', + sha256: '8d7917c1cebd7a29e68c0a0a6cc4ecc3fe05c7fffed958636c7018b319afdda4', + size: 25458015, archive: 'zip', binaryPath: 'ffmpeg', + }, + }, + 'x86_64-unknown-linux-gnu': { + 'whisper-cli': noWhisperCli, + ffmpeg: { + status: 'supported', version: '7.0.2', + url: 'https://johnvansickle.com/ffmpeg/releases/ffmpeg-7.0.2-amd64-static.tar.xz', + sha256: 'abda8d77ce8309141f83ab8edf0596834087c52467f6badf376a6a2a4c87cf67', + size: 41888096, archive: 'tar.xz', binaryPath: 'ffmpeg-7.0.2-amd64-static/ffmpeg', + }, + }, + 'x86_64-pc-windows-msvc': { + 'whisper-cli': { + status: 'supported', version: '1.8.2', + url: 'https://github.com/ggml-org/whisper.cpp/releases/download/v1.8.2/whisper-bin-x64.zip', + sha256: 'b1514ebc099765e39fa37eb780b92a140a94c86bb0b3b3d98226b38825979732', + size: 3832432, archive: 'zip', binaryPath: 'Release/whisper-cli.exe', + }, + ffmpeg: { + status: 'supported', version: '8.1.2', + url: 'https://www.gyan.dev/ffmpeg/builds/packages/ffmpeg-8.1.2-essentials_build.zip', + sha256: 'db580001caa24ac104c8cb856cd113a87b0a443f7bdf47d8c12b1d740584a2ec', + size: 109728040, archive: 'zip', binaryPath: 'ffmpeg-8.1.2-essentials_build/bin/ffmpeg.exe', + }, + }, +}; + +export const WHISPER_MODEL_PIN: ArtifactPin & {fileName: string} = { + fileName: 'ggml-base.bin', + version: '5359861c739e955e79d9a303bcbc70fb988958b1', + url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-base.bin', + sha256: '60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe', + size: 147951465, +}; diff --git a/packages/server/src/ai/service.ts b/packages/server/src/ai/service.ts index 8b0660d8..895ac227 100644 --- a/packages/server/src/ai/service.ts +++ b/packages/server/src/ai/service.ts @@ -4,9 +4,11 @@ import {providerSettings, type AiConfig, type AiProvider, type AiProviderSetting import type {Db} from '../db'; import {createEngine, MockEngine, OpenAiCompatEngine, type TranscriptionEngine, type AiEngine, type GenerateOptions} from './providers'; import {assembleSearchResults, bm25Scores, buildIndex, cosine, pageRowsToDocs, parseTaskList, type Bm25Index} from './search'; -import {WHISPER_MODEL, WHISPER_MODEL_URL} from './whisper'; +import {WHISPER_MODEL} from './whisper'; import {SkillStore} from './skills'; import {downloadFile} from './download'; +import {downloadPinned} from './pinnedDownload'; +import {WHISPER_MODEL_PIN} from './runtimeManifest'; /** * The optional local-AI subsystem: holds the configured engine, the note @@ -354,7 +356,11 @@ export class AiService { void (async () => { try { - if (existsSync(dest)) { + if (fileName === WHISPER_MODEL) { + // Never allow an arbitrary URL to populate the managed Whisper filename. + await downloadPinned(WHISPER_MODEL_PIN, dest, (progress) => Object.assign(state, progress)); + state.done = true; + } else if (existsSync(dest)) { state.done = true; state.received = state.total ?? 0; } else { @@ -362,7 +368,7 @@ export class AiService { state.done = true; } // Auto-select the downloaded model for the llama provider. - if (url !== WHISPER_MODEL_URL && this.config.provider === 'llama' && !this.config.model) { + if (fileName !== WHISPER_MODEL && this.config.provider === 'llama' && !this.config.model) { await this.setConfig({...this.config, model: fileName}); } } catch (err) { diff --git a/packages/server/src/ai/serviceDownload.test.ts b/packages/server/src/ai/serviceDownload.test.ts new file mode 100644 index 00000000..19f41558 --- /dev/null +++ b/packages/server/src/ai/serviceDownload.test.ts @@ -0,0 +1,77 @@ +import {createHash} from 'node:crypto'; +import {mkdtemp, readFile, readdir, rm, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {afterEach, beforeEach, expect, it, vi} from 'vitest'; +import type {Db} from '../db'; +import {AiService} from './service'; +import {WHISPER_MODEL_PIN} from './runtimeManifest'; + +vi.mock('./runtimeManifest', async (importOriginal) => { + const actual = await importOriginal(); + const {createHash} = await import('node:crypto'); + return {...actual, WHISPER_MODEL_PIN: {...actual.WHISPER_MODEL_PIN, + sha256: createHash('sha256').update('model bytes').digest('hex'), size: 11, + }}; +}); + +let dir: string; +let service: AiService; +const fetchMock = vi.fn(async () => new Response('model bytes')); +beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), 'service-download-')); + const db = {query: vi.fn(async () => [])} as unknown as Db; + service = new AiService(db, dir); + await service.setConfig({provider: 'llama'}); + fetchMock.mockClear(); + vi.stubGlobal('fetch', fetchMock); +}); +afterEach(async () => { + await service.dispose(); + vi.unstubAllGlobals(); + await rm(dir, {recursive: true, force: true}); +}); + +async function finish(url: string) { + const state = await service.startDownload(url); + await expect.poll(() => state.done || Boolean(state.error)).toBe(true); + return state; +} + +it('routes Whisper through verification, skips a matching receipt, and refreshes an old version', async () => { + const pin = WHISPER_MODEL_PIN; + expect(await finish(pin.url)).toMatchObject({done: true, received: 11, total: 11}); + expect(await finish(pin.url)).toMatchObject({done: true, received: 11, total: 11}); + expect(fetchMock).toHaveBeenCalledTimes(1); + const receipt = path.join(dir, `${pin.fileName}.verified.json`); + const stored = JSON.parse(await readFile(receipt, 'utf8')); + await writeFile(receipt, JSON.stringify({...stored, version: 'previous-version'})); + expect(await finish(pin.url)).toMatchObject({done: true}); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(JSON.parse(await readFile(receipt, 'utf8')).version).toBe(pin.version); + expect((await service.getConfig()).model).toBeUndefined(); +}); + +it('reports corrupt Whisper bytes as an error and removes the partial', async () => { + fetchMock.mockResolvedValueOnce(new Response('wrong bytes')); + expect(await finish(WHISPER_MODEL_PIN.url)).toMatchObject({done: false, error: expect.stringContaining('SHA-256 mismatch')}); + expect(await readdir(dir)).toEqual([]); +}); + +it('maps the legacy mutable model URL to the current immutable pin', async () => { + await finish('https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin'); + expect(fetchMock).toHaveBeenCalledWith(WHISPER_MODEL_PIN.url, {redirect: 'follow'}); +}); + +it('keeps arbitrary llama downloads unverified, auto-selected, and skipped when present', async () => { + const arbitrary = 'arbitrary GGUF content'; + expect(createHash('sha256').update(arbitrary).digest('hex')).not.toBe(WHISPER_MODEL_PIN.sha256); + fetchMock.mockResolvedValueOnce(new Response(arbitrary)); + const url = 'https://example.test/custom.gguf'; + expect(await finish(url)).toMatchObject({done: true}); + await expect.poll(async () => (await service.getConfig()).model).toBe('custom.gguf'); + expect(await readFile(path.join(dir, 'custom.gguf'), 'utf8')).toBe(arbitrary); + expect(await finish(url)).toMatchObject({done: true}); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(await readdir(dir)).toEqual(['custom.gguf']); +}); diff --git a/packages/server/src/ai/whisper.ts b/packages/server/src/ai/whisper.ts index 989b0858..529eb32d 100644 --- a/packages/server/src/ai/whisper.ts +++ b/packages/server/src/ai/whisper.ts @@ -5,6 +5,7 @@ import {tmpdir} from 'node:os'; import path from 'node:path'; import type {AiStatus, AiTranscriptionResult} from '@book.dev/sdk'; import type {TranscriptionEngine, TranscribeOptions} from './providers'; +import {WHISPER_MODEL_PIN} from './runtimeManifest'; export const LOCAL_TRANSCRIPTION_MAX_JOBS = 2; @@ -15,8 +16,8 @@ export class LocalTranscriptionBusyError extends Error { } } -export const WHISPER_MODEL = 'ggml-base.bin'; -export const WHISPER_MODEL_URL = 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin'; +export const WHISPER_MODEL = WHISPER_MODEL_PIN.fileName; +export const WHISPER_MODEL_URL = WHISPER_MODEL_PIN.url; async function executable(command: string): Promise { const candidates = path.isAbsolute(command) || command.includes(path.sep) diff --git a/packages/server/src/transcription.test.ts b/packages/server/src/transcription.test.ts index 1f5b5a92..578cad2d 100644 --- a/packages/server/src/transcription.test.ts +++ b/packages/server/src/transcription.test.ts @@ -15,6 +15,15 @@ import {LocalWhisper, WHISPER_MODEL, WHISPER_MODEL_URL} from './ai/whisper'; import {LOCAL_TRANSCRIPTION_RATE_LIMIT} from './ai/routes'; import {readFile, readdir, writeFile} from 'node:fs/promises'; +// Keep the existing model-download fixture small while exercising real verification. +vi.mock('./ai/runtimeManifest', async (importOriginal) => { + const actual = await importOriginal(); + const {createHash} = await import('node:crypto'); + return {...actual, WHISPER_MODEL_PIN: {...actual.WHISPER_MODEL_PIN, + sha256: createHash('sha256').update('model bytes').digest('hex'), size: 11, + }}; +}); + let db: PgliteDb; let store: PageStore; let ai: AiService; @@ -147,6 +156,7 @@ describe('transcription contract', () => { expect(download.status).toBe(200); await expect.poll(async () => (await service.status()).download?.done).toBe(true); expect(await readFile(join(dir, WHISPER_MODEL), 'utf8')).toBe('model bytes'); + expect(JSON.parse(await readFile(join(dir, `${WHISPER_MODEL}.verified.json`), 'utf8')).size).toBe(11); expect((await service.getConfig()).model).toBeUndefined(); expect(await readdir(dir)).not.toContain(`${WHISPER_MODEL}.part`); await service.dispose(); From 919fed807f66d0e8996d273d010c55c5d5d7876d Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Wed, 7 Oct 2026 23:37:29 +0800 Subject: [PATCH 3/5] test(server): allow concurrent transcription process startup under load --- packages/server/src/transcription.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/server/src/transcription.test.ts b/packages/server/src/transcription.test.ts index 578cad2d..cd3610ad 100644 --- a/packages/server/src/transcription.test.ts +++ b/packages/server/src/transcription.test.ts @@ -179,7 +179,8 @@ describe('transcription contract', () => { }, {incoming: {socket: {remoteAddress: ip}}}); const accepted = [request('192.0.2.1'), request('192.0.2.2')]; try { - await expect.poll(async () => (await readdir(dir)).filter((f) => f.endsWith('.started')).length).toBe(2); + // Two Node children may take longer than the default 1s under full-suite load. + await expect.poll(async () => (await readdir(dir)).filter((f) => f.endsWith('.started')).length, {timeout: 10_000}).toBe(2); const busy = await request('192.0.2.3'); expect(busy.status).toBe(429); expect(busy.headers.get('Retry-After')).toBe('5'); From 043faaed8784e6bc750a36780fa19269f45399a1 Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Thu, 8 Oct 2026 01:42:48 +0800 Subject: [PATCH 4/5] test(server): allow whisper worker startup under load --- packages/server/src/ai/whisper.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/server/src/ai/whisper.test.ts b/packages/server/src/ai/whisper.test.ts index a9adece0..6cabf298 100644 --- a/packages/server/src/ai/whisper.test.ts +++ b/packages/server/src/ai/whisper.test.ts @@ -46,7 +46,8 @@ describe('optional local whisper runtime', () => { const controller = new AbortController(); const promise = local.transcribe(new Uint8Array([1]), {signal: controller.signal}); const rejected = expect(promise).rejects.toMatchObject({name: 'AbortError'}); - await expect.poll(async () => readFile(marker, 'utf8').catch(() => '')).not.toBe(''); + // Child startup can exceed the default 1s poll deadline under suite load. + await expect.poll(async () => readFile(marker, 'utf8').catch(() => ''), {timeout: 10_000}).not.toBe(''); const started = JSON.parse(await readFile(marker, 'utf8')) as {pid: number; dir: string}; if (action === 'abort') controller.abort(); else await local.dispose(); @@ -64,7 +65,7 @@ describe('optional local whisper runtime', () => { const controller = new AbortController(); const jobs = Promise.allSettled([0, 1].map(() => local.transcribe(new Uint8Array([1]), {signal: controller.signal}))); try { - await expect.poll(async () => (await readdir(dir)).filter((f) => f.endsWith('.started')).length).toBe(2); + await expect.poll(async () => (await readdir(dir)).filter((f) => f.endsWith('.started')).length, {timeout: 10_000}).toBe(2); await expect(local.transcribe(new Uint8Array([1]))).rejects.toBeInstanceOf(LocalTranscriptionBusyError); if (outcome === 'abort') controller.abort(); else await writeFile(mode, 'fail'); From 81bbcf83fd1c82a1facd2f6d9c4c57bf8c959a36 Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Thu, 8 Oct 2026 07:53:45 +0800 Subject: [PATCH 5/5] fix(server): address verified download review fixes --- packages/server/src/ai/download.ts | 7 +++++-- packages/server/src/ai/runtimeManifest.test.ts | 8 ++++++++ packages/server/src/ai/runtimeManifest.ts | 3 +++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/server/src/ai/download.ts b/packages/server/src/ai/download.ts index 105e4e7b..c4878183 100644 --- a/packages/server/src/ai/download.ts +++ b/packages/server/src/ai/download.ts @@ -1,6 +1,6 @@ import {createHash} from 'node:crypto'; import {createWriteStream} from 'node:fs'; -import {rename, unlink} from 'node:fs/promises'; +import {open, rename, unlink} from 'node:fs/promises'; import {Readable} from 'node:stream'; import {pipeline} from 'node:stream/promises'; @@ -27,7 +27,7 @@ export async function downloadFile( const res = await fetch(url, {redirect: 'follow'}); if (!res.ok || !res.body) { await res.body?.cancel(); - throw new Error(`Download failed: HTTP ${res.status}`); + throw new Error(`HTTP ${res.status}`); } const total = integrity?.size ?? (Number(res.headers.get('content-length')) || null); const hash = integrity ? createHash('sha256') : null; @@ -56,6 +56,9 @@ export async function downloadFile( if (received !== integrity.size) throw new Error(`Download size mismatch: expected ${integrity.size} bytes, received ${received}`); if (hash!.digest('hex') !== integrity.sha256) throw new Error('Download SHA-256 mismatch'); } + const fh = await open(partial, 'r+'); + await fh.sync(); + await fh.close(); await rename(partial, dest); } catch (error) { await unlink(partial).catch(() => undefined); diff --git a/packages/server/src/ai/runtimeManifest.test.ts b/packages/server/src/ai/runtimeManifest.test.ts index 4604ddd6..37a741a9 100644 --- a/packages/server/src/ai/runtimeManifest.test.ts +++ b/packages/server/src/ai/runtimeManifest.test.ts @@ -31,6 +31,14 @@ describe('pinned runtime manifest', () => { } }); + it('includes the Windows whisper companion DLL directory', () => { + const artifact = RUNTIME_MANIFEST['x86_64-pc-windows-msvc']['whisper-cli']; + expect(artifact.status).toBe('supported'); + if (artifact.status !== 'supported') throw new Error('Windows whisper must be supported'); + expect(artifact.extractDir).toBe('Release'); + expect(artifact.binaryPath.startsWith(`${artifact.extractDir}/`)).toBe(true); + }); + it('pins the base model to an immutable repository revision', () => { checkPin(WHISPER_MODEL_PIN); expect(WHISPER_MODEL_PIN.fileName).toBe('ggml-base.bin'); diff --git a/packages/server/src/ai/runtimeManifest.ts b/packages/server/src/ai/runtimeManifest.ts index 3d9079d8..57854875 100644 --- a/packages/server/src/ai/runtimeManifest.ts +++ b/packages/server/src/ai/runtimeManifest.ts @@ -21,6 +21,7 @@ export type RuntimeArtifact = (ArtifactPin & { status: 'supported'; archive: 'zip' | 'tar.xz'; binaryPath: string; + extractDir?: string; }) | { status: 'unsupported'; reason: 'no-upstream-cli' | 'no-native-prebuilt'; @@ -66,6 +67,8 @@ export const RUNTIME_MANIFEST: Record