@ebony-vinyl/dsh-our-free-model 2.8.0 → 2.8.3
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.
- package/README.md +12 -7
- package/client.js +894 -6
- package/index.js +245 -8
- package/package.json +41 -3
- package/src/adapter.js +54 -6
- package/src/aggregate.js +53 -13
- package/src/bun-fetch.js +33 -10
- package/src/catalog.js +140 -16
- package/src/core.js +122 -33
- package/src/egress.js +204 -3
- package/src/engine-parts/win32-x64.000.part +0 -0
- package/src/engine-parts/win32-x64.001.part +0 -0
- package/src/engine-parts/win32-x64.002.part +0 -0
- package/src/engine-parts/win32-x64.003.part +0 -0
- package/src/engine-parts/win32-x64.004.part +0 -0
- package/src/engine-parts/win32-x64.005.part +0 -0
- package/src/engine-parts/win32-x64.006.part +0 -0
- package/src/extensions.js +1888 -0
- package/src/feed.js +6 -0
- package/src/http.js +14 -3
- package/src/kilo.js +55 -5
- package/src/magpie-engine.js +73 -3
- package/src/magpie-models.js +31 -3
- package/src/model-test.js +3 -1
- package/src/o2a.js +207 -8
- package/src/open-external.js +43 -5
- package/src/plugins.js +1240 -0
- package/src/providers/registry.json +72 -30
- package/src/semver.js +40 -0
- package/src/store.js +17 -1
- package/src/stream.js +39 -4
- package/src/subscription-completion.js +11 -2
- package/src/updater.js +5 -33
- package/src/upstream.js +43 -0
- package/vendor/channel-pack/NOTICE.md +1 -1
- package/vendor/channel-pack/pack.js +7242 -3952
- package/vendor/magpie/NOTICE.md +221 -5
- package/vendor/magpie/engine.json +35 -15
|
@@ -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
|
+
}
|