@ebony-vinyl/dsh-our-free-model 2.8.0 → 2.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1888 @@
1
+ /**
2
+ * The OFM plugin platform: signed registry, verified install, sandboxed run.
3
+ *
4
+ * The lane is adapted to this host's constraints (zero runtime dependencies,
5
+ * the host's own trust fence, one data directory per service):
6
+ *
7
+ * - **The marketplace is a repo**, `Ebony-Vinyl/ofm-extensions`: a
8
+ * deterministic `registry.json` generated from each package's
9
+ * `ofm-plugin.json` plus its code and page files, signed by the
10
+ * maintainer's Ed25519 key into
11
+ * `registry.sig`. Authors submit pull requests; CI validates schema, code
12
+ * and tests; a human reviews. "Users upload" means "users open a PR" —
13
+ * curated distribution with a signature on the index.
14
+ * - **Install is verify-then-write**: the registry (signed) carries each
15
+ * package's manifest inline and its `main.mjs` sha256; the client
16
+ * downloads only from the registry repo's own hosts, checks size + digest,
17
+ * statically scans the source, writes it under the plugin's data dir, and
18
+ * records everything in an append-only audit log. One version stays on
19
+ * disk for rollback; the updater's package sweep never walks this
20
+ * directory (it lives in the data dir, not the package).
21
+ * - **Run is sandboxed**, in the honest sense available to a Node plugin: a
22
+ * `worker_threads` Worker with a fixed resource budget evaluates the
23
+ * extension inside `vm.SourceTextModule` context whose only import is the
24
+ * synthetic `ofm:sdk` module — no `require`, no `process`, no filesystem,
25
+ * no sockets of its own. Everything a channel needs — outbound HTTP,
26
+ * secrets, storage, logs — crosses the bridge as plain JSON, and the
27
+ * bridge enforces the manifest's declared network origins. This is
28
+ * defense-in-depth against accidents, not a security boundary against a
29
+ * determined author: the load-bearing gates are the signature, the review
30
+ * and the static scan, exactly as the settings page says before an
31
+ * install.
32
+ * - **Channels are thin.** An extension channel declares its models (static
33
+ * or via `listModels`) and turns one harness request into
34
+ * `{ url, headers, body, wire }`; the host performs the actual HTTP and
35
+ * SSE through the same shared streaming core the aggregate lane uses.
36
+ * Enhancement extensions get `onRequest` / `onModels` / `onUsage` hooks
37
+ * with short timeouts and fail-open semantics.
38
+ *
39
+ * @module src/extensions.js
40
+ */
41
+
42
+ import fs from 'node:fs'
43
+ import path from 'node:path'
44
+ import crypto from 'node:crypto'
45
+ import { Worker } from 'node:worker_threads'
46
+ import { restrictPermissions } from './open-external.js'
47
+ import { buildExtensionCatalog, extensionReplaces } from './catalog.js'
48
+ import { compareVersions } from './semver.js'
49
+ import {
50
+ parsePluginManifest,
51
+ manifestHasChannel,
52
+ manifestHasEnhancement,
53
+ manifestHasPage,
54
+ safePluginPath,
55
+ scanExtensionSource,
56
+ scanPageAsset,
57
+ REGISTRY_FALLBACK,
58
+ } from './plugins.js'
59
+
60
+ export { parseExtensionManifest, scanExtensionSource } from './plugins.js'
61
+
62
+ /**
63
+ * Deterministic JSON for signature verification: sorted keys, no whitespace,
64
+ * `undefined` dropped (object fields) or nulled (array slots). Same rules as
65
+ * the release manifest's `stableStringify` in src/updater.js — inlined because
66
+ * the standalone package deliberately ships without the updater, and the
67
+ * registry signature must verify identically in every product that mounts the
68
+ * extension platform.
69
+ */
70
+ function stableStringify(value) {
71
+ if (Array.isArray(value)) return `[${value.map(row => stableStringify(row ?? null)).join(',')}]`
72
+ if (value !== null && typeof value === 'object') {
73
+ const keys = Object.keys(value).filter(key => value[key] !== undefined).sort()
74
+ return `{${keys.map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`
75
+ }
76
+ return JSON.stringify(value ?? null)
77
+ }
78
+
79
+ // ── constants ────────────────────────────────────────────────────────────────
80
+
81
+ const REGISTRY_TTL_MS = 6 * 60 * 60 * 1000
82
+ const REGISTRY_MAX_BYTES = 512 * 1024
83
+ const EXTENSION_SOURCE_MAX_BYTES = 256 * 1024
84
+ const PAGE_ASSET_MAX_BYTES = 2 * 1024 * 1024
85
+ const PAGE_FILE_LIMIT = 32
86
+ /** The cover is display-only; a bigger file would outweigh every source. */
87
+ const COVER_MAX_BYTES = 192 * 1024
88
+ const COVER_NAME_RE = /^[\w.-]+\.(?:png|jpe?g|webp|svg)$/i
89
+ const COVER_TYPES = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', svg: 'image/svg+xml' }
90
+ const MAX_INSTALLED = 32
91
+ const AUDIT_MAX_LINES = 2000
92
+ const RESERVED_KEYS = new Set(['__proto__', 'prototype', 'constructor'])
93
+
94
+ /** Per-hook budgets. A hung extension dies with its worker, never the lane. */
95
+ const HOOK_TIMEOUT_MS = {
96
+ load: 10_000,
97
+ listModels: 10_000,
98
+ prepareChat: 20_000,
99
+ onRequest: 2_000,
100
+ onModels: 2_000,
101
+ onUsage: 1_000,
102
+ selfTest: 10_000,
103
+ }
104
+
105
+ /**
106
+ * How long an update must have been visible in the signed registry before the
107
+ * automatic updater will install it. VS Code adopted the same two-hour soak
108
+ * for exactly this reason: an upstream compromise surfaces in mirrors, issue
109
+ * trackers and the marketplace's own commit history long before the soak ends.
110
+ * A user who wants the bytes sooner clicks 更新 — the soak only governs the
111
+ * updates nobody has asked for.
112
+ */
113
+ const AUTO_UPDATE_SOAK_MS = 2 * 60 * 60 * 1000
114
+
115
+ /** The registry repo, and the only hosts an extension's code may download from. */
116
+ const EXTENSIONS_REPO = process.env.OFM_EXTENSIONS_REPO ?? 'Ebony-Vinyl/ofm-extensions'
117
+ const REGISTRY_HOSTS = new Set(['raw.githubusercontent.com', 'cdn.jsdelivr.net', 'gcore.jsdelivr.net', 'gh-proxy.com'])
118
+
119
+ /**
120
+ * The registry's Ed25519 public key (SPKI, base64). The matching private key
121
+ * lives with the publisher (GitHub Actions secret `OFM_EXTENSIONS_SIGNING_KEY`
122
+ * and the maintainer's machine), never beside this constant — same rule as the
123
+ * release manifest's key in src/updater.js. Tests override it through
124
+ * `OFM_EXTENSIONS_PUBLIC_KEY` together with `OFM_EXTENSIONS_REGISTRY`.
125
+ */
126
+ export const PINNED_EXTENSIONS_PUBLIC_KEY = process.env.OFM_EXTENSIONS_PUBLIC_KEY
127
+ ?? 'MCowBQYDK2VwAyEA7jxuBMdejAlGd9EnkxEpqKpAnxT8vr3sdVBFHxssy/U='
128
+
129
+ /**
130
+ * Registry sources, first alive wins: raw, then the two jsDelivr edges, then
131
+ * the same plain-read proxy the announcement feed uses (the feed's comment on
132
+ * mirrors applies verbatim here).
133
+ */
134
+ export function registrySources() {
135
+ const override = process.env.OFM_EXTENSIONS_REGISTRY
136
+ if (override !== undefined && override !== '') return [String(override)]
137
+ return [
138
+ `https://raw.githubusercontent.com/${EXTENSIONS_REPO}/main/registry.json`,
139
+ `https://cdn.jsdelivr.net/gh/${EXTENSIONS_REPO}@main/registry.json`,
140
+ `https://gcore.jsdelivr.net/gh/${EXTENSIONS_REPO}@main/registry.json`,
141
+ `https://gh-proxy.com/https://raw.githubusercontent.com/${EXTENSIONS_REPO}/main/registry.json`,
142
+ ]
143
+ }
144
+
145
+ /** The sibling `.sig` URL for one registry URL. */
146
+ function signatureUrlFor(registryUrl) {
147
+ const override = process.env.OFM_EXTENSIONS_REGISTRY
148
+ if (override !== undefined && override !== '') return `${override.replace(/\.json$/, '')}.sig`
149
+ if (registryUrl.includes('raw.githubusercontent.com')) return `${registryUrl.replace(/\.json$/, '')}.sig`
150
+ if (registryUrl.includes('jsdelivr.net')) return registryUrl.replace(/registry\.json/, 'registry.sig')
151
+ if (registryUrl.startsWith('https://gh-proxy.com/')) return `${registryUrl.replace(/\.json$/, '')}.sig`
152
+ return `${registryUrl.replace(/\.json$/, '')}.sig`
153
+ }
154
+
155
+ // ── manifest ─────────────────────────────────────────────────────────────────
156
+
157
+ // Manifest parsing lives in ./plugins/manifest.js. v1 and v2 both normalize to
158
+ // the same shape, so the installer and runtime below never branch on
159
+ // schemaVersion; `parseExtensionManifest` is re-exported above for callers that
160
+ // used the old entry point.
161
+
162
+ /** Like a strict URL check, but a loopback http address is also accepted — the
163
+ * offline suite and self-hosted registries live there. */
164
+ function safeTransportUrl(value) {
165
+ if (typeof value !== 'string' || value === '') return ''
166
+ try {
167
+ const parsed = new URL(value)
168
+ if (parsed.protocol === 'https:') return parsed.href
169
+ if (parsed.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(parsed.hostname)) return parsed.href
170
+ return ''
171
+ } catch { return '' }
172
+ }
173
+
174
+ // ── registry ─────────────────────────────────────────────────────────────────
175
+
176
+ /**
177
+ * Verify one registry document's Ed25519 signature over its canonical form.
178
+ * A signature that cannot be checked is a signature that is not there.
179
+ */
180
+ export function verifyRegistrySignature(registry, signatureBase64, publicKeyBase64) {
181
+ if (typeof signatureBase64 !== 'string' || signatureBase64 === '') return false
182
+ if (typeof publicKeyBase64 !== 'string' || publicKeyBase64 === '') return false
183
+ try {
184
+ const key = crypto.createPublicKey({ key: Buffer.from(publicKeyBase64, 'base64'), format: 'der', type: 'spki' })
185
+ return crypto.verify(null, Buffer.from(stableStringify(registry)), key, Buffer.from(signatureBase64, 'base64'))
186
+ } catch {
187
+ return false
188
+ }
189
+ }
190
+
191
+ /** Validate the registry document's entries; drops malformed ones silently. */
192
+ export function parseRegistry(payload) {
193
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('registry document must be a JSON object')
194
+ if (!Array.isArray(payload.plugins)) throw new Error('registry document must carry a plugins array')
195
+ const seen = new Set()
196
+ const plugins = []
197
+ for (const row of payload.plugins.slice(0, 256)) {
198
+ try {
199
+ const manifest = parsePluginManifest(row)
200
+ const files = registryFiles(row, manifest)
201
+ if (files.length === 0) throw new Error('entry carries no downloadable files')
202
+ const primary = files.find(file => file.path === manifest.main) ?? files[0]
203
+ // The cover is cosmetic — a malformed one costs the image, never the
204
+ // entry: a broken picture must not take a channel down with it.
205
+ let cover
206
+ const rawCover = row.cover !== null && typeof row.cover === 'object' && !Array.isArray(row.cover) ? row.cover : {}
207
+ const coverUrl = safeTransportUrl(rawCover.url)
208
+ if (coverUrl !== '' && hostAllowed(coverUrl)
209
+ && Number.isInteger(rawCover.size) && rawCover.size > 0 && rawCover.size <= COVER_MAX_BYTES
210
+ && typeof rawCover.sha256 === 'string' && /^[0-9a-f]{64}$/.test(rawCover.sha256)) {
211
+ const extension = coverUrl.split('.').pop().split('?')[0].toLowerCase()
212
+ if (COVER_TYPES[extension] !== undefined) cover = { url: coverUrl, size: rawCover.size, sha256: rawCover.sha256, contentType: COVER_TYPES[extension] }
213
+ }
214
+ plugins.push({
215
+ manifest,
216
+ url: primary.url,
217
+ size: primary.size,
218
+ sha256: primary.sha256,
219
+ files,
220
+ cover,
221
+ summary: typeof row.summary === 'string' ? row.summary.slice(0, 300) : '',
222
+ tags: Array.isArray(row.tags) ? row.tags.slice(0, 8).map(String) : [],
223
+ publishedAt: typeof row.publishedAt === 'string' ? row.publishedAt : '',
224
+ changelog: Array.isArray(row.changelog) ? row.changelog.slice(0, 8).map(String) : [],
225
+ })
226
+ seen.add(manifest.id)
227
+ } catch (error) {
228
+ // A broken entry is one card fewer, never a broken market. The opt-in
229
+ // diagnostic exists so an author can see which rule their row missed.
230
+ if (process.env.OFM_PLUGIN_DEBUG === '1') console.error(`plugin entry ${row?.id ?? '?'} refused: ${String(error?.message ?? error)}`)
231
+ }
232
+ }
233
+ return { schemaVersion: payload.schemaVersion === 2 ? 2 : 1, generatedAt: typeof payload.generatedAt === 'string' ? payload.generatedAt : '', plugins }
234
+ }
235
+
236
+ /** One registry file row: path + transport URL + signed size/digest. */
237
+ function registryFile(pathValue, raw, max) {
238
+ const filePath = safePluginPath(pathValue)
239
+ if (filePath === null) throw new Error(`entry file path "${pathValue}" is not a safe package path`)
240
+ const source = raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}
241
+ const url = safeTransportUrl(source.url)
242
+ if (url === '') throw new Error(`entry ${filePath}.url must be an https (or loopback http) URL`)
243
+ if (!Number.isInteger(source.size) || source.size <= 0 || source.size > max) throw new Error(`entry ${filePath}.size is out of range`)
244
+ if (typeof source.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(source.sha256)) throw new Error(`entry ${filePath}.sha256 is malformed`)
245
+ if (!hostAllowed(url)) throw new Error(`entry ${filePath}.url host is not an accepted mirror: ${url}`)
246
+ return { path: filePath, url, size: source.size, sha256: source.sha256 }
247
+ }
248
+
249
+ /**
250
+ * Collect every downloadable file a registry entry names: the code entry, the
251
+ * page bundle, and (for v1) the single `main` object. A v2 entry must not name
252
+ * a file the manifest did not declare, so the signed manifest and the signed
253
+ * file list cannot disagree about what a package contains.
254
+ */
255
+ function registryFiles(row, manifest) {
256
+ const declared = new Set(Array.isArray(manifest.files) ? manifest.files : [])
257
+ const files = []
258
+ const seen = new Set()
259
+ const add = (pathValue, raw, max) => {
260
+ const file = registryFile(pathValue, raw, max)
261
+ if (declared.size > 0 && !declared.has(file.path)) throw new Error(`entry file "${file.path}" is not in the manifest files list`)
262
+ if (seen.has(file.path)) throw new Error(`entry file "${file.path}" is listed twice`)
263
+ seen.add(file.path)
264
+ files.push(file)
265
+ }
266
+ if (manifest.main !== '') add(manifest.main, row.main, EXTENSION_SOURCE_MAX_BYTES)
267
+ const ui = row.ui !== null && typeof row.ui === 'object' && !Array.isArray(row.ui) ? row.ui : {}
268
+ const uiFiles = Array.isArray(ui.files) ? ui.files.slice(0, PAGE_FILE_LIMIT) : []
269
+ for (const file of uiFiles) {
270
+ if (file === null || typeof file !== 'object' || Array.isArray(file)) throw new Error('entry ui.files rows must be objects')
271
+ add(file.path, file, PAGE_ASSET_MAX_BYTES)
272
+ }
273
+ if (manifest.pages.length > 0) {
274
+ const listed = new Set(files.map(file => file.path))
275
+ for (const page of manifest.pages) {
276
+ if (!listed.has(page.entry)) throw new Error(`page "${page.id}" entry "${page.entry}" is missing from ui.files`)
277
+ }
278
+ const uiPaths = files.filter(file => file !== undefined && file.path !== manifest.main)
279
+ if (uiPaths.length > PAGE_FILE_LIMIT) throw new Error(`entry ships ${uiPaths.length} page files; the limit is ${PAGE_FILE_LIMIT}`)
280
+ }
281
+ return files
282
+ }
283
+
284
+ /** Only the registry repo's own hosts serve extension code — plus, when the
285
+ * registry source itself is overridden (the offline suite, a self-hosted
286
+ * mirror), that override's own origin. */
287
+ function hostAllowed(url) {
288
+ const override = process.env.OFM_EXTENSIONS_REGISTRY
289
+ if (override !== undefined && override !== '') {
290
+ try {
291
+ const registryOrigin = new URL(override).origin
292
+ const parsed = new URL(url)
293
+ if (parsed.origin === registryOrigin) return true
294
+ } catch { /* fall through to the standard hosts */ }
295
+ }
296
+ try {
297
+ const parsed = new URL(url)
298
+ if (!REGISTRY_HOSTS.has(parsed.hostname)) return false
299
+ if (parsed.protocol !== 'https:') return false
300
+ if (parsed.hostname === 'raw.githubusercontent.com') return parsed.pathname.startsWith(`/${EXTENSIONS_REPO}/`)
301
+ if (parsed.hostname.endsWith('jsdelivr.net')) return parsed.pathname.startsWith(`/gh/${EXTENSIONS_REPO}@`)
302
+ if (parsed.hostname === 'gh-proxy.com') return parsed.pathname.includes(`raw.githubusercontent.com/${EXTENSIONS_REPO}/`)
303
+ return false
304
+ } catch { return false }
305
+ }
306
+
307
+ // ── static scan ──────────────────────────────────────────────────────────────
308
+
309
+ // Code and page scans live in ./plugins/scanner.js; `scanExtensionSource` is
310
+ // re-exported above so the old import surface keeps working.
311
+
312
+ // ── the sandbox worker ───────────────────────────────────────────────────────
313
+
314
+ /**
315
+ * The worker source, inlined like the Bun worker's (`src/bun-fetch.js`): one
316
+ * Worker per enabled extension, each loading its module inside a `vm` context
317
+ * whose only import is the synthetic `ofm:sdk`. No request content is
318
+ * evaluated; the bridge talks JSON in both directions.
319
+ */
320
+ const WORKER_SOURCE = `
321
+ import { parentPort } from 'node:worker_threads'
322
+ import vm from 'node:vm'
323
+
324
+ let state = null // { manifest, hooks, config, sdk }
325
+ let rpcId = 0
326
+ const pending = new Map()
327
+
328
+ parentPort.on('message', async message => {
329
+ try {
330
+ if (message === null || typeof message !== 'object') return
331
+ if (message.event === 'rpc-reply') {
332
+ const entry = pending.get(message.id)
333
+ if (entry === undefined) return
334
+ pending.delete(message.id)
335
+ if (message.ok) entry.resolve(message.result)
336
+ else entry.reject(new Error(String(message.error ?? 'bridge call failed')))
337
+ return
338
+ }
339
+ const reply = { id: message.id }
340
+ try {
341
+ reply.result = await handle(message)
342
+ reply.ok = true
343
+ } catch (error) {
344
+ reply.ok = false
345
+ reply.error = String(error?.message ?? error)
346
+ }
347
+ parentPort.postMessage(reply)
348
+ } catch (error) {
349
+ parentPort.postMessage({ id: message?.id, ok: false, error: String(error?.message ?? error) })
350
+ }
351
+ })
352
+
353
+ function bridge(method, params) {
354
+ rpcId += 1
355
+ const id = rpcId
356
+ return new Promise((resolve, reject) => {
357
+ pending.set(id, { resolve, reject })
358
+ parentPort.postMessage({ event: 'rpc', id, method, params })
359
+ setTimeout(() => {
360
+ if (pending.has(id)) { pending.delete(id); reject(new Error('bridge call timed out')) }
361
+ }, 15000).unref()
362
+ })
363
+ }
364
+
365
+ async function handle(message) {
366
+ if (message.action === 'load') {
367
+ const manifest = message.manifest
368
+ const allowed = (manifest.permissions?.network ?? []).map(origin => String(origin).replace(/\\/+$/, ''))
369
+ const sdk = {
370
+ config: message.config ?? {},
371
+ meta: { id: manifest.id, version: manifest.version, type: manifest.type, host: message.hostVersion ?? '' },
372
+ log: (...args) => { void bridge('log', { args: args.map(value => typeof value === 'string' ? value.slice(0, 500) : JSON.stringify(value)?.slice(0, 500) ?? String(value)) }) },
373
+ fetch: async (url, init) => {
374
+ const target = new URL(String(url))
375
+ const origin = target.origin
376
+ if (!allowed.includes(origin)) throw new Error(\`network origin "\${origin}" is not in this extension's permissions\`)
377
+ if (target.protocol !== 'https:' && target.protocol !== 'http:') throw new Error('only http(s) is available')
378
+ const response = await fetch(target, {
379
+ method: init?.method ?? 'GET',
380
+ headers: init?.headers,
381
+ body: init?.body === undefined ? undefined : typeof init.body === 'string' ? init.body : JSON.stringify(init.body),
382
+ redirect: 'error',
383
+ })
384
+ const text = await response.text()
385
+ return { status: response.status, ok: response.ok, headers: Object.fromEntries(response.headers.entries()), text, json: () => JSON.parse(text) }
386
+ },
387
+ storage: {
388
+ get: async key => (await bridge('storage-get', { key })) ?? null,
389
+ set: async (key, value) => { await bridge('storage-set', { key, value }) },
390
+ delete: async key => { await bridge('storage-delete', { key }) },
391
+ },
392
+ secrets: {
393
+ get: async key => (await bridge('secrets-get', { key })) ?? null,
394
+ },
395
+ }
396
+ const context = vm.createContext({
397
+ defineChannel: value => ({ type: 'channel', value }),
398
+ defineEnhancement: value => ({ type: 'enhancement', value }),
399
+ })
400
+ const module = new vm.SourceTextModule(message.source, { identifier: manifest.main, context })
401
+ const sdkModule = new vm.SyntheticModule(['sdk'], () => {
402
+ // setExport rides the module itself (the evaluate callback's arguments
403
+ // differ across Node lines, so the closure is the portable handle).
404
+ sdkModule.setExport('sdk', sdk)
405
+ }, { context })
406
+ await module.link(specifier => {
407
+ if (specifier === 'ofm:sdk') return sdkModule
408
+ throw new Error(\`import of "\${specifier}" is not available in the extension sandbox\`)
409
+ })
410
+ await module.evaluate({ timeout: 5000 })
411
+ const exported = module.namespace.default
412
+ const value = exported?.value ?? exported
413
+ if (value === null || typeof value !== 'object') throw new Error('the module must default-export defineChannel({...}) or defineEnhancement({...})')
414
+ const declaredType = exported?.type ?? manifest.type
415
+ if (declaredType !== manifest.type) throw new Error(\`the module exports a \${declaredType} but the manifest declares a \${manifest.type}\`)
416
+ const hooks = {}
417
+ for (const hook of manifest.type === 'channel' ? ['listModels', 'prepareChat'] : (manifest.hooks ?? ['onRequest'])) {
418
+ if (typeof value[hook] === 'function') hooks[hook] = value[hook]
419
+ }
420
+ if (typeof value.selfTest === 'function') hooks.selfTest = value.selfTest
421
+ const required = manifest.type === 'channel' ? ['prepareChat'] : (manifest.hooks ?? ['onRequest'])
422
+ for (const hook of required) {
423
+ if (typeof hooks[hook] !== 'function') throw new Error(\`the module does not implement the \${hook} hook its manifest declares\`)
424
+ }
425
+ state = { manifest, hooks, config: message.config ?? {}, sdk }
426
+ return { ok: true, hooks: Object.keys(hooks) }
427
+ }
428
+ if (message.action === 'call') {
429
+ if (state === null) throw new Error('the extension is not loaded')
430
+ const hook = state.hooks[message.hook]
431
+ if (typeof hook !== 'function') throw new Error(\`the extension does not implement \${message.hook}\`)
432
+ const ctx = { manifest: state.manifest, config: state.config, ...state.sdk, ...(message.context ?? {}) }
433
+ const result = await hook(ctx, ...message.args)
434
+ return JSON.parse(JSON.stringify(result ?? null))
435
+ }
436
+ throw new Error('unknown action')
437
+ }
438
+ `
439
+
440
+ // ── the runtime ──────────────────────────────────────────────────────────────
441
+
442
+ /**
443
+ * One installed extension's live process. Owns its Worker, its RPC sequence,
444
+ * its timeout policy and its restart-on-crash; everything above this class
445
+ * speaks plain async calls.
446
+ */
447
+ class ExtensionProcess {
448
+ constructor({ manifest, source, config, secrets, hostVersion, logger }) {
449
+ this.manifest = manifest
450
+ this.source = source
451
+ this.config = config
452
+ this.secrets = secrets
453
+ this.hostVersion = hostVersion
454
+ this.logger = logger
455
+ this.worker = null
456
+ this.rpcId = 0
457
+ this.pending = new Map()
458
+ this.starting = null
459
+ this.state = 'stopped' // stopped | starting | ready | failed
460
+ this.error = ''
461
+ this.hooks = [] // what the module actually implements, reported by load
462
+ }
463
+
464
+ async start() {
465
+ if (this.starting !== null) return this.starting
466
+ if (this.worker !== null) return
467
+ this.state = 'starting'
468
+ this.starting = (async () => {
469
+ try {
470
+ this.worker = new Worker(WORKER_SOURCE, {
471
+ eval: true,
472
+ execArgv: ['--experimental-vm-modules'],
473
+ resourceLimits: { maxOldGenerationSizeMb: 64, maxYoungGenerationSizeMb: 16 },
474
+ })
475
+ this.worker.unref()
476
+ this.worker.on('message', message => this.onMessage(message))
477
+ this.worker.on('error', error => this.onDeath(String(error?.message ?? error)))
478
+ this.worker.on('exit', code => this.onDeath(code === 0 ? 'the extension stopped' : `the extension exited with code ${code}`))
479
+ const loaded = await this.request({ action: 'load', manifest: this.manifest, source: this.source, config: this.config, hostVersion: this.hostVersion }, HOOK_TIMEOUT_MS.load)
480
+ this.hooks = Array.isArray(loaded?.hooks) ? loaded.hooks : []
481
+ this.state = 'ready'
482
+ this.error = ''
483
+ } catch (error) {
484
+ this.state = 'failed'
485
+ this.error = String(error?.message ?? error)
486
+ this.kill()
487
+ throw error
488
+ } finally {
489
+ this.starting = null
490
+ }
491
+ })()
492
+ return this.starting
493
+ }
494
+
495
+ onMessage(message) {
496
+ if (message === null || typeof message !== 'object') return
497
+ if (message.event === 'rpc') {
498
+ void this.handleBridge(message)
499
+ return
500
+ }
501
+ const entry = this.pending.get(message.id)
502
+ if (entry === undefined) return
503
+ this.pending.delete(message.id)
504
+ clearTimeout(entry.timer)
505
+ if (message.ok) entry.resolve(message.result)
506
+ else entry.reject(new Error(String(message.error ?? 'the extension failed')))
507
+ }
508
+
509
+ async handleBridge(message) {
510
+ const params = message.params ?? {}
511
+ try {
512
+ if (message.method === 'log') {
513
+ this.logger?.warn?.(`extension ${this.manifest.id}: ${String(params.args ?? []).join(' ').slice(0, 500)}`)
514
+ } else if (message.method === 'storage-get') {
515
+ this.reply(message.id, true, this.storageRead()[String(params.key ?? '').slice(0, 128)] ?? null)
516
+ } else if (message.method === 'storage-set') {
517
+ const store = this.storageRead()
518
+ store[String(params.key ?? '').slice(0, 128)] = params.value ?? null
519
+ this.storageWrite(store)
520
+ this.reply(message.id, true, null)
521
+ } else if (message.method === 'storage-delete') {
522
+ const store = this.storageRead()
523
+ delete store[String(params.key ?? '').slice(0, 128)]
524
+ this.storageWrite(store)
525
+ this.reply(message.id, true, null)
526
+ } else if (message.method === 'secrets-get') {
527
+ const key = String(params.key ?? '')
528
+ if (!this.manifest.permissions.secrets.includes(key)) {
529
+ this.reply(message.id, false, `secret "${key}" is not in this extension's permissions`)
530
+ return
531
+ }
532
+ this.reply(message.id, true, this.secrets[key] ?? null)
533
+ } else {
534
+ this.reply(message.id, false, 'unknown bridge method')
535
+ }
536
+ } catch (error) {
537
+ this.reply(message.id, false, String(error?.message ?? error))
538
+ }
539
+ }
540
+
541
+ reply(id, ok, result) {
542
+ this.worker?.postMessage({ id, ok, result, event: 'rpc-reply' })
543
+ }
544
+
545
+ storagePath() { return this.paths?.storage }
546
+ storageRead() {
547
+ try { return JSON.parse(fs.readFileSync(this.storagePath(), 'utf8')) } catch { return {} }
548
+ }
549
+ storageWrite(value) {
550
+ if (this.storagePath() === undefined) return
551
+ try {
552
+ fs.mkdirSync(path.dirname(this.storagePath()), { recursive: true })
553
+ fs.writeFileSync(this.storagePath(), JSON.stringify(value, null, 1), { mode: 0o600 })
554
+ restrictPermissions(this.storagePath())
555
+ } catch { /* a full disk is logged by the next read's failure */ }
556
+ }
557
+
558
+ onDeath(reason) {
559
+ if (this.worker === null) return
560
+ this.worker = null
561
+ for (const [, entry] of this.pending) {
562
+ clearTimeout(entry.timer)
563
+ entry.reject(new Error(`the extension stopped: ${reason}`))
564
+ }
565
+ this.pending.clear()
566
+ if (this.state !== 'failed') {
567
+ this.state = 'stopped'
568
+ this.error = reason
569
+ }
570
+ }
571
+
572
+ kill() {
573
+ const worker = this.worker
574
+ this.worker = null
575
+ const termination = worker?.terminate().catch(() => {}) ?? Promise.resolve()
576
+ for (const [, entry] of this.pending) {
577
+ clearTimeout(entry.timer)
578
+ entry.reject(new Error('the extension was stopped'))
579
+ }
580
+ this.pending.clear()
581
+ if (this.state !== 'failed') this.state = 'stopped'
582
+ return termination
583
+ }
584
+
585
+ request(payload, timeoutMs) {
586
+ if (this.worker === null) return Promise.reject(new Error('the extension is not running'))
587
+ this.rpcId += 1
588
+ const id = this.rpcId
589
+ return new Promise((resolve, reject) => {
590
+ const timer = setTimeout(() => {
591
+ this.pending.delete(id)
592
+ this.state = 'failed'
593
+ this.error = `the extension did not answer ${payload.action ?? payload.hook} within ${Math.round(timeoutMs / 1000)}s`
594
+ this.logger?.warn?.(`our-free-model: extension ${this.manifest.id} ${this.error}; stopping it`)
595
+ this.kill()
596
+ reject(new Error(this.error))
597
+ }, timeoutMs)
598
+ timer.unref?.()
599
+ this.pending.set(id, { resolve, reject, timer })
600
+ this.worker.postMessage({ id, ...payload })
601
+ })
602
+ }
603
+
604
+ async call(hook, args, context, timeoutKey = hook) {
605
+ if (this.state === 'failed') throw new Error(this.error)
606
+ if (this.state !== 'ready') await this.start()
607
+ return this.request({ action: 'call', hook, args, context }, HOOK_TIMEOUT_MS[timeoutKey] ?? 10_000)
608
+ }
609
+ }
610
+
611
+ /**
612
+ * The extension runtime: the install store, the registry client, and the live
613
+ * processes of every enabled extension — the one object `index.js` holds.
614
+ */
615
+ export function createExtensionRuntime({
616
+ dataDir,
617
+ logger = console,
618
+ hostVersion = '0.0.0',
619
+ onCatalogChanged = () => {},
620
+ } = {}) {
621
+ const rootDir = path.join(dataDir, 'extensions')
622
+ const installDir = id => path.join(rootDir, 'packages', id)
623
+ const lockPath = path.join(rootDir, 'installed.json')
624
+ const registryCachePath = path.join(rootDir, 'registry-cache.json')
625
+ const auditPath = path.join(rootDir, 'audit.jsonl')
626
+ const configPath = path.join(rootDir, 'config.json')
627
+ const secretsPath = path.join(rootDir, 'secrets.json')
628
+
629
+ /** id -> ExtensionProcess (channels and enhancements, enabled only). */
630
+ const processes = new Map()
631
+ /** id -> {manifest, contributions}; always enabled, never uninstallable. */
632
+ const builtins = new Map()
633
+ /** id -> why an enabled plugin is waiting (a required host capability). */
634
+ const pending = new Map()
635
+ /** stable-per-boot capability tokens for sandboxed page assets. */
636
+ const pageTokens = new Map()
637
+ const providedCapabilities = new Set([
638
+ 'ui', 'channels', 'enhancements', 'providers', 'clients', 'formats',
639
+ 'network', 'storage', 'secrets', 'http',
640
+ ])
641
+ const pageRpcWindow = new Map()
642
+ let watchTimer = null
643
+ let registryCache = null
644
+ let channelRows = []
645
+ let channelManifests = new Map()
646
+ let disposed = false
647
+
648
+ const readJson = (file, fallback) => {
649
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return structuredClone(fallback) }
650
+ }
651
+ const writeJson = (file, value) => {
652
+ fs.mkdirSync(path.dirname(file), { recursive: true })
653
+ const staged = `${file}.tmp`
654
+ fs.writeFileSync(staged, JSON.stringify(value, null, 1), { mode: 0o600 })
655
+ fs.renameSync(staged, file)
656
+ restrictPermissions(file)
657
+ }
658
+ const readLock = () => readJson(lockPath, { version: 1, extensions: {} })
659
+ const writeLock = lock => writeJson(lockPath, lock)
660
+ const readConfigStore = () => readJson(configPath, { version: 1, values: {} })
661
+ const readSecrets = () => readJson(secretsPath, { version: 1, values: {} })
662
+ const storagePathFor = id => path.join(rootDir, 'storage', `${id}.json`)
663
+
664
+ function readStorageFile(id) {
665
+ try { return JSON.parse(fs.readFileSync(storagePathFor(id), 'utf8')) } catch { return {} }
666
+ }
667
+
668
+ function writeStorageFile(id, value) {
669
+ const file = storagePathFor(id)
670
+ fs.mkdirSync(path.dirname(file), { recursive: true })
671
+ const staged = `${file}.tmp`
672
+ fs.writeFileSync(staged, JSON.stringify(value, null, 1), { mode: 0o600 })
673
+ fs.renameSync(staged, file)
674
+ restrictPermissions(file)
675
+ }
676
+
677
+ /**
678
+ * Register a built-in contribution. Built-ins use the same manifest and
679
+ * contribution shapes as third-party plugins, but their implementation lives
680
+ * in core code: they are essential, always ready, and cannot be uninstalled.
681
+ * Registering them here is what makes the plugin center one list rather than
682
+ * two worlds.
683
+ */
684
+ function registerBuiltin(descriptor) {
685
+ if (descriptor === null || typeof descriptor !== 'object') throw new Error('builtin descriptor must be an object')
686
+ const manifest = parsePluginManifest({
687
+ schemaVersion: 2,
688
+ kind: 'bundle',
689
+ apiVersion: '^2',
690
+ version: hostVersion,
691
+ ...descriptor.manifest,
692
+ essential: true,
693
+ }, { source: 'builtin' })
694
+ if (builtins.has(manifest.id)) throw new Error(`builtin "${manifest.id}" is already registered`)
695
+ builtins.set(manifest.id, { manifest, contributions: descriptor.contributions ?? {} })
696
+ for (const capability of manifest.provides) providedCapabilities.add(capability)
697
+ return builtins.get(manifest.id)
698
+ }
699
+
700
+ function builtinRecord(entry) {
701
+ const { manifest, contributions } = entry
702
+ return {
703
+ id: manifest.id,
704
+ name: manifest.name,
705
+ version: manifest.version,
706
+ kind: manifest.kind,
707
+ kinds: manifest.kinds,
708
+ type: manifest.type,
709
+ description: manifest.description,
710
+ author: manifest.author,
711
+ license: manifest.license,
712
+ homepage: manifest.homepage,
713
+ repository: manifest.repository,
714
+ icon: manifest.icon,
715
+ permissions: manifest.permissions,
716
+ config: [],
717
+ hooks: manifest.hooks ?? [],
718
+ enabled: true,
719
+ builtin: true,
720
+ essential: true,
721
+ verified: true,
722
+ source: 'builtin',
723
+ installedAt: '',
724
+ state: 'ready',
725
+ error: '',
726
+ selfTestAvailable: false,
727
+ secrets: [],
728
+ values: {},
729
+ pages: [],
730
+ pageCount: 0,
731
+ modelCount: Array.isArray(contributions.channels) ? contributions.channels.length : 0,
732
+ provides: manifest.provides,
733
+ requires: manifest.requires,
734
+ apiVersion: manifest.apiVersion,
735
+ }
736
+ }
737
+
738
+ /** The first missing host capability, or '' when the plugin can mount. */
739
+ function missingRequirement(manifest) {
740
+ for (const capability of Array.isArray(manifest.requires) ? manifest.requires : []) {
741
+ if (!providedCapabilities.has(capability)) return `需要宿主能力:${capability}`
742
+ }
743
+ return ''
744
+ }
745
+
746
+ function audit(action, id, detail = {}) {
747
+ try {
748
+ fs.mkdirSync(rootDir, { recursive: true })
749
+ let lines = []
750
+ try { lines = fs.readFileSync(auditPath, 'utf8').split('\n').filter(Boolean).slice(-AUDIT_MAX_LINES) } catch { /* fresh log */ }
751
+ lines.push(JSON.stringify({ at: new Date().toISOString(), action, id, ...detail }))
752
+ while (lines.length > AUDIT_MAX_LINES) lines.shift()
753
+ fs.writeFileSync(auditPath, `${lines.join('\n')}\n`, { mode: 0o600 })
754
+ } catch { /* the audit trail never blocks the lane */ }
755
+ }
756
+
757
+ /** Verify an installed package still hashes to what the registry signed. */
758
+ function verifyInstalled(id) {
759
+ const record = readLock().extensions[id]
760
+ if (record === undefined) throw new Error(`extension "${id}" is not installed`)
761
+ const base = path.resolve(installDir(id), record.manifest.version)
762
+ const files = Array.isArray(record.files) && record.files.length > 0
763
+ ? record.files
764
+ : (record.manifest.main !== '' ? [{ path: record.manifest.main, sha256: record.sha256 }] : [])
765
+ let source
766
+ for (const file of files) {
767
+ const full = path.resolve(base, ...String(file.path).split('/'))
768
+ if (full !== base && !full.startsWith(`${base}${path.sep}`)) throw new Error(`extension "${id}" has an unsafe file path`)
769
+ let bytes
770
+ try { bytes = fs.readFileSync(full) } catch { throw new Error(`extension "${id}" is missing ${file.path} on disk`) }
771
+ const digest = crypto.createHash('sha256').update(bytes).digest('hex')
772
+ if (typeof file.sha256 === 'string' && digest !== file.sha256) throw new Error(`extension "${id}" failed its integrity check; reinstall it`)
773
+ if (file.path === record.manifest.main) source = bytes.toString('utf8')
774
+ }
775
+ return { record, source, files }
776
+ }
777
+
778
+ function packageFileTarget(dir, relPath) {
779
+ const base = path.resolve(dir)
780
+ const target = path.resolve(base, ...String(relPath).split('/'))
781
+ if (target === base || !target.startsWith(`${base}${path.sep}`)) throw new Error(`unsafe package path "${relPath}"`)
782
+ return target
783
+ }
784
+
785
+ function defaultEnabled(manifest) {
786
+ if (manifestHasChannel(manifest) || manifestHasPage(manifest)) return true
787
+ return false
788
+ }
789
+
790
+ async function startExtension(record, source) {
791
+ const { manifest } = record
792
+ const config = readConfigStore().values[manifest.id] ?? {}
793
+ const secrets = readSecrets().values[manifest.id] ?? {}
794
+ const paths = { storage: path.join(rootDir, 'storage', `${manifest.id}.json`) }
795
+ // Retire whatever process already holds this id before taking its slot.
796
+ // The map keeps exactly one worker per extension, so an entry overwritten
797
+ // in place would leave its thread running with no handle left to stop it —
798
+ // unreachable, unbounded, and re-running the module's `load` side effects.
799
+ const retired = processes.get(manifest.id)
800
+ if (retired !== undefined) {
801
+ processes.delete(manifest.id)
802
+ await retired.kill()
803
+ }
804
+ const process_ = new ExtensionProcess({ manifest, source, config, secrets, hostVersion, logger })
805
+ process_.paths = paths
806
+ processes.set(manifest.id, process_)
807
+ try {
808
+ await process_.start()
809
+ } catch (error) {
810
+ processes.delete(manifest.id)
811
+ throw error
812
+ }
813
+ return process_
814
+ }
815
+
816
+ async function stopExtension(id) {
817
+ const process_ = processes.get(id)
818
+ processes.delete(id)
819
+ process_?.kill()
820
+ await Promise.resolve()
821
+ }
822
+
823
+ /** Stop and start one extension so it re-reads config/secrets from disk. */
824
+ async function restartExtension(id) {
825
+ const lock = readLock()
826
+ const record = lock.extensions[id]
827
+ if (record === undefined || record.enabled !== true) return
828
+ if (record.manifest.main === '') return // a page-only plugin has no worker
829
+ try {
830
+ const { source } = verifyInstalled(id)
831
+ await stopExtension(id)
832
+ await startExtension(record, source)
833
+ if (manifestHasChannel(record.manifest)) {
834
+ channelManifests.set(id, record.manifest)
835
+ const rows = await listChannelModels(processes.get(id), record.manifest)
836
+ channelRows = [
837
+ ...channelRows.filter(row => row.extensionId !== id),
838
+ ...buildExtensionCatalog(record.manifest, rows),
839
+ ]
840
+ channelRows = await applyModelHooks(channelRows)
841
+ onCatalogChanged()
842
+ }
843
+ } catch (error) {
844
+ logger?.warn?.(`our-free-model: extension "${id}" restart failed (${String(error?.message ?? error).slice(0, 200)})`)
845
+ }
846
+ }
847
+
848
+ // ── install / uninstall ──────────────────────────────────────────────────
849
+
850
+ async function fetchSignedFile(file) {
851
+ const response = await fetch(file.url, { redirect: 'error', signal: AbortSignal.timeout(20_000) })
852
+ if (!response.ok) throw new Error(`${file.path} answered HTTP ${response.status}`)
853
+ const raw = Buffer.from(await response.arrayBuffer())
854
+ // The registry's builder signs the decoded source: `size` counts UTF-8
855
+ // code units and the digest runs over the re-encoded text. Accept that
856
+ // convention first, then the raw-byte convention for binary assets — the
857
+ // digest decides which one applies, and the stored bytes are the ones the
858
+ // signature actually covers.
859
+ const text = new TextDecoder().decode(raw)
860
+ const textBytes = Buffer.from(text, 'utf8')
861
+ const textDigest = crypto.createHash('sha256').update(textBytes).digest('hex')
862
+ if (textDigest === file.sha256) {
863
+ if (text.length !== file.size) throw new Error(`${file.path} is ${text.length} characters, the registry signed ${file.size}`)
864
+ return { bytes: textBytes, text }
865
+ }
866
+ const byteDigest = crypto.createHash('sha256').update(raw).digest('hex')
867
+ if (byteDigest === file.sha256) {
868
+ if (raw.length !== file.size) throw new Error(`${file.path} is ${raw.length} bytes, the registry signed ${file.size}`)
869
+ return { bytes: raw, text }
870
+ }
871
+ throw new Error(`${file.path} does not match its signed digest (the registry signed ${file.size})`)
872
+ }
873
+
874
+ function scanInstalledFile(manifest, file, bytes) {
875
+ const name = file.path.toLowerCase()
876
+ const text = bytes.toString('utf8')
877
+ if (file.path === manifest.main) return scanExtensionSource(text, { manifest })
878
+ if (name.endsWith('.mjs') || name.endsWith('.js') || name.endsWith('.html') || name.endsWith('.css')) {
879
+ return scanPageAsset({ path: file.path, text })
880
+ }
881
+ return { ok: true, violations: [] }
882
+ }
883
+
884
+ async function install(id, { version } = {}) {
885
+ if (disposed) throw new Error('the extension runtime is stopped')
886
+ const registry = await refreshRegistry()
887
+ const entry = registry.plugins.find(plugin => plugin.manifest.id === id)
888
+ if (entry === undefined) throw new Error(`the registry does not carry "${id}"`)
889
+ if (builtins.has(id)) throw new Error(`"${id}" is reserved by a built-in plugin`)
890
+ if (version !== undefined && entry.manifest.version !== version) throw new Error(`the registry carries "${id}" at ${entry.manifest.version}, not ${version}`)
891
+ if (compareVersions(hostVersion, entry.manifest.minHost) < 0) {
892
+ throw new Error(`the extension requires host >= ${entry.manifest.minHost}, this host is ${hostVersion}`)
893
+ }
894
+ if (entry.manifest.apiCompatible === false) {
895
+ throw new Error(`the extension requires plugin API ${entry.manifest.apiVersion}, this host implements 2.x`)
896
+ }
897
+ const lock = readLock()
898
+ if (Object.keys(lock.extensions).length >= MAX_INSTALLED && lock.extensions[id] === undefined) {
899
+ throw new Error(`at most ${MAX_INSTALLED} extensions may be installed at once`)
900
+ }
901
+ // Download every file the signed registry names, verify size + digest,
902
+ // then scan before the first byte reaches the package directory.
903
+ const downloaded = []
904
+ for (const file of entry.files) {
905
+ const { bytes } = await fetchSignedFile(file)
906
+ const scan = scanInstalledFile(entry.manifest, file, bytes)
907
+ if (!scan.ok) throw new Error(`the extension failed its pre-install scan: ${file.path}: ${scan.violations.join('; ')}`)
908
+ downloaded.push({ file, bytes })
909
+ }
910
+ const dir = path.join(installDir(id), entry.manifest.version)
911
+ fs.mkdirSync(dir, { recursive: true })
912
+ for (const { file, bytes } of downloaded) {
913
+ const target = packageFileTarget(dir, file.path)
914
+ fs.mkdirSync(path.dirname(target), { recursive: true })
915
+ fs.writeFileSync(target, bytes, { mode: 0o600 })
916
+ restrictPermissions(target)
917
+ }
918
+ const previous = lock.extensions[id]
919
+ lock.extensions[id] = {
920
+ manifest: entry.manifest,
921
+ sha256: entry.sha256,
922
+ url: entry.url,
923
+ files: entry.files.map(file => ({ path: file.path, size: file.size, sha256: file.sha256 })),
924
+ source: 'registry',
925
+ verified: true,
926
+ installedAt: new Date().toISOString(),
927
+ enabled: previous?.enabled ?? defaultEnabled(entry.manifest),
928
+ updateSeen: previous?.updateSeen,
929
+ }
930
+ writeLock(lock)
931
+ audit('install', id, { version: entry.manifest.version, sha256: entry.sha256, files: entry.files.length, previous: previous?.manifest.version })
932
+ // The bytes may differ even when the version matches (a republished
933
+ // package). Retire the running worker first so remount's reuse path cannot
934
+ // keep serving the source that was on disk a moment ago.
935
+ await stopExtension(id)
936
+ await remount()
937
+ onCatalogChanged()
938
+ return publicRecord(lock.extensions[id])
939
+ }
940
+
941
+ /**
942
+ * Install a local, unsigned package from a directory. Developer mode only:
943
+ * the caller (the management layer) enforces the setting and the explicit
944
+ * acknowledgement. The package must declare every file it ships; nothing
945
+ * outside that list is read or copied.
946
+ */
947
+ async function installLocal({ path: sourceDir, acknowledgeUnsigned = false } = {}) {
948
+ if (disposed) throw new Error('the extension runtime is stopped')
949
+ if (acknowledgeUnsigned !== true) throw new Error('local plugins require an explicit unsigned-package acknowledgement')
950
+ if (typeof sourceDir !== 'string' || sourceDir.trim() === '') throw new Error('local plugin path is required')
951
+ const root = fs.realpathSync(path.resolve(sourceDir))
952
+ if (!fs.statSync(root).isDirectory()) throw new Error('local plugin path must be a directory')
953
+ const manifestFile = path.join(root, 'ofm-plugin.json')
954
+ let raw
955
+ try { raw = JSON.parse(fs.readFileSync(manifestFile, 'utf8')) } catch { throw new Error('local plugin is missing a readable ofm-plugin.json') }
956
+ const manifest = parsePluginManifest(raw, { source: 'local' })
957
+ if (builtins.has(manifest.id)) throw new Error(`"${manifest.id}" is reserved by a built-in plugin`)
958
+ if (compareVersions(hostVersion, manifest.minHost) < 0) {
959
+ throw new Error(`the plugin requires host >= ${manifest.minHost}, this host is ${hostVersion}`)
960
+ }
961
+ if (manifest.apiCompatible === false) {
962
+ throw new Error(`the plugin requires API ${manifest.apiVersion}, this host implements 2.x`)
963
+ }
964
+ const declared = Array.isArray(manifest.files) ? manifest.files : []
965
+ if (declared.length === 0) throw new Error('local plugins must list every shipped file in manifest.files')
966
+ if (manifest.main === '' && manifest.pages.length === 0) throw new Error('local plugin ships no code entry and no page')
967
+ const lock = readLock()
968
+ if (Object.keys(lock.extensions).length >= MAX_INSTALLED && lock.extensions[manifest.id] === undefined) {
969
+ throw new Error(`at most ${MAX_INSTALLED} extensions may be installed at once`)
970
+ }
971
+ const staged = []
972
+ for (const relPath of declared) {
973
+ const source = path.resolve(root, ...relPath.split('/'))
974
+ if (!source.startsWith(`${root}${path.sep}`)) throw new Error(`local plugin file "${relPath}" escapes its directory`)
975
+ let stat
976
+ try { stat = fs.lstatSync(source) } catch { throw new Error(`local plugin file "${relPath}" is missing`) }
977
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`local plugin file "${relPath}" must be a regular file`)
978
+ const max = relPath === manifest.main ? EXTENSION_SOURCE_MAX_BYTES : PAGE_ASSET_MAX_BYTES
979
+ if (stat.size <= 0 || stat.size > max) throw new Error(`local plugin file "${relPath}" is out of range`)
980
+ const bytes = fs.readFileSync(source)
981
+ const scan = scanInstalledFile(manifest, { path: relPath }, bytes)
982
+ if (!scan.ok) throw new Error(`local plugin failed its scan: ${relPath}: ${scan.violations.join('; ')}`)
983
+ staged.push({ path: relPath, bytes, size: bytes.length, sha256: crypto.createHash('sha256').update(bytes).digest('hex') })
984
+ }
985
+ const dir = path.join(installDir(manifest.id), manifest.version)
986
+ fs.mkdirSync(dir, { recursive: true })
987
+ for (const file of staged) {
988
+ const target = packageFileTarget(dir, file.path)
989
+ fs.mkdirSync(path.dirname(target), { recursive: true })
990
+ fs.writeFileSync(target, file.bytes, { mode: 0o600 })
991
+ restrictPermissions(target)
992
+ }
993
+ const previous = lock.extensions[manifest.id]
994
+ const primary = staged.find(file => file.path === manifest.main) ?? staged[0]
995
+ lock.extensions[manifest.id] = {
996
+ manifest,
997
+ sha256: primary.sha256,
998
+ url: `local:${root}`,
999
+ files: staged.map(file => ({ path: file.path, size: file.size, sha256: file.sha256 })),
1000
+ source: 'local',
1001
+ verified: false,
1002
+ installedAt: new Date().toISOString(),
1003
+ enabled: previous?.enabled ?? defaultEnabled(manifest),
1004
+ updateSeen: previous?.updateSeen,
1005
+ }
1006
+ writeLock(lock)
1007
+ audit('install-local', manifest.id, { version: manifest.version, files: staged.length })
1008
+ await stopExtension(manifest.id)
1009
+ await remount()
1010
+ onCatalogChanged()
1011
+ return publicRecord(lock.extensions[manifest.id])
1012
+ }
1013
+
1014
+ function invalidatePageTokens(id) {
1015
+ for (const [token, value] of pageTokens) if (value.pluginId === id) pageTokens.delete(token)
1016
+ }
1017
+
1018
+ async function uninstall(id) {
1019
+ if (builtins.has(id)) throw new Error(`builtin plugin "${id}" cannot be uninstalled`)
1020
+ const lock = readLock()
1021
+ if (lock.extensions[id] === undefined) throw new Error(`extension "${id}" is not installed`)
1022
+ await stopExtension(id)
1023
+ delete lock.extensions[id]
1024
+ writeLock(lock)
1025
+ invalidatePageTokens(id)
1026
+ fs.rmSync(installDir(id), { recursive: true, force: true })
1027
+ audit('uninstall', id)
1028
+ await remount()
1029
+ onCatalogChanged()
1030
+ }
1031
+
1032
+ async function setEnabled(id, enabled) {
1033
+ if (builtins.has(id)) throw new Error(`builtin plugin "${id}" is always enabled`)
1034
+ const lock = readLock()
1035
+ const record = lock.extensions[id]
1036
+ if (record === undefined) throw new Error(`extension "${id}" is not installed`)
1037
+ record.enabled = enabled === true
1038
+ writeLock(lock)
1039
+ invalidatePageTokens(id)
1040
+ audit('set-enabled', id, { enabled: record.enabled })
1041
+ await remount()
1042
+ onCatalogChanged()
1043
+ return publicRecord(record)
1044
+ }
1045
+
1046
+ async function saveConfig(id, values) {
1047
+ const lock = readLock()
1048
+ const record = lock.extensions[id]
1049
+ if (record === undefined) throw new Error(`extension "${id}" is not installed`)
1050
+ const store = readConfigStore()
1051
+ const previous = store.values[id] ?? {}
1052
+ const clean = {}
1053
+ for (const field of record.manifest.config) {
1054
+ if (!(field.key in values)) continue
1055
+ clean[field.key] = sanitizeValue(values[field.key], field)
1056
+ }
1057
+ store.values[id] = { ...previous, ...clean }
1058
+ writeJson(configPath, store)
1059
+ // The worker captured its config at load; a running extension must restart
1060
+ // to see new values (a hot patch would race the module's own reads). The
1061
+ // caller awaits the restart, so the next turn already sees the new values.
1062
+ await restartExtension(id)
1063
+ return publicRecord(record)
1064
+ }
1065
+
1066
+ async function saveSecret(id, key, value) {
1067
+ const lock = readLock()
1068
+ const record = lock.extensions[id]
1069
+ if (record === undefined) throw new Error(`extension "${id}" is not installed`)
1070
+ if (!record.manifest.permissions.secrets.includes(key)) throw new Error(`secret "${key}" is not declared by "${id}"`)
1071
+ const store = readSecrets()
1072
+ store.values[id] = { ...(store.values[id] ?? {}), [key]: String(value ?? '') }
1073
+ writeJson(secretsPath, store)
1074
+ audit('set-secret', id, { key })
1075
+ // Same freshness rule as saveConfig: secrets are read at load.
1076
+ await restartExtension(id)
1077
+ return publicRecord(record)
1078
+ }
1079
+
1080
+ function sanitizeValue(value, field) {
1081
+ const type = field.type
1082
+ if (type === 'toggle') return value === true
1083
+ if (type === 'number') {
1084
+ const parsed = Number(value)
1085
+ if (Number.isFinite(parsed)) return parsed
1086
+ return Number.isFinite(Number(field.default)) ? Number(field.default) : 0
1087
+ }
1088
+ if (type === 'select') {
1089
+ // Only a listed option is a value; anything else falls back to the
1090
+ // default the manifest signed, then to the first option.
1091
+ const options = field.options ?? []
1092
+ const chosen = String(value ?? '')
1093
+ if (options.includes(chosen)) return chosen
1094
+ if (options.includes(String(field.default))) return String(field.default)
1095
+ return options[0] ?? ''
1096
+ }
1097
+ return String(value ?? '').slice(0, 300)
1098
+ }
1099
+
1100
+ /** The manifest's optional selfTest(ctx), run on demand from the marketplace
1101
+ * card. The hook speaks for itself: a bare boolean, or an {ok, message};
1102
+ * a thrown hook is the failure. Disabled extensions have no process to
1103
+ * ask — that is a verdict, not an error. */
1104
+ async function runSelfTest(id) {
1105
+ const lock = readLock()
1106
+ const record = lock.extensions[id]
1107
+ if (record === undefined) throw new Error(`extension "${id}" is not installed`)
1108
+ if (record.enabled !== true) return { ok: false, message: 'the extension is disabled' }
1109
+ const process_ = processes.get(id)
1110
+ if (process_ === undefined) return { ok: false, message: 'the extension is not running' }
1111
+ try {
1112
+ const result = await process_.call('selfTest', [])
1113
+ if (result && typeof result === 'object' && typeof result.ok === 'boolean') {
1114
+ return { ok: result.ok, message: typeof result.message === 'string' ? result.message.slice(0, 200) : '' }
1115
+ }
1116
+ return { ok: true, message: '' }
1117
+ } catch (error) {
1118
+ return { ok: false, message: String(error?.message ?? error).slice(0, 200) }
1119
+ }
1120
+ }
1121
+
1122
+ // ── registry ─────────────────────────────────────────────────────────────
1123
+
1124
+ /** The in-flight registry read: the boot warm-up, a page's first look and a
1125
+ * manual refresh all want the same document, and racing them would fetch the
1126
+ * index three times. */
1127
+ let refreshing = null
1128
+ /** Why the last read failed, or '' when it succeeded. The page needs the
1129
+ * sentence, not just the fact: "unreachable" and "failed its signature
1130
+ * check" send the reader after different problems. */
1131
+ let registryFailure = ''
1132
+
1133
+ /** The registry snapshot shipped with the build. It is a build-time artifact,
1134
+ * not a network trust root: used only when every mirror fails, so a fresh
1135
+ * install still shows the official shelf instead of an empty market. */
1136
+ let bundledRegistry
1137
+ function readBundledRegistry() {
1138
+ if (bundledRegistry !== undefined) return bundledRegistry
1139
+ try {
1140
+ const payload = REGISTRY_FALLBACK
1141
+ bundledRegistry = { payload, view: parseRegistry(payload) }
1142
+ } catch {
1143
+ bundledRegistry = null
1144
+ }
1145
+ return bundledRegistry
1146
+ }
1147
+
1148
+ async function refreshRegistry({ force = false } = {}) {
1149
+ const cached = readJson(registryCachePath, null)
1150
+ if (cached !== null && !force && Date.now() - (cached.fetchedAt ?? 0) < REGISTRY_TTL_MS && Array.isArray(cached.registry?.plugins)) {
1151
+ registryCache = cached
1152
+ return cached.view
1153
+ }
1154
+ if (refreshing !== null) return refreshing
1155
+ const run = readRegistry(cached)
1156
+ refreshing = run
1157
+ try {
1158
+ return await run
1159
+ } finally {
1160
+ if (refreshing === run) refreshing = null
1161
+ }
1162
+ }
1163
+
1164
+ /** One pass over the mirror list, first alive wins. */
1165
+ async function readRegistry(cached) {
1166
+ const errors = []
1167
+ for (const source of registrySources()) {
1168
+ try {
1169
+ const [registryResponse, signatureResponse] = await Promise.all([
1170
+ fetch(source, { redirect: 'error', signal: AbortSignal.timeout(10_000) }),
1171
+ fetch(signatureUrlFor(source), { redirect: 'error', signal: AbortSignal.timeout(10_000) }),
1172
+ ])
1173
+ if (!registryResponse.ok) throw new Error(`HTTP ${registryResponse.status}`)
1174
+ const text = await registryResponse.text()
1175
+ if (text.length > REGISTRY_MAX_BYTES) throw new Error(`the registry is ${text.length} bytes; the limit is ${REGISTRY_MAX_BYTES}`)
1176
+ const payload = JSON.parse(text)
1177
+ const signature = (await signatureResponse.text()).trim()
1178
+ const publicKey = process.env.OFM_EXTENSIONS_PUBLIC_KEY ?? PINNED_EXTENSIONS_PUBLIC_KEY
1179
+ const allowUnsigned = process.env.OFM_EXTENSIONS_ALLOW_UNSIGNED === '1' && process.env.OFM_EXTENSIONS_REGISTRY !== undefined
1180
+ if (!allowUnsigned && !verifyRegistrySignature(payload, signature, publicKey)) {
1181
+ throw new Error('the registry failed its signature check')
1182
+ }
1183
+ const view = parseRegistry(payload)
1184
+ if (view.plugins.length === 0) throw new Error('the registry carries no readable entries')
1185
+ registryCache = { fetchedAt: Date.now(), source, registry: payload, signature, view }
1186
+ writeJson(registryCachePath, registryCache)
1187
+ registryFailure = ''
1188
+ return view
1189
+ } catch (error) {
1190
+ errors.push(`${new URL(source).hostname}: ${String(error?.message ?? error).slice(0, 120)}`)
1191
+ }
1192
+ }
1193
+ registryFailure = errors.join('; ')
1194
+ if (cached !== null && Array.isArray(cached.registry?.plugins)) {
1195
+ logger?.warn?.(`our-free-model: the extension registry could not be refreshed (${errors.join('; ')}); serving the cache from ${new Date(cached.fetchedAt).toISOString()}`)
1196
+ registryCache = cached
1197
+ return cached.view
1198
+ }
1199
+ const bundled = readBundledRegistry()
1200
+ if (bundled !== null && bundled.view.plugins.length > 0) {
1201
+ logger?.warn?.(`our-free-model: the extension registry could not be refreshed (${errors.join('; ')}); serving the bundled shelf`)
1202
+ registryCache = { fetchedAt: 0, source: 'bundled', registry: bundled.payload, signature: '', view: bundled.view }
1203
+ return bundled.view
1204
+ }
1205
+ throw new Error(`the extension registry is unreachable (${errors.join('; ')})`)
1206
+ }
1207
+
1208
+ /**
1209
+ * The registry for a first paint, with a bounded wait for a read already in
1210
+ * flight. Without this the page a user opens during the boot warm-up is told
1211
+ * the index is unreadable — for a document that lands a few hundred
1212
+ * milliseconds later — and never asks again.
1213
+ */
1214
+ async function registryStatusSoon(waitMs = 3000) {
1215
+ if (registryCache === null && refreshing !== null) {
1216
+ // The warm-up owns the failure; this caller only wants to stop waiting.
1217
+ await Promise.race([
1218
+ refreshing.catch(() => {}),
1219
+ new Promise(resolve => { const timer = setTimeout(resolve, waitMs); timer.unref?.() }),
1220
+ ])
1221
+ }
1222
+ return { registry: registryView(), error: registryCache === null ? registryFailure : '' }
1223
+ }
1224
+
1225
+ // ── covers ───────────────────────────────────────────────────────────────
1226
+
1227
+ let coverFetchSeq = 0
1228
+
1229
+ /**
1230
+ * One entry's cover image, verified and cached: the marketplace card's
1231
+ * banner. The bytes are display-only, so the failure mode is deliberately
1232
+ * soft — `null` means "render without a cover", never an error to the page.
1233
+ * Digest-checked against the signed registry before the first write, then
1234
+ * served from the data dir by digest (immutable per sha256).
1235
+ */
1236
+ async function coverImage(id) {
1237
+ const entry = registryCache?.view?.plugins?.find(plugin => plugin.manifest.id === id)
1238
+ const cover = entry?.cover
1239
+ if (cover === undefined) return null
1240
+ const extension = cover.url.split('.').pop().split('?')[0].toLowerCase()
1241
+ const cachePath = path.join(rootDir, 'covers', `${cover.sha256}.${extension}`)
1242
+ try {
1243
+ const cached = fs.readFileSync(cachePath)
1244
+ if (crypto.createHash('sha256').update(cached).digest('hex') === cover.sha256) {
1245
+ return { bytes: cached, contentType: cover.contentType }
1246
+ }
1247
+ } catch { /* not cached yet — fetch below */ }
1248
+ try {
1249
+ const response = await fetch(cover.url, { redirect: 'error', signal: AbortSignal.timeout(15_000) })
1250
+ if (!response.ok) return null
1251
+ const bytes = Buffer.from(await response.arrayBuffer())
1252
+ if (bytes.length !== cover.size) return null
1253
+ if (crypto.createHash('sha256').update(bytes).digest('hex') !== cover.sha256) return null
1254
+ // A unique staged name: two parallel requests for the same cover must
1255
+ // not interleave into one truncated file — each renames its own complete
1256
+ // buffer, and the loser's rename is a no-op overwrite of equal bytes.
1257
+ const staged = `${cachePath}.${Date.now()}-${coverFetchSeq += 1}.tmp`
1258
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true })
1259
+ fs.writeFileSync(staged, bytes, { mode: 0o644 })
1260
+ fs.renameSync(staged, cachePath)
1261
+ return { bytes, contentType: cover.contentType }
1262
+ } catch {
1263
+ // A mirror hiccup costs the banner for this request only; the next one
1264
+ // (or the browser's own retry) tries again.
1265
+ return null
1266
+ }
1267
+ }
1268
+
1269
+ // ── lifecycle ────────────────────────────────────────────────────────────
1270
+
1271
+ /** (Re)start every enabled extension and rebuild the channel lane's rows. */
1272
+ async function remount() {
1273
+ if (disposed) return
1274
+ const lock = readLock()
1275
+ pending.clear()
1276
+ // Retire only the processes nobody wants any more: one whose extension was
1277
+ // removed or disabled, one whose version changed, and one that already
1278
+ // failed. Everything else keeps its worker — a reconcile that rebuilt the
1279
+ // healthy ones would re-run their `load` on every marketplace action, and
1280
+ // the replaced worker would leak.
1281
+ for (const [id, process_] of [...processes]) {
1282
+ const record = lock.extensions[id]
1283
+ const wanted = record !== undefined && record.enabled === true
1284
+ && record.manifest.version === process_.manifest.version
1285
+ if (wanted && process_.state !== 'failed') continue
1286
+ processes.delete(id)
1287
+ process_.kill()
1288
+ }
1289
+ channelRows = []
1290
+ channelManifests = new Map()
1291
+ for (const [id, record] of Object.entries(lock.extensions)) {
1292
+ if (record.enabled !== true) continue
1293
+ const missing = missingRequirement(record.manifest)
1294
+ if (missing !== '') {
1295
+ pending.set(id, missing)
1296
+ const stale = processes.get(id)
1297
+ if (stale !== undefined) { processes.delete(id); stale.kill() }
1298
+ continue
1299
+ }
1300
+ // A page-only plugin has no worker; its contribution is the files on disk.
1301
+ if (record.manifest.main === '') continue
1302
+ try {
1303
+ const { source } = verifyInstalled(id)
1304
+ const existing = processes.get(id)
1305
+ const process_ = existing !== undefined ? existing : await startExtension(record, source)
1306
+ if (manifestHasChannel(record.manifest)) {
1307
+ channelManifests.set(id, record.manifest)
1308
+ const rows = await listChannelModels(process_, record.manifest)
1309
+ channelRows = channelRows.concat(buildExtensionCatalog(record.manifest, rows))
1310
+ }
1311
+ } catch (error) {
1312
+ // A row whose bytes no longer hash to the signed digest is not served:
1313
+ // stop it, and let the warning say why.
1314
+ const stale = processes.get(id)
1315
+ if (stale !== undefined) {
1316
+ processes.delete(id)
1317
+ stale.kill()
1318
+ }
1319
+ logger?.warn?.(`our-free-model: extension "${id}" did not start (${String(error?.message ?? error).slice(0, 200)})`)
1320
+ }
1321
+ }
1322
+ channelRows = await applyModelHooks(channelRows)
1323
+ }
1324
+
1325
+ async function listChannelModels(process_, manifest) {
1326
+ if (Array.isArray(manifest.models)) return manifest.models
1327
+ try {
1328
+ return await process_.call('listModels', [], { manifest }, 'listModels')
1329
+ } catch (error) {
1330
+ logger?.warn?.(`our-free-model: extension "${manifest.id}" model list failed (${String(error?.message ?? error).slice(0, 200)})`)
1331
+ return []
1332
+ }
1333
+ }
1334
+
1335
+ /**
1336
+ * Enhancement `onModels`: a fail-open filter over the extension lane's rows.
1337
+ * A hook may remove or rewrite rows but cannot introduce a row that is not
1338
+ * already an extension row, and a thrown/timed-out hook ships the catalog
1339
+ * unchanged. This is the hook the manifest has always declared; it is wired
1340
+ * here so it is not a dead surface.
1341
+ */
1342
+ async function applyModelHooks(rows) {
1343
+ let current = Array.isArray(rows) ? rows : []
1344
+ for (const [id, process_] of processes) {
1345
+ if (!manifestHasEnhancement(process_.manifest)) continue
1346
+ if (!Array.isArray(process_.manifest.hooks) || !process_.manifest.hooks.includes('onModels')) continue
1347
+ try {
1348
+ const result = await process_.call('onModels', [current], { manifest: process_.manifest }, 'onModels')
1349
+ if (Array.isArray(result)) {
1350
+ current = result.filter(row => row !== null && typeof row === 'object'
1351
+ && typeof row.id === 'string' && row.channel === 'extension')
1352
+ }
1353
+ } catch (error) {
1354
+ logger?.warn?.(`our-free-model: enhancement "${id}" onModels failed (${String(error?.message ?? error).slice(0, 160)}); the catalog ships unchanged`)
1355
+ }
1356
+ }
1357
+ return current
1358
+ }
1359
+
1360
+ // ── the lane surface the adapter calls ───────────────────────────────────
1361
+
1362
+ /**
1363
+ * Turn one harness request into the upstream call an extension channel
1364
+ * serves. The adapter has already built the wire-shaped payload; the
1365
+ * extension names where it goes and with which headers, and may rewrite the
1366
+ * body (never the model or the stream flag).
1367
+ */
1368
+ async function prepareChat(entry, payload, session) {
1369
+ const process_ = processes.get(entry.extensionId)
1370
+ if (process_ === undefined) throw new Error(`extension "${entry.extensionId}" is not running`)
1371
+ const request = {
1372
+ model: entry.modelId,
1373
+ wire: entry.wire,
1374
+ messages: payload.messages ?? payload.input ?? [],
1375
+ tools: payload.tools,
1376
+ maxTokens: payload.max_tokens ?? payload.max_completion_tokens,
1377
+ temperature: payload.temperature,
1378
+ sessionId: session ?? '',
1379
+ }
1380
+ const prepared = await process_.call('prepareChat', [request], { manifest: process_.manifest }, 'prepareChat')
1381
+ if (prepared === null || typeof prepared !== 'object' || typeof prepared.url !== 'string') {
1382
+ throw new Error(`extension "${entry.extensionId}" returned no url for this turn`)
1383
+ }
1384
+ const target = new URL(prepared.url)
1385
+ const allowed = process_.manifest.permissions.network
1386
+ if (!allowed.includes(target.origin)) {
1387
+ throw new Error(`extension "${entry.extensionId}" named ${target.origin}, which its permissions do not allow`)
1388
+ }
1389
+ if (target.protocol !== 'https:' && target.protocol !== 'http:') {
1390
+ throw new Error(`extension "${entry.extensionId}" named a ${target.protocol} endpoint`)
1391
+ }
1392
+ const headers = {}
1393
+ for (const [key, value] of Object.entries(prepared.headers ?? {})) {
1394
+ const name = String(key).toLowerCase()
1395
+ // The extension's own credentials ride its headers; the hop-by-hop set
1396
+ // and anything that would re-frame or re-address the request do not.
1397
+ if (['host', 'content-length', 'connection', 'transfer-encoding', 'cookie'].includes(name)) continue
1398
+ if (typeof value !== 'string' || value.length > 2048) continue
1399
+ headers[name] = value
1400
+ }
1401
+ let body = prepared.body
1402
+ if (body !== undefined && body !== null) {
1403
+ if (typeof body === 'string') {
1404
+ try { body = JSON.parse(body) } catch { throw new Error(`extension "${entry.extensionId}" returned a body that is not JSON`) }
1405
+ }
1406
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) {
1407
+ throw new Error(`extension "${entry.extensionId}" returned a non-object body`)
1408
+ }
1409
+ } else {
1410
+ body = { ...payload }
1411
+ delete body.sessionId
1412
+ }
1413
+ body.model = entry.modelId
1414
+ body.stream = true
1415
+ if (entry.wire === 'chat' && body.stream_options === undefined) body.stream_options = { include_usage: true }
1416
+ const wire = ['chat', 'responses', 'messages'].includes(prepared.wire) ? prepared.wire : entry.wire
1417
+ // Serialized: the shared streaming core posts bodies as bytes, exactly as
1418
+ // the aggregate lane's resolver hands it a string.
1419
+ return { url: target.href, headers, body: JSON.stringify(body), wire, model: entry.modelId, secret: '' }
1420
+ }
1421
+
1422
+ /** Enhancement hook: rewrite one outgoing body (fail-open). */
1423
+ async function applyRequestHooks(payload, meta) {
1424
+ let body = payload
1425
+ for (const [id, process_] of processes) {
1426
+ if (!manifestHasEnhancement(process_.manifest)) continue
1427
+ if (!Array.isArray(process_.manifest.hooks) || !process_.manifest.hooks.includes('onRequest')) continue
1428
+ try {
1429
+ const result = await process_.call('onRequest', [body, meta], { manifest: process_.manifest }, 'onRequest')
1430
+ if (result !== null && typeof result === 'object' && !Array.isArray(result)) body = result
1431
+ } catch (error) {
1432
+ logger?.warn?.(`our-free-model: enhancement "${id}" onRequest failed (${String(error?.message ?? error).slice(0, 160)}); the body ships unchanged`)
1433
+ }
1434
+ }
1435
+ return body
1436
+ }
1437
+
1438
+ /** Enhancement hook: observe one usage record (never mutates it). */
1439
+ function noteUsage(record) {
1440
+ for (const [id, process_] of processes) {
1441
+ if (!manifestHasEnhancement(process_.manifest)) continue
1442
+ if (!Array.isArray(process_.manifest.hooks) || !process_.manifest.hooks.includes('onUsage')) continue
1443
+ process_.call('onUsage', [record, {}], { manifest: process_.manifest }, 'onUsage')
1444
+ .catch(error => logger?.warn?.(`our-free-model: enhancement "${id}" onUsage failed (${String(error?.message ?? error).slice(0, 160)})`))
1445
+ }
1446
+ }
1447
+
1448
+ // ── read models for the API ──────────────────────────────────────────────
1449
+
1450
+ const MASK = value => typeof value === 'string' && value.length >= 8 ? `…${value.slice(-4)}` : '…'
1451
+
1452
+ const PAGE_MIME = {
1453
+ html: 'text/html; charset=utf-8',
1454
+ js: 'text/javascript; charset=utf-8',
1455
+ mjs: 'text/javascript; charset=utf-8',
1456
+ css: 'text/css; charset=utf-8',
1457
+ svg: 'image/svg+xml',
1458
+ png: 'image/png',
1459
+ jpg: 'image/jpeg',
1460
+ jpeg: 'image/jpeg',
1461
+ webp: 'image/webp',
1462
+ gif: 'image/gif',
1463
+ woff2: 'font/woff2',
1464
+ json: 'application/json; charset=utf-8',
1465
+ txt: 'text/plain; charset=utf-8',
1466
+ }
1467
+
1468
+ const pageTokenKeys = new Map()
1469
+
1470
+ function pageTokenFor(pluginId, version, pageId, entry) {
1471
+ const key = `${pluginId}@${version}:${pageId}`
1472
+ let token = pageTokenKeys.get(key)
1473
+ if (token === undefined || !pageTokens.has(token)) {
1474
+ token = crypto.randomBytes(16).toString('hex')
1475
+ pageTokenKeys.set(key, token)
1476
+ pageTokens.set(token, { pluginId, version, pageId, entry })
1477
+ }
1478
+ return token
1479
+ }
1480
+
1481
+ function recordFor(pluginId) {
1482
+ if (builtins.has(pluginId)) return { builtin: true, entry: builtins.get(pluginId), record: undefined }
1483
+ const record = readLock().extensions[pluginId]
1484
+ return record === undefined ? undefined : { builtin: false, record, entry: undefined }
1485
+ }
1486
+
1487
+ /** Every page contribution the console may render, one per enabled plugin. */
1488
+ function pages() {
1489
+ const out = []
1490
+ for (const [id, entry] of builtins) {
1491
+ for (const page of Array.isArray(entry.contributions?.pages) ? entry.contributions.pages : []) {
1492
+ const token = pageTokenFor(id, entry.manifest.version, page.id, page.entry)
1493
+ out.push({
1494
+ id: `plugin:${id}:${page.id}`,
1495
+ pluginId: id,
1496
+ pageId: page.id,
1497
+ title: page.title,
1498
+ description: page.description ?? '',
1499
+ group: page.group ?? '插件',
1500
+ order: page.order ?? 100,
1501
+ icon: page.icon ?? 'puzzle',
1502
+ url: `/api/management/plugins/assets/${token}/${page.entry}`,
1503
+ builtin: true,
1504
+ version: entry.manifest.version,
1505
+ })
1506
+ }
1507
+ }
1508
+ const lock = readLock()
1509
+ for (const [id, record] of Object.entries(lock.extensions)) {
1510
+ if (record.enabled !== true || pending.has(id)) continue
1511
+ for (const page of Array.isArray(record.manifest.pages) ? record.manifest.pages : []) {
1512
+ const token = pageTokenFor(id, record.manifest.version, page.id, page.entry)
1513
+ out.push({
1514
+ id: `plugin:${id}:${page.id}`,
1515
+ pluginId: id,
1516
+ pageId: page.id,
1517
+ title: page.title,
1518
+ description: page.description ?? '',
1519
+ group: page.group ?? '插件',
1520
+ order: page.order ?? 100,
1521
+ icon: page.icon ?? 'puzzle',
1522
+ url: `/api/management/plugins/assets/${token}/${page.entry}`,
1523
+ builtin: false,
1524
+ version: record.manifest.version,
1525
+ })
1526
+ }
1527
+ }
1528
+ return out.sort((left, right) => left.order - right.order || left.title.localeCompare(right.title))
1529
+ }
1530
+
1531
+ /**
1532
+ * One declared page asset. The token is the capability: a sandboxed iframe
1533
+ * has an opaque origin and no session, so the path itself authorizes exactly
1534
+ * the files the plugin declared and nothing else.
1535
+ */
1536
+ function pageAsset(token, relPath) {
1537
+ const value = pageTokens.get(String(token ?? ''))
1538
+ if (value === undefined) return null
1539
+ const found = recordFor(value.pluginId)
1540
+ if (found === undefined) return null
1541
+ const manifest = found.builtin ? found.entry.manifest : found.record.manifest
1542
+ const enabled = found.builtin || (found.record.enabled === true && !pending.has(value.pluginId))
1543
+ if (!enabled) return null
1544
+ if (!found.builtin && found.record.manifest.version !== value.version) return null
1545
+ const pathValue = safePluginPath(relPath)
1546
+ if (pathValue === null || pathValue === manifest.main) return null
1547
+ const files = found.builtin
1548
+ ? (Array.isArray(found.entry.contributions?.files) ? found.entry.contributions.files : [])
1549
+ : (Array.isArray(found.record.files) ? found.record.files.map(file => file.path) : [])
1550
+ if (!files.includes(pathValue)) return null
1551
+ const recordFiles = found.builtin || !Array.isArray(found.record.files) ? [] : found.record.files
1552
+ const base = found.builtin
1553
+ ? path.join(rootDir, 'builtin-assets', value.pluginId)
1554
+ : path.join(installDir(value.pluginId), value.version)
1555
+ let bytes
1556
+ try { bytes = fs.readFileSync(packageFileTarget(base, pathValue)) } catch { return null }
1557
+ if (!found.builtin) {
1558
+ const expected = recordFiles.find(file => file.path === pathValue)?.sha256
1559
+ if (typeof expected === 'string' && crypto.createHash('sha256').update(bytes).digest('hex') !== expected) return null
1560
+ }
1561
+ const extension = pathValue.split('.').pop().toLowerCase()
1562
+ return { bytes, type: PAGE_MIME[extension] ?? 'application/octet-stream', pluginId: value.pluginId, pageId: value.pageId }
1563
+ }
1564
+
1565
+ function configWithDefaults(manifest) {
1566
+ const values = readConfigStore().values[manifest.id] ?? {}
1567
+ const out = {}
1568
+ for (const field of manifest.config) out[field.key] = field.default
1569
+ return { ...out, ...values }
1570
+ }
1571
+
1572
+ /**
1573
+ * A page plugin's only door to the host. Mirrored by the bridge injected
1574
+ * into its HTML; every method is allowlisted here, and the iframe never sees
1575
+ * a secret value or a management session.
1576
+ */
1577
+ async function pageRpc(pluginId, pageId, method, params = {}) {
1578
+ const found = recordFor(String(pluginId ?? ''))
1579
+ if (found === undefined) throw new Error('plugin is not installed')
1580
+ const manifest = found.builtin ? found.entry.manifest : found.record.manifest
1581
+ const enabled = found.builtin || (found.record.enabled === true && !pending.has(manifest.id))
1582
+ if (!enabled) throw new Error('plugin is not enabled')
1583
+ if (!manifest.pages.some(page => page.id === pageId)) throw new Error('page is not declared by this plugin')
1584
+ const key = manifest.id
1585
+ const now = Date.now()
1586
+ const window_ = pageRpcWindow.get(key)
1587
+ if (window_ === undefined || now - window_.at > 60_000) pageRpcWindow.set(key, { at: now, count: 1 })
1588
+ else if (++window_.count > 120) throw new Error('页面调用过于频繁,请稍后再试')
1589
+ const name = String(method ?? '')
1590
+ if (name === 'plugin.info') {
1591
+ return { id: manifest.id, name: manifest.name, version: manifest.version, pageId }
1592
+ }
1593
+ if (name === 'config.get') return configWithDefaults(manifest)
1594
+ if (name === 'storage.get' || name === 'storage.set' || name === 'storage.delete') {
1595
+ if (manifest.permissions.storage !== true) throw new Error('this plugin did not declare storage permission')
1596
+ const store = readStorageFile(manifest.id)
1597
+ const storageKey = String(params.key ?? '').slice(0, 128)
1598
+ if (storageKey === '') throw new Error('storage key is required')
1599
+ if (RESERVED_KEYS.has(storageKey)) throw new Error('storage key is reserved')
1600
+ if (name === 'storage.get') return Object.hasOwn(store, storageKey) ? store[storageKey] : null
1601
+ if (name === 'storage.set') {
1602
+ store[storageKey] = params.value ?? null
1603
+ writeStorageFile(manifest.id, store)
1604
+ return null
1605
+ }
1606
+ delete store[storageKey]
1607
+ writeStorageFile(manifest.id, store)
1608
+ return null
1609
+ }
1610
+ if (name === 'http.fetch') {
1611
+ const target = String(params.url ?? '')
1612
+ let url
1613
+ try { url = new URL(target) } catch { throw new Error('invalid URL') }
1614
+ if (!manifest.permissions.network.includes(url.origin)) throw new Error(`network origin "${url.origin}" is not in this plugin's permissions`)
1615
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error('only http(s) is available')
1616
+ const requestMethod = String(params.method ?? 'GET').toUpperCase()
1617
+ if (requestMethod !== 'GET' && requestMethod !== 'POST') throw new Error('only GET and POST are available')
1618
+ const headers = {}
1619
+ if (params.headers !== null && typeof params.headers === 'object' && !Array.isArray(params.headers)) {
1620
+ for (const [header, value] of Object.entries(params.headers).slice(0, 32)) {
1621
+ const headerName = String(header).toLowerCase()
1622
+ if (RESERVED_KEYS.has(headerName)) continue
1623
+ if (['host', 'content-length', 'connection', 'transfer-encoding', 'cookie'].includes(headerName)) continue
1624
+ if (typeof value === 'string' && value.length <= 2048) headers[headerName] = value
1625
+ }
1626
+ }
1627
+ const body = params.body === undefined || params.body === null ? undefined
1628
+ : typeof params.body === 'string' ? params.body.slice(0, 64 * 1024)
1629
+ : JSON.stringify(params.body).slice(0, 64 * 1024)
1630
+ const response = await fetch(url, {
1631
+ method: requestMethod, headers, body,
1632
+ redirect: 'error', signal: AbortSignal.timeout(20_000),
1633
+ })
1634
+ const text = (await response.text()).slice(0, 1024 * 1024)
1635
+ return { status: response.status, ok: response.ok, headers: Object.fromEntries(response.headers.entries()), text }
1636
+ }
1637
+ throw new Error(`unknown page method "${name}"`)
1638
+ }
1639
+
1640
+ function auditTail(limit = 100) {
1641
+ const count = Number.isInteger(limit) ? Math.max(1, Math.min(200, limit)) : 100
1642
+ let lines = []
1643
+ try { lines = fs.readFileSync(auditPath, 'utf8').split('\n').filter(Boolean) } catch { return [] }
1644
+ const out = []
1645
+ for (const line of lines.slice(-count)) {
1646
+ try { out.push(JSON.parse(line)) } catch { /* one corrupt line is one row fewer */ }
1647
+ }
1648
+ return out
1649
+ }
1650
+
1651
+ function contributions() {
1652
+ const out = {
1653
+ pages: pages(), channels: [], enhancements: [], providers: [], clients: [], formats: [], themes: [],
1654
+ }
1655
+ for (const [, entry] of builtins) {
1656
+ for (const key of ['channels', 'providers', 'clients', 'formats', 'themes']) {
1657
+ if (Array.isArray(entry.contributions?.[key])) out[key].push(...entry.contributions[key])
1658
+ }
1659
+ }
1660
+ const lock = readLock()
1661
+ for (const [id, record] of Object.entries(lock.extensions)) {
1662
+ if (record.enabled !== true || pending.has(id)) continue
1663
+ if (manifestHasChannel(record.manifest)) out.channels.push({ id, name: record.manifest.name, kind: 'installed' })
1664
+ if (manifestHasEnhancement(record.manifest)) out.enhancements.push({ id, name: record.manifest.name, hooks: record.manifest.hooks ?? [] })
1665
+ }
1666
+ return out
1667
+ }
1668
+
1669
+ /** Optional 2s reconciler for config/lock edits made outside the API. */
1670
+ function startWatching({ intervalMs = 2000 } = {}) {
1671
+ if (watchTimer !== null) return
1672
+ const stamps = new Map()
1673
+ const stamp = file => { try { const stat = fs.statSync(file); return `${stat.mtimeMs}:${stat.size}` } catch { return '' } }
1674
+ const files = [lockPath, configPath, secretsPath]
1675
+ for (const file of files) stamps.set(file, stamp(file))
1676
+ let running = false
1677
+ watchTimer = setInterval(async () => {
1678
+ if (disposed || running) return
1679
+ let changed = false
1680
+ for (const file of files) {
1681
+ const next = stamp(file)
1682
+ if (next !== stamps.get(file)) { stamps.set(file, next); changed = true }
1683
+ }
1684
+ if (!changed) return
1685
+ running = true
1686
+ try { await remount() } catch { /* the next tick retries */ } finally { running = false }
1687
+ }, Math.max(500, intervalMs))
1688
+ watchTimer.unref?.()
1689
+ }
1690
+
1691
+ function publicRecord(record) {
1692
+ const manifest = record.manifest
1693
+ const process_ = processes.get(manifest.id)
1694
+ const wait = pending.get(manifest.id)
1695
+ const state = record.enabled !== true ? 'disabled'
1696
+ : wait !== undefined ? 'pending'
1697
+ : manifest.main === '' ? 'ready'
1698
+ : process_ === undefined ? 'stopped' : process_.state
1699
+ return {
1700
+ id: manifest.id,
1701
+ name: manifest.name,
1702
+ version: manifest.version,
1703
+ kind: manifest.kind,
1704
+ kinds: manifest.kinds,
1705
+ type: manifest.type,
1706
+ description: manifest.description,
1707
+ author: manifest.author,
1708
+ license: manifest.license,
1709
+ homepage: manifest.homepage,
1710
+ repository: manifest.repository,
1711
+ icon: manifest.icon,
1712
+ replaces: manifest.replaces,
1713
+ permissions: manifest.permissions,
1714
+ config: manifest.config,
1715
+ hooks: manifest.hooks ?? [],
1716
+ enabled: record.enabled === true,
1717
+ builtin: false,
1718
+ essential: false,
1719
+ source: record.source ?? 'registry',
1720
+ verified: record.verified !== false,
1721
+ installedAt: record.installedAt,
1722
+ state,
1723
+ error: wait ?? process_?.error ?? '',
1724
+ apiVersion: manifest.apiVersion,
1725
+ requires: manifest.requires,
1726
+ provides: manifest.provides,
1727
+ selfTestAvailable: process_?.hooks?.includes('selfTest') === true,
1728
+ secrets: manifest.permissions.secrets.map(key => {
1729
+ const current = (readSecrets().values[manifest.id] ?? {})[key]
1730
+ return { key, hasValue: typeof current === 'string' && current !== '', hint: typeof current === 'string' && current.length >= 4 ? MASK(current) : '' }
1731
+ }),
1732
+ values: readConfigStore().values[manifest.id] ?? {},
1733
+ pages: Array.isArray(manifest.pages) ? manifest.pages : [],
1734
+ pageCount: Array.isArray(manifest.pages) ? manifest.pages.length : 0,
1735
+ fileCount: Array.isArray(record.files) ? record.files.length : (record.manifest.main === '' ? 0 : 1),
1736
+ modelCount: channelManifests.has(manifest.id)
1737
+ ? channelRows.filter(row => row.extensionId === manifest.id).length
1738
+ : undefined,
1739
+ }
1740
+ }
1741
+
1742
+ function listInstalled() {
1743
+ const records = [...builtins.values()].map(entry => builtinRecord(entry))
1744
+ const lock = readLock()
1745
+ records.push(...Object.values(lock.extensions).map(record => publicRecord(record)))
1746
+ return records
1747
+ }
1748
+
1749
+ function installedVersions() {
1750
+ return Object.fromEntries(Object.entries(readLock().extensions).map(([id, record]) => [id, record.manifest.version]))
1751
+ }
1752
+
1753
+ /** Registry entries joined with install state — the marketplace page's data. */
1754
+ function registryView() {
1755
+ const lock = readLock()
1756
+ const view = registryCache?.view ?? null
1757
+ if (view === null) return null
1758
+ return {
1759
+ generatedAt: view.generatedAt,
1760
+ plugins: view.plugins.map(entry => {
1761
+ const installed = lock.extensions[entry.manifest.id]
1762
+ return {
1763
+ ...entry.manifest,
1764
+ summary: entry.summary,
1765
+ tags: entry.tags,
1766
+ publishedAt: entry.publishedAt,
1767
+ changelog: entry.changelog,
1768
+ cover: entry.cover,
1769
+ // The signed source file's own URL — the detail view's "read the code
1770
+ // before you trust it" link.
1771
+ sourceUrl: entry.url,
1772
+ fileCount: entry.files.length,
1773
+ installed: installed !== undefined,
1774
+ installedVersion: installed?.manifest.version,
1775
+ enabled: installed?.enabled === true,
1776
+ updateAvailable: installed !== undefined && compareVersions(installed.manifest.version, entry.manifest.version) < 0,
1777
+ // The install button's gate: host floor and plugin API range.
1778
+ installable: compareVersions(hostVersion, entry.manifest.minHost) >= 0 && entry.manifest.apiCompatible !== false,
1779
+ state: pending.get(entry.manifest.id) !== undefined ? 'pending' : (processes.get(entry.manifest.id)?.state ?? 'stopped'),
1780
+ }
1781
+ }),
1782
+ }
1783
+ }
1784
+
1785
+ /**
1786
+ * Every installed extension the live registry carries at a newer version.
1787
+ *
1788
+ * The first observation of a newer version stamps `updateSeen` into the
1789
+ * install record — that stamp, not the registry's own timestamps, starts the
1790
+ * auto-update soak clock. A read here is also the only place the stamp is
1791
+ * written: the marketplace page calls this on every load, so a version the
1792
+ * user saw in the UI is a version the soak has been counting for.
1793
+ */
1794
+ function updatesAvailable() {
1795
+ const view = registryCache?.view ?? null
1796
+ if (view === null) return []
1797
+ const lock = readLock()
1798
+ let stamped = false
1799
+ const now = Date.now()
1800
+ const out = []
1801
+ for (const entry of view.plugins) {
1802
+ const record = lock.extensions[entry.manifest.id]
1803
+ if (record === undefined) continue
1804
+ if (compareVersions(record.manifest.version, entry.manifest.version) >= 0) continue
1805
+ // An update the host could not even run (minHost floor, dead API range)
1806
+ // never enters the soak: the badge would promise an install that the
1807
+ // gate refuses the moment it fires.
1808
+ if (compareVersions(hostVersion, entry.manifest.minHost) < 0 || entry.manifest.apiCompatible === false) continue
1809
+ const seenAt = typeof record.updateSeen === 'number' && record.updateSeen > 0 ? record.updateSeen : now
1810
+ if (seenAt === now) { record.updateSeen = seenAt; stamped = true }
1811
+ out.push({
1812
+ id: entry.manifest.id,
1813
+ from: record.manifest.version,
1814
+ to: entry.manifest.version,
1815
+ seenAt,
1816
+ ready: now - seenAt >= AUTO_UPDATE_SOAK_MS,
1817
+ soakRemainingMs: Math.max(0, AUTO_UPDATE_SOAK_MS - (now - seenAt)),
1818
+ enabled: record.enabled === true,
1819
+ })
1820
+ }
1821
+ if (stamped === true) writeLock(lock)
1822
+ return out
1823
+ }
1824
+
1825
+ /**
1826
+ * The automatic updater, driven by the registry tick (6h) rather than the
1827
+ * page: an update that has soaked past the window installs itself, then the
1828
+ * same install/verify/scan path a button press takes. Disabled extensions
1829
+ * are never auto-enabled — a update the user parked stays parked.
1830
+ */
1831
+ async function autoUpdate() {
1832
+ const updates = updatesAvailable().filter(update => update.ready === true && update.enabled === true)
1833
+ const installedRows = []
1834
+ const failed = []
1835
+ for (const update of updates) {
1836
+ try {
1837
+ const record = await install(update.id, { version: update.to })
1838
+ installedRows.push({ id: update.id, from: update.from, to: record.version })
1839
+ audit('auto-update', update.id, { from: update.from, to: update.to })
1840
+ } catch (error) {
1841
+ failed.push({ id: update.id, reason: String(error?.message ?? error).slice(0, 200) })
1842
+ }
1843
+ }
1844
+ return { installed: installedRows, failed, checked: updates.length }
1845
+ }
1846
+
1847
+ /** Where the served registry snapshot came from, for the page's source pill:
1848
+ * a `bundled` answer reads very differently from a live mirror one. */
1849
+ function registryMeta() {
1850
+ return {
1851
+ source: registryCache?.source ?? '',
1852
+ fetchedAt: typeof registryCache?.fetchedAt === 'number' ? registryCache.fetchedAt : 0,
1853
+ generatedAt: registryCache?.view?.generatedAt ?? '',
1854
+ soakHours: AUTO_UPDATE_SOAK_MS / 3_600_000,
1855
+ }
1856
+ }
1857
+
1858
+ return {
1859
+ init: remount,
1860
+ dispose() {
1861
+ disposed = true
1862
+ if (watchTimer !== null) { clearInterval(watchTimer); watchTimer = null }
1863
+ pageTokens.clear()
1864
+ pageTokenKeys.clear()
1865
+ const terminations = []
1866
+ for (const [, process_] of processes) terminations.push(process_.kill())
1867
+ processes.clear()
1868
+ // Worker termination is async; the host's dispose effect may return this
1869
+ // so a hot reload does not race a dying worker against a fresh mount.
1870
+ return Promise.allSettled(terminations)
1871
+ },
1872
+ install, installLocal, uninstall, setEnabled, saveConfig, saveSecret, runSelfTest,
1873
+ refreshRegistry, registryStatusSoon, coverImage, updatesAvailable, autoUpdate, registryMeta,
1874
+ listInstalled, registryView, installedVersions,
1875
+ registerBuiltin, contributions, pages, pageAsset, pageRpc, auditTail, startWatching,
1876
+ // The lane surface.
1877
+ get catalog() { return channelRows },
1878
+ get replacesLane() {
1879
+ for (const manifest of channelManifests.values()) {
1880
+ const lane = extensionReplaces(manifest)
1881
+ if (lane !== null) return lane
1882
+ }
1883
+ return null
1884
+ },
1885
+ prepareChat, applyRequestHooks, noteUsage,
1886
+ isRunning: id => processes.get(id)?.state === 'ready',
1887
+ }
1888
+ }