@hoardodile/host 0.0.0

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.
Files changed (144) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +73 -0
  3. package/dist/chunks/worker-entry.mjs +422 -0
  4. package/dist/container-fKxefO7p.d.ts +58 -0
  5. package/dist/contract/index.d.ts +49 -0
  6. package/dist/contract/index.js +187 -0
  7. package/dist/contract/index.js.map +1 -0
  8. package/dist/hoard/index.d.ts +754 -0
  9. package/dist/hoard/index.js +1939 -0
  10. package/dist/hoard/index.js.map +1 -0
  11. package/dist/image-Dx2Be3Bj.d.ts +168 -0
  12. package/dist/index.d.ts +788 -0
  13. package/dist/index.js +3311 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/media/index.d.ts +96 -0
  16. package/dist/media/index.js +152 -0
  17. package/dist/media/index.js.map +1 -0
  18. package/dist/probe/index.d.ts +113 -0
  19. package/dist/probe/index.js +454 -0
  20. package/dist/probe/index.js.map +1 -0
  21. package/dist/probe-cache-BkRI7HEK.d.ts +25 -0
  22. package/dist/render/index.d.ts +125 -0
  23. package/dist/render/index.js +430 -0
  24. package/dist/render/index.js.map +1 -0
  25. package/package.json +103 -0
  26. package/src/__testutils__/zip-fixtures.ts +139 -0
  27. package/src/activation.ts +116 -0
  28. package/src/api-types.ts +60 -0
  29. package/src/api.test.ts +564 -0
  30. package/src/api.ts +501 -0
  31. package/src/archive/7z.test.ts +222 -0
  32. package/src/archive/7z.ts +261 -0
  33. package/src/archive/archive-ops.test.ts +654 -0
  34. package/src/archive/extract-archive.test.ts +237 -0
  35. package/src/archive/extract-archive.ts +534 -0
  36. package/src/archive/extract.ts +693 -0
  37. package/src/archive/format.ts +49 -0
  38. package/src/archive/index.ts +116 -0
  39. package/src/archive/listing.ts +121 -0
  40. package/src/archive/materialize.ts +88 -0
  41. package/src/archive/name-decode.test.ts +34 -0
  42. package/src/archive/name-decode.ts +287 -0
  43. package/src/archive/nested-cd-cache.ts +55 -0
  44. package/src/archive/nested-entry.test.ts +194 -0
  45. package/src/archive/nested-entry.ts +254 -0
  46. package/src/archive/pack.ts +40 -0
  47. package/src/archive/zip-entries.test.ts +56 -0
  48. package/src/archive/zip-entries.ts +336 -0
  49. package/src/capability-guard.ts +73 -0
  50. package/src/container.ts +62 -0
  51. package/src/contract/container-fixture.ts +74 -0
  52. package/src/contract/index.ts +10 -0
  53. package/src/contract/suite.test.ts +101 -0
  54. package/src/contract/suite.ts +205 -0
  55. package/src/define-plugin.test.ts +53 -0
  56. package/src/define-plugin.ts +12 -0
  57. package/src/dev-runner.test.ts +98 -0
  58. package/src/dev-runner.ts +73 -0
  59. package/src/directory-api.test.ts +229 -0
  60. package/src/directory-api.ts +42 -0
  61. package/src/directory-container.ts +141 -0
  62. package/src/discovery.ts +295 -0
  63. package/src/errors.ts +74 -0
  64. package/src/fixtures.ts +8 -0
  65. package/src/hash.test.ts +301 -0
  66. package/src/hash.ts +167 -0
  67. package/src/hoard/dir-size.ts +58 -0
  68. package/src/hoard/index.ts +84 -0
  69. package/src/hoard/migrate-script.test.ts +539 -0
  70. package/src/hoard/order-manifest.test.ts +115 -0
  71. package/src/hoard/order-manifest.ts +131 -0
  72. package/src/hoard/paths.test.ts +226 -0
  73. package/src/hoard/paths.ts +569 -0
  74. package/src/hoard/plugin-vault.test.ts +160 -0
  75. package/src/hoard/plugin-vault.ts +233 -0
  76. package/src/hoard/sanitize.test.ts +149 -0
  77. package/src/hoard/sanitize.ts +230 -0
  78. package/src/hoard/staging-dir.ts +133 -0
  79. package/src/hoard/version.test.ts +285 -0
  80. package/src/hoard/version.ts +216 -0
  81. package/src/hoard/versioned-folder-ops.ts +152 -0
  82. package/src/hoard/write-versioned.test.ts +67 -0
  83. package/src/hoard/write-versioned.ts +39 -0
  84. package/src/hooks.test.ts +528 -0
  85. package/src/hooks.ts +428 -0
  86. package/src/index.ts +135 -0
  87. package/src/loader.test.ts +442 -0
  88. package/src/loader.ts +213 -0
  89. package/src/media/index.ts +16 -0
  90. package/src/media/render-cache.ts +27 -0
  91. package/src/media/seekable.ts +35 -0
  92. package/src/media/thumb-input.test.ts +109 -0
  93. package/src/media/thumb-input.ts +194 -0
  94. package/src/nested-view.test.ts +223 -0
  95. package/src/nested-view.ts +239 -0
  96. package/src/probe/audio.test.ts +135 -0
  97. package/src/probe/audio.ts +140 -0
  98. package/src/probe/av.ts +84 -0
  99. package/src/probe/ffprobe.ts +197 -0
  100. package/src/probe/image.test.ts +251 -0
  101. package/src/probe/image.ts +336 -0
  102. package/src/probe/index.ts +54 -0
  103. package/src/probe/sniff.test.ts +72 -0
  104. package/src/probe/sniff.ts +67 -0
  105. package/src/probe/video.test.ts +41 -0
  106. package/src/probe/video.ts +116 -0
  107. package/src/probe-cache.test.ts +89 -0
  108. package/src/probe-cache.ts +65 -0
  109. package/src/render/ffmpeg.ts +78 -0
  110. package/src/render/index.ts +34 -0
  111. package/src/render/pipeline.test.ts +187 -0
  112. package/src/render/pipeline.ts +479 -0
  113. package/src/sandbox/call-timers.ts +68 -0
  114. package/src/sandbox/fixtures/api-error-plugin.mjs +11 -0
  115. package/src/sandbox/fixtures/asset-plugin.mjs +13 -0
  116. package/src/sandbox/fixtures/chatty-plugin.mjs +14 -0
  117. package/src/sandbox/fixtures/container-plugin.mjs +8 -0
  118. package/src/sandbox/fixtures/context-plugin.mjs +19 -0
  119. package/src/sandbox/fixtures/crash-plugin.mjs +6 -0
  120. package/src/sandbox/fixtures/echo-plugin.mjs +17 -0
  121. package/src/sandbox/fixtures/exit-plugin.mjs +6 -0
  122. package/src/sandbox/fixtures/fetch-plugin.mjs +8 -0
  123. package/src/sandbox/fixtures/flood-api-plugin.mjs +8 -0
  124. package/src/sandbox/fixtures/flood-log-plugin.mjs +8 -0
  125. package/src/sandbox/fixtures/hostile-dynamic-plugin.mjs +9 -0
  126. package/src/sandbox/fixtures/hostile-plugin.mjs +13 -0
  127. package/src/sandbox/fixtures/huge-result-plugin.mjs +8 -0
  128. package/src/sandbox/fixtures/logging-plugin.mjs +9 -0
  129. package/src/sandbox/fixtures/range-plugin.mjs +9 -0
  130. package/src/sandbox/fixtures/slow-api-plugin.mjs +11 -0
  131. package/src/sandbox/fixtures/spin-plugin.mjs +9 -0
  132. package/src/sandbox/fixtures/stuck-plugin.mjs +12 -0
  133. package/src/sandbox/fixtures/thrower-plugin.mjs +7 -0
  134. package/src/sandbox/host.test.ts +619 -0
  135. package/src/sandbox/host.ts +933 -0
  136. package/src/sandbox/host.unit.test.ts +330 -0
  137. package/src/sandbox/protocol.test.ts +85 -0
  138. package/src/sandbox/protocol.ts +147 -0
  139. package/src/sandbox/sandboxed-plugin.ts +30 -0
  140. package/src/sandbox/worker-entry.mjs +422 -0
  141. package/src/seed.test.ts +102 -0
  142. package/src/seed.ts +160 -0
  143. package/src/settings-store.ts +26 -0
  144. package/src/types.ts +21 -0
