diff --git a/config/scripts/generate-plugin-authoring-skill-references.mjs b/config/scripts/generate-plugin-authoring-skill-references.mjs
index 1b60a2ef02b..73a31bfd89d 100644
--- a/config/scripts/generate-plugin-authoring-skill-references.mjs
+++ b/config/scripts/generate-plugin-authoring-skill-references.mjs
@@ -164,12 +164,17 @@ function renderTesting({ testing }) {
['## `TestPanel` (from `mount`)', '', ...members('TestPanel'), ''],
['## `TestWorker` (from `mountWorker`)', '', ...members('TestWorker'), ''],
['## `MockHost` (from `mockHost`)', '', ...members('MockHost'), ''],
+ ['## `MockEditorHost` (from `mockEditorHost`)', '', ...members('MockEditorHost'), ''],
[
'## Options',
'',
- ...['MountOptions', 'MountWorkerOptions', 'MockHostOptions', 'CheckScenario'].map(
- (name) => `- ${code(testing.typeDeclaration(name))}`
- )
+ ...[
+ 'MountOptions',
+ 'MountWorkerOptions',
+ 'MockHostOptions',
+ 'MockEditorHostOptions',
+ 'CheckScenario'
+ ].map((name) => `- ${code(testing.typeDeclaration(name))}`)
]
]
)
diff --git a/docs/reference/plugin-authoring.md b/docs/reference/plugin-authoring.md
index 2df2ea98ba9..6d0bb4a1072 100644
--- a/docs/reference/plugin-authoring.md
+++ b/docs/reference/plugin-authoring.md
@@ -373,6 +373,31 @@ Theme changes do not reload the frame: the shell updates the `--orca-*` variable
`light`/`dark` class on `` in place and emits `theme`. Restyle from the variables, or
listen with `orca.on('theme', …)`, to follow it.
+`orca plugin check` (and `dev watch --check`) checks each custom editor without running it or
+Orca: from the `entry` it follows local `
+
+${e}`}var wI=new TextEncoder;function Np(e,n){return e.length>n?n+1:wI.encode(e).byteLength}function Cy(e,n=Iy,i={}){let o=i.maxObjects??1e4,t=i.maxDepth??100,r=new WeakSet,a=0,s=0,u=l=>{a=Math.min(n+1,a+l)},c=(l,p)=>{if(a>n)return;if(l===null){u(1);return}switch(typeof l){case"undefined":case"boolean":u(1);return;case"number":u(8);return;case"bigint":u(Np(l.toString(),n-a));return;case"string":u(Np(l,n-a));return;case"symbol":case"function":a=n+1;return;case"object":break}let m=l;if(r.has(m)){u(8);return}if(r.add(m),s+=1,s>o||p>t){a=n+1;return}if(m instanceof ArrayBuffer){u(m.byteLength);return}if(typeof SharedArrayBuffer<"u"&&m instanceof SharedArrayBuffer){u(m.byteLength);return}if(ArrayBuffer.isView(m)){u(16),c(m.buffer,p+1);return}if(typeof Blob<"u"&&m instanceof Blob){u(m.size);return}if(m instanceof Date){u(8);return}if(m instanceof RegExp){c(m.source,p+1),c(m.flags,p+1);return}if(m instanceof Map){u(8);for(let[f,g]of m)if(u(4),c(f,p+1),c(g,p+1),a>n)return;return}if(m instanceof Set){u(8);for(let f of m)if(u(4),c(f,p+1),a>n)return;return}if(Array.isArray(m)){u(8);for(let f of m)if(u(4),c(f,p+1),a>n)return;return}try{let f=Object.getPrototypeOf(m);if(f!==Object.prototype&&f!==null){a=n+1;return}for(let g of Object.keys(m))if(u(4),u(Np(g,n-a)),c(m[g],p+1),a>n)return}catch{a=n+1}};return c(e,0),a}var II=e=>typeof e=="object"&&e!==null,Uy=/')
+ })
+ await writeFile(join(root, 'secret.js'), 'export {}')
+ await symlink(join(root, 'secret.js'), join(root, 'notes', 'editor', 'lib.js'))
+ const result = await check(root)
+ expect(result.issues).toEqual([
+ expect.objectContaining({
+ code: 'editor_import',
+ message: expect.stringContaining('outside')
+ })
+ ])
+ }
+ )
+
+ it('reads import maps and skips data blocks, and resolves bare native imports through the map', async () => {
+ const root = await editorWith({
+ 'editor/index.html': page(
+ [
+ '',
+ '',
+ ''
+ ].join('\n')
+ ),
+ 'editor/vendor/lib.js': 'export const a = (x) => x\n',
+ 'editor/vendor/kit/b.js': 'export const b = 1\n'
+ })
+ const result = await check(root)
+ expect(result.issues).toEqual([])
+ expect(result.files).toEqual([
+ 'editor/index.html',
+ 'editor/vendor/kit/b.js',
+ 'editor/vendor/lib.js'
+ ])
+ })
+
+ it('refuses a bare native import the import map does not name', async () => {
+ const root = await editorWith({
+ 'editor/index.html': page('')
+ })
+ expect((await check(root)).issues).toEqual([
+ expect.objectContaining({
+ code: 'editor_import',
+ message: expect.stringContaining('import map')
+ })
+ ])
+ })
+
+ it('checks JSON imports as JSON and an extensionless entry as a page', async () => {
+ const root = await editorWith(
+ {
+ 'editor/page': page(''),
+ 'editor/main.ts': "import data from './data.json' with { type: 'json' }\nvoid data\n",
+ 'editor/data.json': '{ "ok": true, }'
+ },
+ 'editor/page'
+ )
+ expect((await check(root)).issues).toEqual([
+ expect.objectContaining({ code: 'editor_syntax', location: 'editor/data.json:1:1' })
+ ])
+ })
+
+ it('decodes an escaped script src before looking it up', async () => {
+ const root = await editorWith({
+ 'editor/index.html': page(''),
+ 'editor/my script.js': 'void 0\n'
+ })
+ const result = await check(root)
+ expect(result.issues).toEqual([])
+ expect(result.files).toContain('editor/my script.js')
+ })
+
+ it('lets a dependency reach the 16 MiB the protocol serves, past the 10 MiB entry cap', async () => {
+ const root = await editorWith({
+ 'editor/index.html': page(''),
+ 'editor/vendor.mjs': `export const x = 1\n//${'x'.repeat(11 * 1024 * 1024)}\n`
+ })
+ expect((await check(root)).issues).toEqual([])
+ })
+})
diff --git a/src/cli/plugin-authoring/plugin-editor-check.test.ts b/src/cli/plugin-authoring/plugin-editor-check.test.ts
new file mode 100644
index 00000000000..d9bd329eba0
--- /dev/null
+++ b/src/cli/plugin-authoring/plugin-editor-check.test.ts
@@ -0,0 +1,128 @@
+import { appendFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { checkPluginEditors, formatPluginEditorCheck } from './plugin-editor-check'
+import { createPluginFromTemplate } from './plugin-create'
+import { validatePluginTarget } from './plugin-folder-validation'
+import { checkPluginDevFolder } from '../plugin-dev-watch/plugin-dev-watch-check'
+
+const roots: string[] = []
+
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+/** The editor template with its script moved to `editor/main.ts`, which imports `./sync.ts`. */
+async function editorPlugin(): Promise {
+ const root = await mkdtemp(join(tmpdir(), 'orca-editor-check-'))
+ roots.push(root)
+ const dir = join(root, 'notes')
+ await createPluginFromTemplate({ dir, template: 'editor', publisher: 'acme' })
+ const html = join(dir, 'editor', 'index.html')
+ await writeFile(
+ html,
+ (await readFile(html, 'utf8')).replace(
+ /'
+ )
+ )
+ await writeFile(
+ join(dir, 'editor', 'main.ts'),
+ "import { start } from './sync'\nconst text: string = 'a'\nstart(text)\n"
+ )
+ await writeFile(join(dir, 'editor', 'sync.ts'), 'export function start(text: string): void {}\n')
+ return dir
+}
+
+describe('orca plugin check: custom editors', () => {
+ it('passes the editor template and follows its script and imports', async () => {
+ const dir = await editorPlugin()
+ const [check] = await checkPluginEditors(dir)
+ expect(check.issues).toEqual([])
+ expect(check.files).toEqual(['editor/index.html', 'editor/main.ts', 'editor/sync.ts'])
+ expect(formatPluginEditorCheck(check)[0]).toMatch(/: ok, 3 file\(s\) load$/)
+ })
+
+ it('fails on a syntax error that validate lets through', async () => {
+ const dir = await editorPlugin()
+ await appendFile(join(dir, 'editor', 'main.ts'), 'this is not valid ts(\n')
+ expect((await validatePluginTarget(dir)).valid).toBe(true)
+ const [check] = await checkPluginEditors(dir)
+ expect(check.issues).toEqual([
+ expect.objectContaining({ code: 'editor_syntax', location: 'editor/main.ts:4:6' })
+ ])
+ })
+
+ it('reports missing files and refused imports, and warns on string eval', async () => {
+ const dir = await editorPlugin()
+ await writeFile(
+ join(dir, 'editor', 'main.ts'),
+ [
+ "import './gone'",
+ "import 'left-pad'",
+ '// eval("comments do not count")',
+ "const label = 'new Function in a string does not count'",
+ "eval('1')",
+ "const f = new Function('return 1')",
+ ''
+ ].join('\n')
+ )
+ const [check] = await checkPluginEditors(dir)
+ expect(check.issues.map(({ code, location }) => [code, location])).toEqual([
+ ['editor_import', 'editor/main.ts:1:9'],
+ ['editor_import', 'editor/main.ts:2:9']
+ ])
+ // A lexical scan can misread code, so eval is a warning that does not fail the check.
+ expect(check.warnings.map(({ code, location }) => [code, location])).toEqual([
+ ['editor_eval', 'editor/main.ts:5:1'],
+ ['editor_eval', 'editor/main.ts:6:11']
+ ])
+ })
+
+ it('reports a missing script, a broken inline script, and a script over 16 MiB', async () => {
+ const dir = await editorPlugin()
+ const html = join(dir, 'editor', 'index.html')
+ await writeFile(
+ html,
+ [
+ '',
+ '',
+ '',
+ '',
+ ''
+ ].join('\n')
+ )
+ await mkdir(join(dir, 'editor'), { recursive: true })
+ await writeFile(join(dir, 'editor', 'big.js'), 'x'.repeat(16 * 1024 * 1024 + 1))
+ const [check] = await checkPluginEditors(dir)
+ expect(check.issues.map(({ code, location }) => [code, location])).toEqual([
+ ['editor_syntax', 'editor/index.html:6:1'],
+ ['editor_missing_file', 'editor/index.html:2:1'],
+ ['editor_too_large', 'editor/index.html:3:1']
+ ])
+ })
+
+ it('caps a transpiled source module at 1 MiB', async () => {
+ const dir = await editorPlugin()
+ await writeFile(join(dir, 'editor', 'sync.ts'), `// ${'x'.repeat(1024 * 1024)}\n`)
+ const [check] = await checkPluginEditors(dir)
+ expect(check.issues).toEqual([
+ expect.objectContaining({ code: 'editor_too_large', location: 'editor/main.ts:1:24' })
+ ])
+ })
+
+ it('fails dev watch --check on an editor issue, and passes it with only a warning', async () => {
+ const dir = await editorPlugin()
+ await appendFile(join(dir, 'editor', 'main.ts'), "eval('1')\n")
+ const warned = await checkPluginDevFolder(dir)
+ expect(warned.ok).toBe(true)
+ expect(warned.editors[0]?.warnings).toEqual([expect.objectContaining({ code: 'editor_eval' })])
+ await appendFile(join(dir, 'editor', 'main.ts'), 'this is not valid ts(\n')
+ const failed = await checkPluginDevFolder(dir)
+ expect(failed.ok).toBe(false)
+ expect(failed.editors[0]?.issues).toEqual([expect.objectContaining({ code: 'editor_syntax' })])
+ })
+})
diff --git a/src/cli/plugin-authoring/plugin-editor-check.ts b/src/cli/plugin-authoring/plugin-editor-check.ts
new file mode 100644
index 00000000000..db21881764d
--- /dev/null
+++ b/src/cli/plugin-authoring/plugin-editor-check.ts
@@ -0,0 +1,62 @@
+import { readFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import type {
+ PluginEditorCheckIssue,
+ PluginEditorStaticCheck
+} from '../../main/plugins/testing/plugin-editor-static-check'
+import { parsePluginManifest } from '../../shared/plugins/manifest/plugin-manifest'
+import { pluginTestingFiles } from './plugin-test-runner'
+
+export type PluginEditorCheck = {
+ editor: string
+ entry: string
+ /** Plugin-relative files the entry loads, itself included. */
+ files: string[]
+ issues: PluginEditorCheckIssue[]
+ /** `editor_eval`: likely refused by the frame, but found by a scan that can misread code. */
+ warnings: PluginEditorCheckIssue[]
+}
+
+type EditorCheckModule = {
+ checkPluginEditorEntry: (dir: string, entry: string) => Promise
+}
+
+/** Every custom editor's files, transpiled and resolved as the frame loads them; runs no code. */
+export async function checkPluginEditors(
+ dir: string,
+ appRoot?: string
+): Promise {
+ const parsed = parsePluginManifest(
+ JSON.parse(await readFile(join(dir, 'orca-plugin.json'), 'utf8'))
+ )
+ const editors = parsed.ok ? parsed.manifest.contributes.customEditors : []
+ if (editors.length === 0) {
+ return []
+ }
+ // The check ships in the hooks bundle, which carries the frame's transpiler in plain Node.
+ const loaded: Partial = await import(
+ pathToFileURL(pluginTestingFiles(appRoot).hooks).href
+ )
+ const check = loaded.checkPluginEditorEntry
+ if (!check) {
+ throw new Error('this Orca build has no custom editor check')
+ }
+ const checks: PluginEditorCheck[] = []
+ for (const editor of editors) {
+ checks.push({ editor: editor.id, entry: editor.entry, ...(await check(dir, editor.entry)) })
+ }
+ return checks
+}
+
+/** Lines for one editor: how many files it loads, or each issue at its plugin location. */
+export function formatPluginEditorCheck(check: PluginEditorCheck): string[] {
+ const head = `${check.editor} (${check.entry}): `
+ const line = (issue: PluginEditorCheckIssue) =>
+ `${issue.location}: ${issue.message} [${issue.code}]`
+ const warnings = check.warnings.map((warning) => ` warning: ${line(warning)}`)
+ if (check.issues.length === 0) {
+ return [`${head}ok, ${check.files.length} file(s) load`, ...warnings]
+ }
+ return [`${head}failed`, ...check.issues.map((issue) => ` ${line(issue)}`), ...warnings]
+}
diff --git a/src/cli/plugin-dev-watch/plugin-dev-watch-check.test.ts b/src/cli/plugin-dev-watch/plugin-dev-watch-check.test.ts
index b7aa904e741..3415de2b7d8 100644
--- a/src/cli/plugin-dev-watch/plugin-dev-watch-check.test.ts
+++ b/src/cli/plugin-dev-watch/plugin-dev-watch-check.test.ts
@@ -45,7 +45,8 @@ const passed: PluginDevWatchCheckResult = {
issues: [],
warnings: []
}
- ]
+ ],
+ editors: []
}
const failed: PluginDevWatchCheckResult = {
ok: false,
@@ -59,7 +60,8 @@ const failed: PluginDevWatchCheckResult = {
issues: [{ kind: 'plugin', message: 'boom', location: 'src/panel.tsx:4:7' }],
warnings: []
}
- ]
+ ],
+ editors: []
}
/** Polls: 1 baseline, 2 status (unchanged), 3 logs, 4 status (one save), then Ctrl-C. */
diff --git a/src/cli/plugin-dev-watch/plugin-dev-watch-check.ts b/src/cli/plugin-dev-watch/plugin-dev-watch-check.ts
index d94ba850a67..bc540563b59 100644
--- a/src/cli/plugin-dev-watch/plugin-dev-watch-check.ts
+++ b/src/cli/plugin-dev-watch/plugin-dev-watch-check.ts
@@ -1,5 +1,6 @@
import { validatePluginTarget } from '../plugin-authoring/plugin-folder-validation'
import { checkPluginPanels, type PluginPanelCheck } from '../plugin-authoring/plugin-panel-check'
+import { checkPluginEditors, type PluginEditorCheck } from '../plugin-authoring/plugin-editor-check'
/** `orca plugin check` for one folder, as data: validation errors, else each panel's render. */
export type PluginDevWatchCheckResult = {
@@ -7,16 +8,23 @@ export type PluginDevWatchCheckResult = {
/** Manifest problems; panels are not rendered while there are any. */
invalid: string[]
panels: PluginPanelCheck[]
+ /** Each custom editor's static check. */
+ editors: PluginEditorCheck[]
}
export async function checkPluginDevFolder(dir: string): Promise {
const report = await validatePluginTarget(dir)
if (!report.valid) {
- return { ok: false, invalid: report.errors, panels: [] }
+ return { ok: false, invalid: report.errors, panels: [], editors: [] }
}
+ const [rendered, editors] = await Promise.all([
+ checkPluginPanels(report.path),
+ checkPluginEditors(report.path)
+ ])
// Why no tree: a watch prints one line per save; the summary says what drew.
- const panels = (await checkPluginPanels(report.path)).map(({ tree: _tree, ...panel }) => panel)
- return { ok: panels.every((panel) => panel.issues.length === 0), invalid: [], panels }
+ const panels = rendered.map(({ tree: _tree, ...panel }) => panel)
+ const ok = [...panels, ...editors].every((check) => check.issues.length === 0)
+ return { ok, invalid: [], panels, editors }
}
/** Runs checks one at a time: a request while one runs queues a single rerun of the newest code. */
diff --git a/src/cli/plugin-dev-watch/plugin-dev-watch-events.ts b/src/cli/plugin-dev-watch/plugin-dev-watch-events.ts
index d07357ccc76..3f426a405d5 100644
--- a/src/cli/plugin-dev-watch/plugin-dev-watch-events.ts
+++ b/src/cli/plugin-dev-watch/plugin-dev-watch-events.ts
@@ -1,6 +1,7 @@
import type { PluginHostStatus } from '../../shared/plugins/plugin-list-entry'
import { formatPluginPanelCheck } from '../plugin-authoring/plugin-panel-check'
import type { PluginDevWatchCheckResult } from './plugin-dev-watch-check'
+import { formatPluginEditorCheck } from '../plugin-authoring/plugin-editor-check'
import { PLUGIN_DEV_WATCH_SHOOTABLE, type PluginDevWatchShot } from './plugin-dev-watch-shot'
/** `missing`: the host lists no plugin for the folder (for example, its id changed). */
@@ -120,7 +121,8 @@ export function formatPluginDevWatchEvent(event: PluginDevWatchEvent): string {
? [` in Orca: ${pluginStatusPhrase(event.pluginStatus, event.pluginError)}`]
: []),
...event.invalid.map((error) => ` invalid: ${error}`),
- ...event.panels.flatMap(formatPluginPanelCheck).map((line) => ` ${line}`)
+ ...event.panels.flatMap(formatPluginPanelCheck).map((line) => ` ${line}`),
+ ...event.editors.flatMap(formatPluginEditorCheck).map((line) => ` ${line}`)
].join('\n')
case 'shot':
return [
diff --git a/src/cli/plugin-dev-watch/plugin-dev-watch-shot.test.ts b/src/cli/plugin-dev-watch/plugin-dev-watch-shot.test.ts
index 4ca8dce9bc2..01a58754a8f 100644
--- a/src/cli/plugin-dev-watch/plugin-dev-watch-shot.test.ts
+++ b/src/cli/plugin-dev-watch/plugin-dev-watch-shot.test.ts
@@ -113,7 +113,7 @@ describe('plugin dev watch --shot', () => {
{
check: async () => {
order.push('check')
- return { ok: true, invalid: [], panels: [] }
+ return { ok: true, invalid: [], panels: [], editors: [] }
},
shot: host
},
diff --git a/src/cli/specs/plugin.ts b/src/cli/specs/plugin.ts
index 337bc8326b4..2cf279bd54d 100644
--- a/src/cli/specs/plugin.ts
+++ b/src/cli/specs/plugin.ts
@@ -66,7 +66,7 @@ export const PLUGIN_AUTHORING_COMMAND_SPECS: CommandSpec[] = [
{
path: ['plugin', 'check'],
summary:
- 'Validate a plugin and render each components panel once on this machine, unsandboxed: only for code you trust',
+ 'Validate a plugin, render each components panel once on this machine (unsandboxed: only for code you trust), and statically check each custom editor',
usage: 'orca plugin check [dir] [--json]',
allowedFlags: [...LOCAL_COMMAND_FLAGS, 'dir'],
positionalArgs: ['dir'],
diff --git a/src/main/plugins/panels/plugin-frame-content-types.ts b/src/main/plugins/panels/plugin-frame-content-types.ts
new file mode 100644
index 00000000000..c8c5bb99024
--- /dev/null
+++ b/src/main/plugins/panels/plugin-frame-content-types.ts
@@ -0,0 +1,49 @@
+import { extname } from 'node:path'
+
+// How the frame protocol types a plugin file; free of the shell so plain-Node checks share it.
+
+const MIME_TYPES: Record = {
+ '.html': 'text/html; charset=utf-8',
+ '.htm': 'text/html; charset=utf-8',
+ '.js': 'text/javascript; charset=utf-8',
+ '.mjs': 'text/javascript; charset=utf-8',
+ '.css': 'text/css; charset=utf-8',
+ '.json': 'application/json; charset=utf-8',
+ '.map': 'application/json; charset=utf-8',
+ '.txt': 'text/plain; charset=utf-8',
+ '.md': 'text/markdown; charset=utf-8',
+ '.csv': 'text/csv; charset=utf-8',
+ '.xml': 'application/xml; charset=utf-8',
+ '.wasm': 'application/wasm',
+ '.svg': 'image/svg+xml',
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.gif': 'image/gif',
+ '.webp': 'image/webp',
+ '.avif': 'image/avif',
+ '.ico': 'image/x-icon',
+ '.woff': 'font/woff',
+ '.woff2': 'font/woff2',
+ '.ttf': 'font/ttf',
+ '.otf': 'font/otf',
+ '.mp3': 'audio/mpeg',
+ '.wav': 'audio/wav',
+ '.ogg': 'audio/ogg',
+ '.mp4': 'video/mp4',
+ '.webm': 'video/webm'
+}
+
+export const HTML_CONTENT_TYPE = 'text/html; charset=utf-8'
+export const JAVASCRIPT_CONTENT_TYPE = 'text/javascript; charset=utf-8'
+
+export function pluginFrameContentType(path: string): string {
+ return MIME_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream'
+}
+
+/** Code the guest executes or renders as a document: read whole and checked byte-for-byte. */
+export function isCheckedPluginFrameType(contentType: string): boolean {
+ return /^(?:text\/html|text\/javascript|text\/css|application\/json|application\/wasm|image\/svg\+xml|application\/xml)/.test(
+ contentType
+ )
+}
diff --git a/src/main/plugins/panels/plugin-frame-module-transform.ts b/src/main/plugins/panels/plugin-frame-module-transform.ts
index d0c1c770083..8cd939f7c77 100644
--- a/src/main/plugins/panels/plugin-frame-module-transform.ts
+++ b/src/main/plugins/panels/plugin-frame-module-transform.ts
@@ -14,9 +14,9 @@ import {
} from './plugin-source-transpile-protocol'
import { parsePluginTranspileFailure } from './plugin-source-transpile-failure'
import { pluginSourceMapPrelude } from '../../../shared/plugins/frames/plugin-frame-source-map-prelude'
+import { PLUGIN_SOURCE_MODULE_MAX_BYTES } from '../../../shared/plugins/frames/plugin-frame-entry-limits'
-/** Source modules are read whole and transpiled per load, so they get a tighter cap. */
-export const PLUGIN_SOURCE_MODULE_MAX_BYTES = 1024 * 1024
+export { PLUGIN_SOURCE_MODULE_MAX_BYTES }
const CACHE_MAX_ENTRIES = 512
const CACHE_MAX_CHARS = 64 * 1024 * 1024
diff --git a/src/main/plugins/panels/plugin-frame-protocol.ts b/src/main/plugins/panels/plugin-frame-protocol.ts
index c614328f1eb..b8d766c33bb 100644
--- a/src/main/plugins/panels/plugin-frame-protocol.ts
+++ b/src/main/plugins/panels/plugin-frame-protocol.ts
@@ -1,4 +1,8 @@
import { stat } from 'node:fs/promises'
+import {
+ PLUGIN_FRAME_ASSET_MAX_BYTES,
+ PLUGIN_FRAME_CHECKED_MAX_BYTES
+} from '../../../shared/plugins/frames/plugin-frame-entry-limits'
import {
parsePluginFrameUrl,
PLUGIN_DECLARATIVE_HOST_FILE,
@@ -45,10 +49,7 @@ import {
export { pluginFrameContentType } from './plugin-frame-responses'
-/** Streamed assets (images, fonts, media); the whole plugin tree is capped at 50 MiB anyway. */
-export const PLUGIN_FRAME_ASSET_MAX_BYTES = 32 * 1024 * 1024
-/** Documents, scripts and WASM are read whole to check their digest, so they get a lower cap. */
-export const PLUGIN_FRAME_CHECKED_MAX_BYTES = 16 * 1024 * 1024
+export { PLUGIN_FRAME_ASSET_MAX_BYTES, PLUGIN_FRAME_CHECKED_MAX_BYTES }
/** Concurrent requests one guest session may hold open; more get 503 until some finish. */
export const PLUGIN_FRAME_MAX_IN_FLIGHT = 32
diff --git a/src/main/plugins/panels/plugin-frame-responses.ts b/src/main/plugins/panels/plugin-frame-responses.ts
index 3377824b082..76ce62fea64 100644
--- a/src/main/plugins/panels/plugin-frame-responses.ts
+++ b/src/main/plugins/panels/plugin-frame-responses.ts
@@ -1,52 +1,11 @@
import { createReadStream } from 'node:fs'
-import { extname } from 'node:path'
import { PLUGIN_GUEST_CSP } from '../../../shared/plugins/frames/plugin-panel-shell'
-
-const MIME_TYPES: Record = {
- '.html': 'text/html; charset=utf-8',
- '.htm': 'text/html; charset=utf-8',
- '.js': 'text/javascript; charset=utf-8',
- '.mjs': 'text/javascript; charset=utf-8',
- '.css': 'text/css; charset=utf-8',
- '.json': 'application/json; charset=utf-8',
- '.map': 'application/json; charset=utf-8',
- '.txt': 'text/plain; charset=utf-8',
- '.md': 'text/markdown; charset=utf-8',
- '.csv': 'text/csv; charset=utf-8',
- '.xml': 'application/xml; charset=utf-8',
- '.wasm': 'application/wasm',
- '.svg': 'image/svg+xml',
- '.png': 'image/png',
- '.jpg': 'image/jpeg',
- '.jpeg': 'image/jpeg',
- '.gif': 'image/gif',
- '.webp': 'image/webp',
- '.avif': 'image/avif',
- '.ico': 'image/x-icon',
- '.woff': 'font/woff',
- '.woff2': 'font/woff2',
- '.ttf': 'font/ttf',
- '.otf': 'font/otf',
- '.mp3': 'audio/mpeg',
- '.wav': 'audio/wav',
- '.ogg': 'audio/ogg',
- '.mp4': 'video/mp4',
- '.webm': 'video/webm'
-}
-
-export const HTML_CONTENT_TYPE = 'text/html; charset=utf-8'
-export const JAVASCRIPT_CONTENT_TYPE = 'text/javascript; charset=utf-8'
-
-export function pluginFrameContentType(path: string): string {
- return MIME_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream'
-}
-
-/** Code the guest executes or renders as a document: read whole and checked byte-for-byte. */
-export function isCheckedPluginFrameType(contentType: string): boolean {
- return /^(?:text\/html|text\/javascript|text\/css|application\/json|application\/wasm|image\/svg\+xml|application\/xml)/.test(
- contentType
- )
-}
+export {
+ HTML_CONTENT_TYPE,
+ isCheckedPluginFrameType,
+ JAVASCRIPT_CONTENT_TYPE,
+ pluginFrameContentType
+} from './plugin-frame-content-types'
const COMMON_HEADERS = {
// Why on every response: workers take their CSP from their own script response.
diff --git a/src/main/plugins/plugin-artifact-validation.ts b/src/main/plugins/plugin-artifact-validation.ts
index 5f0eb8bd82b..5870d780a06 100644
--- a/src/main/plugins/plugin-artifact-validation.ts
+++ b/src/main/plugins/plugin-artifact-validation.ts
@@ -1,6 +1,5 @@
-import { createReadStream } from 'node:fs'
-import { readdir, realpath, stat } from 'node:fs/promises'
-import { isAbsolute, join, relative, resolve, sep } from 'node:path'
+import { readdir, realpath } from 'node:fs/promises'
+import { join, resolve } from 'node:path'
import type { PluginManifest } from '../../shared/plugins/manifest/plugin-manifest'
import {
PLUGIN_SYNTAX_CONFIGURATION_MAX_BYTES,
@@ -10,11 +9,23 @@ import { parsePluginVmRecipeArtifact } from '../../shared/plugins/artifacts/plug
import { PLUGIN_IMAGE_RULES } from '../../shared/plugins/images/plugin-image-fields'
import { PLUGIN_NEW_FILE_TEMPLATE_MAX_BYTES } from '../../shared/plugins/contributions/plugin-new-file-contribution'
import { validatePluginImageArtifacts } from './plugin-image-artifact-validation'
+import { PLUGIN_PANEL_ENTRY_MAX_BYTES } from '../../shared/plugins/frames/plugin-frame-entry-limits'
+import {
+ PLUGIN_WORKER_ENTRY_MAX_BYTES,
+ readContainedPluginArtifactBytes,
+ readContainedPluginArtifactText,
+ resolveContainedPluginArtifact,
+ resolvePathFromRealRoot
+} from './plugin-contained-artifact'
export type PluginArtifactValidationResult = { ok: true } | { ok: false; error: string }
-export const PLUGIN_PANEL_ENTRY_MAX_BYTES = 10 * 1024 * 1024
-export const PLUGIN_WORKER_ENTRY_MAX_BYTES = 50 * 1024 * 1024
+export { PLUGIN_PANEL_ENTRY_MAX_BYTES, PLUGIN_WORKER_ENTRY_MAX_BYTES }
+export {
+ readContainedPluginArtifactBytes,
+ readContainedPluginArtifactText,
+ resolveContainedPluginArtifact
+}
const PLUGIN_ICON_MAX_BYTES = 2 * 1024 * 1024
export const PLUGIN_LANGUAGE_PACK_MAX_BYTES = 5 * 1024 * 1024
export const PLUGIN_VM_RECIPE_MAX_BYTES = 256 * 1024
@@ -101,72 +112,6 @@ function declaredArtifactPaths(manifest: PluginManifest): DeclaredArtifact[] {
]
}
-export async function resolveContainedPluginArtifact(
- rootDir: string,
- relativePath: string,
- maxBytes = PLUGIN_WORKER_ENTRY_MAX_BYTES
-): Promise {
- const rootReal = await realpath(resolve(rootDir))
- return resolvePathFromRealRoot(rootDir, rootReal, relativePath, 'file', maxBytes)
-}
-
-export async function readContainedPluginArtifactText(
- rootDir: string,
- relativePath: string,
- maxBytes: number
-): Promise {
- return (await readContainedPluginArtifactBytes(rootDir, relativePath, maxBytes)).toString('utf8')
-}
-
-export async function readContainedPluginArtifactBytes(
- rootDir: string,
- relativePath: string,
- maxBytes: number
-): Promise {
- const artifact = await resolveContainedPluginArtifact(rootDir, relativePath, maxBytes)
- const chunks: Buffer[] = []
- let totalBytes = 0
- for await (const chunk of createReadStream(artifact)) {
- const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
- totalBytes += bytes.byteLength
- if (totalBytes > maxBytes) {
- throw new Error(`exceeds the ${maxBytes}-byte artifact limit`)
- }
- chunks.push(bytes)
- }
- return Buffer.concat(chunks, totalBytes)
-}
-
-async function resolvePathFromRealRoot(
- rootDir: string,
- rootReal: string,
- relativePath: string,
- kind: 'file' | 'directory',
- maxBytes?: number
-): Promise {
- const artifactReal = await realpath(resolve(rootDir, ...relativePath.split(/[\\/]/)))
- const fromRoot = relative(rootReal, artifactReal)
- if (
- fromRoot.length === 0 ||
- isAbsolute(fromRoot) ||
- fromRoot === '..' ||
- fromRoot.startsWith(`..${sep}`)
- ) {
- throw new Error('resolves outside the plugin directory')
- }
- const artifactStat = await stat(artifactReal)
- if (kind === 'file' && !artifactStat.isFile()) {
- throw new Error('is not a regular file')
- }
- if (kind === 'directory' && !artifactStat.isDirectory()) {
- throw new Error('is not a directory')
- }
- if (kind === 'file' && maxBytes !== undefined && artifactStat.size > maxBytes) {
- throw new Error(`exceeds the ${maxBytes}-byte artifact limit`)
- }
- return artifactReal
-}
-
/** Why: APFS and NTFS resolve `dark.json` to a committed `DARK.json`, but Linux does not. */
async function assertExactPathSpelling(
rootDir: string,
diff --git a/src/main/plugins/plugin-contained-artifact.ts b/src/main/plugins/plugin-contained-artifact.ts
new file mode 100644
index 00000000000..75f00bff270
--- /dev/null
+++ b/src/main/plugins/plugin-contained-artifact.ts
@@ -0,0 +1,74 @@
+import { createReadStream } from 'node:fs'
+import { realpath, stat } from 'node:fs/promises'
+import { isAbsolute, relative, resolve, sep } from 'node:path'
+
+/** Reads plugin files by their real path, refusing any that a symlink takes outside the plugin.
+ * Free of manifest code so the plain-Node check bundle can share it. */
+
+export const PLUGIN_WORKER_ENTRY_MAX_BYTES = 50 * 1024 * 1024
+
+export async function resolveContainedPluginArtifact(
+ rootDir: string,
+ relativePath: string,
+ maxBytes = PLUGIN_WORKER_ENTRY_MAX_BYTES
+): Promise {
+ const rootReal = await realpath(resolve(rootDir))
+ return resolvePathFromRealRoot(rootDir, rootReal, relativePath, 'file', maxBytes)
+}
+
+export async function readContainedPluginArtifactText(
+ rootDir: string,
+ relativePath: string,
+ maxBytes: number
+): Promise {
+ return (await readContainedPluginArtifactBytes(rootDir, relativePath, maxBytes)).toString('utf8')
+}
+
+export async function readContainedPluginArtifactBytes(
+ rootDir: string,
+ relativePath: string,
+ maxBytes: number
+): Promise {
+ const artifact = await resolveContainedPluginArtifact(rootDir, relativePath, maxBytes)
+ const chunks: Buffer[] = []
+ let totalBytes = 0
+ for await (const chunk of createReadStream(artifact)) {
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
+ totalBytes += bytes.byteLength
+ if (totalBytes > maxBytes) {
+ throw new Error(`exceeds the ${maxBytes}-byte artifact limit`)
+ }
+ chunks.push(bytes)
+ }
+ return Buffer.concat(chunks, totalBytes)
+}
+
+export async function resolvePathFromRealRoot(
+ rootDir: string,
+ rootReal: string,
+ relativePath: string,
+ kind: 'file' | 'directory',
+ maxBytes?: number
+): Promise {
+ const artifactReal = await realpath(resolve(rootDir, ...relativePath.split(/[\\/]/)))
+ const fromRoot = relative(rootReal, artifactReal)
+ if (
+ fromRoot.length === 0 ||
+ isAbsolute(fromRoot) ||
+ fromRoot === '..' ||
+ fromRoot.startsWith(`..${sep}`)
+ ) {
+ throw new Error('resolves outside the plugin directory')
+ }
+ const artifactStat = await stat(artifactReal)
+ if (kind === 'file' && !artifactStat.isFile()) {
+ throw new Error('is not a regular file')
+ }
+ if (kind === 'directory' && !artifactStat.isDirectory()) {
+ throw new Error('is not a directory')
+ }
+ if (kind === 'file' && maxBytes !== undefined && artifactStat.size > maxBytes) {
+ throw new Error(`exceeds the ${maxBytes}-byte artifact limit`)
+ }
+ return artifactReal
+}
diff --git a/src/main/plugins/testing/plugin-editor-html-scripts.ts b/src/main/plugins/testing/plugin-editor-html-scripts.ts
new file mode 100644
index 00000000000..922903b810e
--- /dev/null
+++ b/src/main/plugins/testing/plugin-editor-html-scripts.ts
@@ -0,0 +1,93 @@
+/** The scripts an editor's HTML document runs, read as Chromium would for `orca plugin check`. */
+
+export type HtmlScript = { text: string; module: boolean; lineOffset: number }
+export type HtmlScriptSrc = { src: string; module: boolean; line: number; column: number }
+/** An import map's `imports`: bare specifier, or a prefix ending in `/`, to a URL. */
+export type HtmlImportMap = Record
+
+// Chromium's JavaScript MIME types; any other `type` is a data block it never runs.
+const JS_TYPES = new Set([
+ '',
+ 'module',
+ 'text/javascript',
+ 'application/javascript',
+ 'application/ecmascript',
+ 'application/x-ecmascript',
+ 'application/x-javascript',
+ 'text/ecmascript',
+ 'text/jscript',
+ 'text/livescript',
+ 'text/x-ecmascript',
+ 'text/x-javascript'
+])
+
+export function lineColumn(text: string, offset: number): { line: number; column: number } {
+ const before = text.slice(0, offset).split('\n')
+ return { line: before.length, column: (before.at(-1)?.length ?? 0) + 1 }
+}
+
+function attribute(attributes: string, name: string): string | null {
+ const match = new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i').exec(
+ attributes
+ )
+ return match ? (match[1] ?? match[2] ?? match[3] ?? '') : null
+}
+
+function importsOf(json: string): HtmlImportMap {
+ try {
+ const parsed: unknown = JSON.parse(json)
+ const imports =
+ typeof parsed === 'object' && parsed !== null && 'imports' in parsed ? parsed.imports : null
+ if (typeof imports !== 'object' || imports === null) {
+ return {}
+ }
+ return Object.fromEntries(
+ Object.entries(imports).filter(
+ (entry): entry is [string, string] => typeof entry[1] === 'string'
+ )
+ )
+ } catch {
+ return {}
+ }
+}
+
+/** Inline and `src` scripts Chromium runs, and the document's import map. */
+export function readHtmlScripts(html: string): {
+ inline: HtmlScript[]
+ srcs: HtmlScriptSrc[]
+ importMap: HtmlImportMap
+} {
+ const inline: HtmlScript[] = []
+ const srcs: HtmlScriptSrc[] = []
+ const importMap: HtmlImportMap = {}
+ for (const match of html.matchAll(/