package/src/api.ts ADDED
@@ -0,0 +1,501 @@
1
+ import type { Readable } from "node:stream"
2
+ import type {
3
+ ContainerListing,
4
+ ImageHashKind,
5
+ PluginSchema,
6
+ } from "@hoardodile/sdk-types"
7
+ import { fileTypeFromName, pluginAssetError } from "@hoardodile/sdk-types"
8
+ import { MIME_FFMPEG_INPUT_FORMAT } from "@hoardodile/sdk-types/media-exts"
9
+ import { PLUGIN_READ_FILE_MAX_BYTES } from "@hoardodile/sdk-types/plugin"
10
+ import type { ArchiveEntry, NestedCdCache } from "./archive/index.ts"
11
+ import {
12
+ type ArchiveExtractor,
13
+ createArchiveExtractor,
14
+ type ExtractionResult,
15
+ type ExtractProgress,
16
+ } from "./archive/index.ts"
17
+ import type { ResourceContainer } from "./container.ts"
18
+ import {
19
+ computeDHash,
20
+ computePHash,
21
+ grayStddev,
22
+ hashStream,
23
+ MIN_PERCEPTUAL_STDDEV,
24
+ PERCEPTUAL_HASH_KINDS,
25
+ PHASH_GRID,
26
+ } from "./hash.ts"
27
+ import { createNestedAwareContainer } from "./nested-view.ts"
28
+ import type { AvProbeOptions } from "./probe/av.ts"
29
+ import {
30
+ type ImageMetadataInput,
31
+ type ImageSourceProbe,
32
+ isAnimatedCandidateExt,
33
+ PROBE_HEADER_BYTES,
34
+ sharpFromReadable,
35
+ } from "./probe/image.ts"
36
+ import { SNIFF_HEADER_BYTES, sniffBytes } from "./probe/sniff.ts"
37
+ import type { PluginProbeCache } from "./probe-cache.ts"
38
+ import type {
39
+ FileType,
40
+ ProbeResult,
41
+ ReadFileRange,
42
+ ResourceAPI,
43
+ } from "./types.ts"
44
+
45
+ // The directory-backed ResourceAPI is part of the host so the CLI and the
46
+ // contract suite run the same implementation the server's import path uses.
47
+ export {
48
+ createDirectoryResourceAPI as createImportResourceAPI,
49
+ resolveSafeImportPath,
50
+ } from "./directory-api.ts"
51
+
52
+ /**
53
+ * Construct a {@link ResourceAPI} on top of a {@link ResourceContainer}.
54
+ * The container abstracts away the storage shape (bare-file resource
55
+ * folder, raw directory, in-memory fixture) so plugin code stays
56
+ * unaware of how source bytes are stored.
57
+ */
58
+ export type CreatePluginResourceAPIDeps = {
59
+ readonly view: ResourceContainer
60
+ /**
61
+ * Probe implementations, both optional: when absent the matching
62
+ * branch of `probe` resolves to
63
+ * `{ kind: "unknown", reason: "unavailable" }` without opening any
64
+ * stream — the directory backend ships without probes by design.
65
+ * `sniff` never needs a backend; it reads the file's own header.
66
+ */
67
+ readonly probeImage?: (
68
+ source: ImageMetadataInput,
69
+ extHint?: string,
70
+ ) => Promise<ImageSourceProbe | undefined>
71
+ readonly probeAv?: (
72
+ source: string | Readable,
73
+ opts: AvProbeOptions,
74
+ ) => Promise<ProbeResult>
75
+ /** Per-call `readFile` byte cap. Defaults to {@link PLUGIN_READ_FILE_MAX_BYTES}. */
76
+ readonly maxReadFileBytes?: number
77
+ /**
78
+ * Process-wide probe cache. Only active together with
79
+ * {@link cacheScope}; without it every probe opens a fresh stream.
80
+ */
81
+ readonly probeCache?: PluginProbeCache
82
+ /**
83
+ * Cache namespace for this API instance, typically
84
+ * `${resId}:${fileVersion}`. Archives are immutable per version, so
85
+ * cached probe results never need explicit invalidation.
86
+ */
87
+ readonly cacheScope?: string
88
+ /**
89
+ * Absolute directory for plugin container extractions (e.g. the
90
+ * server's `local/cache/resources/<id>/extracted/v<N>/archives`, or a
91
+ * temp dir for the CLI). When absent, `extractArchive` rejects with
92
+ * a clear message.
93
+ */
94
+ readonly extractCacheDir?: string
95
+ /**
96
+ * Hard caps for one `extractArchive` call. Defaults to
97
+ * {@link DEFAULT_PLUGIN_EXTRACT_MAX_BYTES} /
98
+ * {@link DEFAULT_PLUGIN_EXTRACT_MAX_ENTRIES}.
99
+ */
100
+ readonly maxExtractBytes?: number
101
+ readonly maxExtractEntries?: number
102
+ /**
103
+ * Called with per-entry materialization progress during
104
+ * `extractArchive`, so the server can surface it to the user.
105
+ */
106
+ readonly onExtractProgress?: (progress: ExtractProgress) => void
107
+ /**
108
+ * Process-wide nested central-directory cache. When provided, virtual
109
+ * path resolution (`outer!inner`) reuses parsed listings across hook
110
+ * calls instead of re-reading each archive's CD per invocation. The
111
+ * server passes one cache per (resId, fileVersion) scope.
112
+ */
113
+ readonly nestedCdCache?: NestedCdCache
114
+ /**
115
+ * Session context handed to hooks as `api.context.detect`: the
116
+ * payload a prior `detect` invocation returned on a successful
117
+ * match. The sandbox worker injects it itself; this escape hatch
118
+ * lets in-process hosts (the dev runner) do the same.
119
+ */
120
+ readonly detectContext?: unknown
121
+ }
122
+
123
+ export const DEFAULT_PLUGIN_EXTRACT_MAX_BYTES = 8 * 1024 * 1024 * 1024
124
+ export const DEFAULT_PLUGIN_EXTRACT_MAX_ENTRIES = 200_000
125
+
126
+ export function createPluginResourceAPI<
127
+ TSchema extends PluginSchema = PluginSchema,
128
+ >(deps: CreatePluginResourceAPIDeps): ResourceAPI<TSchema> {
129
+ // Virtual paths (`outer!inner`) resolve through this wrapper, so
130
+ // readFile/sniff/probe/stat/hash all reach into nested containers.
131
+ // The cacheScope disambiguates nested-cache keys when one shared
132
+ // cache serves many resources (see createNestedAwareContainer); the
133
+ // extract cache dir extends virtual addressing to *extracted*
134
+ // archives (non-zip formats become addressable once materialized).
135
+ const view = createNestedAwareContainer(
136
+ deps.view,
137
+ deps.nestedCdCache,
138
+ deps.cacheScope,
139
+ deps.extractCacheDir,
140
+ )
141
+ const maxReadFileBytes = deps.maxReadFileBytes ?? PLUGIN_READ_FILE_MAX_BYTES
142
+
143
+ const extractor: ArchiveExtractor | undefined =
144
+ deps.extractCacheDir === undefined
145
+ ? undefined
146
+ : createArchiveExtractor({
147
+ outer: {
148
+ sizeOf: (rel) =>
149
+ deps.view.resolveByteRange(rel).then((r) => r?.size),
150
+ readSlice: (rel, start, end) =>
151
+ deps.view.readEntrySlice(rel, start, end),
152
+ },
153
+ cacheDir: deps.extractCacheDir,
154
+ maxBytes: deps.maxExtractBytes ?? DEFAULT_PLUGIN_EXTRACT_MAX_BYTES,
155
+ maxEntries:
156
+ deps.maxExtractEntries ?? DEFAULT_PLUGIN_EXTRACT_MAX_ENTRIES,
157
+ probeImage: deps.probeImage,
158
+ onProgress: deps.onExtractProgress,
159
+ nestedCdCache: deps.nestedCdCache,
160
+ })
161
+
162
+ async function readFileScoped(
163
+ path: string,
164
+ range?: ReadFileRange,
165
+ ): Promise<Uint8Array> {
166
+ if (range === undefined) {
167
+ const size = (await view.resolveByteRange(path))?.size
168
+ if (size !== undefined) assertReadSize(path, size, maxReadFileBytes)
169
+ const buf = await view.readEntry(path)
170
+ return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength)
171
+ }
172
+ const start = Math.max(0, range.start ?? 0)
173
+ const size = (await view.resolveByteRange(path))?.size
174
+ if (size === undefined) {
175
+ // Unknown size (missing entry) — let the view raise its own error.
176
+ return toUint8Array(
177
+ await view.readEntrySlice(path, start, range.end ?? start),
178
+ )
179
+ }
180
+ const end = Math.min(range.end ?? size, size)
181
+ assertReadSize(path, Math.max(0, end - start), maxReadFileBytes)
182
+ return toUint8Array(await view.readEntrySlice(path, start, end))
183
+ }
184
+
185
+ async function listFileNamesScoped(): Promise<readonly string[]> {
186
+ return view.listEntries()
187
+ }
188
+
189
+ /**
190
+ * Run `compute` through the shared probe cache when configured. The
191
+ * cache key carries the computation kind — sniffing an entry and
192
+ * decoding it are different results for the same path.
193
+ */
194
+ function cached<T extends object | boolean | undefined>(
195
+ kind: string,
196
+ path: string,
197
+ compute: () => Promise<T>,
198
+ ): Promise<T> {
199
+ if (deps.probeCache === undefined || deps.cacheScope === undefined) {
200
+ return compute()
201
+ }
202
+ return deps.probeCache.getOrCompute(
203
+ `${deps.cacheScope}:${kind}:${path}`,
204
+ compute,
205
+ )
206
+ }
207
+
208
+ function sniffScoped(path: string): Promise<FileType | undefined> {
209
+ return cached("sniff", path, async () => {
210
+ // A missing or unreadable entry has no type — identification
211
+ // never rejects, so callers can sniff freely while walking a
212
+ // file list that may be stale.
213
+ const head = await view
214
+ .readEntrySlice(path, 0, SNIFF_HEADER_BYTES)
215
+ .catch(() => undefined)
216
+ if (head === undefined) return undefined
217
+ return sniffBytes(head, path)
218
+ })
219
+ }
220
+
221
+ /**
222
+ * Image metadata plus animation in one pass. Static formats answer
223
+ * from a header slice (libvips reads the ENTIRE entry for metadata
224
+ * from a stream, but only the header from a buffer); animation
225
+ * candidates (gif/webp/avif) must stream, because a truncated buffer
226
+ * cannot complete the frame scan and would report an animated source
227
+ * as static.
228
+ */
229
+ async function probeImageEntry(
230
+ path: string,
231
+ type: FileType,
232
+ ): Promise<ProbeResult> {
233
+ const probeImage = deps.probeImage
234
+ if (probeImage === undefined) {
235
+ return { kind: "unknown", reason: "unavailable" }
236
+ }
237
+ if (!isAnimatedCandidateExt(type.ext)) {
238
+ const head = await view.readEntrySlice(path, 0, PROBE_HEADER_BYTES)
239
+ if (head.length > 0) {
240
+ const probed = await probeImage(head, type.ext).catch(() => undefined)
241
+ if (probed !== undefined) return imageResult(type.mime, probed)
242
+ }
243
+ }
244
+ try {
245
+ const { stream } = await view.openEntryStream(path)
246
+ const probed = await probeImage(stream, type.ext)
247
+ if (probed !== undefined) return imageResult(type.mime, probed)
248
+ } catch (err) {
249
+ console.warn(
250
+ `[probe] path=${path} image probe threw: ${err instanceof Error ? err.message : String(err)}`,
251
+ )
252
+ }
253
+ return { kind: "unknown", reason: "failed" }
254
+ }
255
+
256
+ /**
257
+ * Audio/video metadata. The ffmpeg container hint comes from the
258
+ * sniffed MIME type rather than the filename, so a mislabelled entry
259
+ * still demuxes; ISO-BMFF sources keep their index at the end of the
260
+ * file, so a pipe probe can legitimately fail and resolve to
261
+ * `{ kind: "unknown", reason: "failed" }`.
262
+ */
263
+ async function probeAvEntry(
264
+ path: string,
265
+ type: FileType,
266
+ ): Promise<ProbeResult> {
267
+ const probeAv = deps.probeAv
268
+ if (probeAv === undefined) {
269
+ return { kind: "unknown", reason: "unavailable" }
270
+ }
271
+ const inputFormat = MIME_FFMPEG_INPUT_FORMAT[type.mime]
272
+ if (inputFormat === undefined) {
273
+ return { kind: "unknown", reason: "unsupported" }
274
+ }
275
+ try {
276
+ const { stream } = await view.openEntryStream(path)
277
+ return await probeAv(stream, { mime: type.mime, inputFormat })
278
+ } catch {
279
+ return { kind: "unknown", reason: "failed" }
280
+ }
281
+ }
282
+
283
+ function probeScoped(path: string): Promise<ProbeResult> {
284
+ return cached("probe", path, async () => {
285
+ const type = await sniffScoped(path)
286
+ if (type === undefined) {
287
+ return { kind: "unknown", reason: "unsupported" } as const
288
+ }
289
+ switch (type.kind) {
290
+ case "image":
291
+ return probeImageEntry(path, type)
292
+ case "video":
293
+ case "audio":
294
+ return probeAvEntry(path, type)
295
+ default:
296
+ return { kind: "other", mime: type.mime } as const
297
+ }
298
+ })
299
+ }
300
+
301
+ async function statFileScoped(
302
+ path: string,
303
+ ): Promise<{ readonly sizeBytes: number } | undefined> {
304
+ const range = await view.resolveByteRange(path)
305
+ if (range === undefined) return undefined
306
+ return { sizeBytes: range.size }
307
+ }
308
+
309
+ async function statFilesScoped(
310
+ paths: readonly string[],
311
+ ): Promise<readonly ({ readonly sizeBytes: number } | undefined)[]> {
312
+ // Batch resolution: the zip CD cache makes every range lookup a
313
+ // warm map hit; parallelizing keeps the fan-out bounded by the
314
+ // plugin's own concurrency when it chunks the input.
315
+ return Promise.all(paths.map(statFileScoped))
316
+ }
317
+
318
+ async function hashBytesScoped(
319
+ path: string,
320
+ algo: "md5" | "sha256",
321
+ ): Promise<string> {
322
+ const { stream } = await view.openEntryStream(path)
323
+ return hashStream(stream, algo)
324
+ }
325
+
326
+ async function computeImageHashesScoped(
327
+ path: string,
328
+ kinds: readonly ImageHashKind[],
329
+ ): Promise<Readonly<Record<ImageHashKind, string>> | undefined> {
330
+ // Hashing follows the content, not the name: an image with a
331
+ // wrong extension is still a duplicate of itself.
332
+ const type = await sniffScoped(path)
333
+ if (type?.kind !== "image") return undefined
334
+ const result: Partial<Record<ImageHashKind, string>> = {}
335
+ if (kinds.includes("sha256")) {
336
+ const { stream } = await view.openEntryStream(path)
337
+ result.sha256 = await hashStream(stream, "sha256")
338
+ }
339
+ const perceptual = kinds.filter((kind) =>
340
+ PERCEPTUAL_HASH_KINDS.includes(kind),
341
+ )
342
+ if (perceptual.length > 0) {
343
+ const { stream } = await view.openEntryStream(path)
344
+ const gray = await decodeGrayGrid(stream)
345
+ if (gray !== undefined && grayStddev(gray) >= MIN_PERCEPTUAL_STDDEV) {
346
+ if (perceptual.includes("dhash")) result.dhash = computeDHash(gray)
347
+ if (perceptual.includes("phash")) result.phash = computePHash(gray)
348
+ } else if (result.sha256 === undefined) {
349
+ // Not a decodable image (or too flat for a meaningful
350
+ // perceptual hash) and no byte hash requested.
351
+ return undefined
352
+ }
353
+ }
354
+ return result as Readonly<Record<ImageHashKind, string>>
355
+ }
356
+
357
+ /**
358
+ * Decode one frame of the entry as a `PHASH_GRID × PHASH_GRID`
359
+ * grayscale buffer. Animated sources render their first frame
360
+ * (sharp's default with `pages: 1`). Undecodable input resolves to
361
+ * `undefined`.
362
+ */
363
+ async function decodeGrayGrid(
364
+ stream: Readable,
365
+ ): Promise<Uint8Array | undefined> {
366
+ try {
367
+ const instance = await sharpFromReadable(stream, { pages: 1 })
368
+ const { data } = await instance
369
+ .resize(PHASH_GRID, PHASH_GRID, { fit: "fill" })
370
+ .grayscale()
371
+ .raw()
372
+ .toBuffer({ resolveWithObject: true })
373
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
374
+ } catch {
375
+ return undefined
376
+ }
377
+ }
378
+
379
+ return {
380
+ // No-ops: the sandbox host is the plugin log sink — it alone knows
381
+ // which plugin emitted the line (see dispatchLog in sandbox/host.ts).
382
+ logInfo() {},
383
+ logWarn() {},
384
+ logError() {},
385
+ // The payload is opaque to this factory (hosts pass `unknown`);
386
+ // the schema generic only types it for schema-aware consumers.
387
+ context: { detect: deps.detectContext as TSchema["detect"] | undefined },
388
+ listFileNames: listFileNamesScoped,
389
+ readFile: readFileScoped,
390
+ statFile: statFileScoped,
391
+ statFiles: statFilesScoped,
392
+ sniff: sniffScoped,
393
+ probe: probeScoped,
394
+ hashBytes: hashBytesScoped,
395
+ computeImageHashes: computeImageHashesScoped,
396
+ listContainer: (filename) => listContainerScoped(filename, extractor),
397
+ extractArchive: (filename) => extractArchiveScoped(filename, extractor),
398
+ // The plugin vault is a sandbox-level capability: the sandbox host
399
+ // intercepts these method names and routes them to the wired
400
+ // plugin-asset service with the owning plugin id (see
401
+ // sandbox/host.ts). In-process hosts (directory API, contract
402
+ // fixtures, dev runner) have no consent channel, so the methods
403
+ // answer UNAVAILABLE — exactly like the CLI and the workbench.
404
+ download: async () => {
405
+ throw pluginAssetError(
406
+ "UNAVAILABLE",
407
+ "download() — this host has no plugin asset service; only the app server host can download into the plugin vault",
408
+ )
409
+ },
410
+ statAsset: async () => {
411
+ throw unavailableAsset("statAsset")
412
+ },
413
+ readAsset: async () => {
414
+ throw unavailableAsset("readAsset")
415
+ },
416
+ deleteAsset: async () => {
417
+ throw unavailableAsset("deleteAsset")
418
+ },
419
+ }
420
+ }
421
+
422
+ function unavailableAsset(method: string): Error {
423
+ return pluginAssetError(
424
+ "UNAVAILABLE",
425
+ `${method}() — this host has no plugin asset vault; only the app server host manages vault files`,
426
+ )
427
+ }
428
+
429
+ /**
430
+ * List a container entry's files without materializing — metadata-only
431
+ * consumers (detect, card counts) use this so a preview never pays for
432
+ * extraction it does not need.
433
+ */
434
+ async function listContainerScoped(
435
+ filename: string,
436
+ extractor: ArchiveExtractor | undefined,
437
+ ): Promise<ContainerListing> {
438
+ if (extractor === undefined) {
439
+ throw new Error(
440
+ `listContainer("${filename}") — this host has no container support; only the archive-backed server host lists containers`,
441
+ )
442
+ }
443
+ const entries = await extractor.list(filename)
444
+ if (entries === undefined) {
445
+ throw new Error(
446
+ `listContainer("${filename}") — not a supported archive (zip/tar/7z/rar/xz), or no 7-Zip binary for non-zip formats`,
447
+ )
448
+ }
449
+ return { entries: entries.map(bareContainerEntry) }
450
+ }
451
+
452
+ /** Convert a listed entry to the wire shape (no dimensions). */
453
+ function bareContainerEntry(
454
+ entry: ArchiveEntry,
455
+ ): ContainerListing["entries"][number] {
456
+ return {
457
+ path: entry.name,
458
+ sizeBytes: entry.sizeBytes,
459
+ kind: fileTypeFromName(entry.name)?.kind ?? "other",
460
+ }
461
+ }
462
+
463
+ /**
464
+ * Materialize the container entry `filename` into the configured cache
465
+ * directory (or list its contents without writing in read-only mode).
466
+ * Rejects when this API instance has no extraction cache wired.
467
+ */
468
+ async function extractArchiveScoped(
469
+ filename: string,
470
+ extractor: ArchiveExtractor | undefined,
471
+ ): Promise<ExtractionResult> {
472
+ if (extractor === undefined) {
473
+ throw new Error(
474
+ `extractArchive("${filename}") — this host has no extraction cache; only the archive-backed server host materializes containers`,
475
+ )
476
+ }
477
+ return extractor.extract(filename)
478
+ }
479
+
480
+ /** Assemble the image branch of a {@link ProbeResult}. */
481
+ function imageResult(mime: string, probed: ImageSourceProbe): ProbeResult {
482
+ return {
483
+ kind: "image",
484
+ mime,
485
+ width: probed.width,
486
+ height: probed.height,
487
+ animated: probed.animated,
488
+ }
489
+ }
490
+
491
+ function assertReadSize(path: string, sizeBytes: number, max: number): void {
492
+ if (sizeBytes > max) {
493
+ throw new Error(
494
+ `readFile("${path}") requests ${sizeBytes} bytes, exceeding the per-call limit of ${max} bytes — pass a byte range or use readFileChunks()`,
495
+ )
496
+ }
497
+ }
498
+
499
+ function toUint8Array(buf: Buffer): Uint8Array {
500
+ return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength)
501
+ }