From fa7c3f95fe178306f7fe3a70ddfc360674a6d2a0 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Sun, 1 Nov 2026 21:28:13 -0800
Subject: [PATCH] feat(plugins): check custom editors and test them with
mockEditorHost (#37293)
* feat(plugins): check custom editors statically and add mockEditorHost
orca plugin check now follows each custom editor entry (local script src,
inline module scripts, relative imports), transpiled and resolved as the
frame loader does, and fails at file:line:column on syntax errors, missing
files, refused imports, string eval/Function and files over their size
cap, with no running Orca. The check ships in the test hooks bundle.
orca/testing gains mockEditorHost: it runs the frame shell's real
window.orca (document API and session) over an in-memory text or bytes
file, with edits, replace, conflictNext, flush, setTheme and dispose.
Typings, docs and the skill guide cover both.
* feat(plugins): warn on editor eval and check editors in dev watch
editor_eval comes from a lexical scan that can misread rare code, so it is
now a warning that does not fail orca plugin check. dev watch --check runs
the editor check beside the panel check and reports it under editors.
* fix(plugins): check editors by the frame protocol's rules; mock blocks lost saves
The static editor check now reads files through the loader's contained
reader (symlinks out of the plugin are refused), runs only JavaScript
script types, honors the page's import map for native modules, classifies
files by the protocol's content types (JSON imports, extensionless HTML
entries), decodes escaped script URLs, and uses the protocol's caps: 10 MiB
entry, 1 MiB transpiled module, 16 MiB checked dependency.
mockEditorHost refuses an oversized edit before reading it and then rejects
flush() until a later edit lands, as Orca blocks the save.
* test(plugins): type mockEditorHost's frame API instead of reflecting on it
* fix(plugins): keep main's case-spelling check and hook typings through the rebase
The rebase onto main resolved plugin-artifact-validation.ts and
orca-testing.d.ts to B4's side; this puts back main's exact-path-spelling
check and hooks typings beside B4's contained reader and mockEditorHost
types, and regenerates the testing bundle.
---
...rate-plugin-authoring-skill-references.mjs | 11 +-
docs/reference/plugin-authoring.md | 25 +
.../plugins/declarative/orca-test-hooks.mjs | 36 +-
.../plugins/declarative/orca-testing.mjs | 516 +++++++++++++++++-
resources/plugins/types/orca-testing.d.ts | 48 ++
skill-guides/orca-plugin-authoring.md | 4 +
.../orca-plugin-authoring/references/cli.md | 2 +-
.../references/testing.md | 15 +
.../references/workflow.md | 3 +-
src/cli/bundled-skill-guides.ts | 24 +-
src/cli/handlers/plugin-authoring.ts | 22 +-
.../plugin-editor-check-loader-rules.test.ts | 122 +++++
.../plugin-editor-check.test.ts | 128 +++++
.../plugin-authoring/plugin-editor-check.ts | 62 +++
.../plugin-dev-watch-check.test.ts | 6 +-
.../plugin-dev-watch-check.ts | 14 +-
.../plugin-dev-watch-events.ts | 4 +-
.../plugin-dev-watch-shot.test.ts | 2 +-
src/cli/specs/plugin.ts | 2 +-
.../panels/plugin-frame-content-types.ts | 49 ++
.../panels/plugin-frame-module-transform.ts | 4 +-
.../plugins/panels/plugin-frame-protocol.ts | 9 +-
.../plugins/panels/plugin-frame-responses.ts | 53 +-
.../plugins/plugin-artifact-validation.ts | 87 +--
src/main/plugins/plugin-contained-artifact.ts | 74 +++
.../testing/plugin-editor-html-scripts.ts | 93 ++++
.../testing/plugin-editor-static-check.ts | 292 ++++++++++
src/main/plugins/testing/plugin-test-hooks.ts | 3 +
.../authoring/plugin-host-typings.test.ts | 15 +-
.../declarative/testing/orca-testing.ts | 7 +
.../testing/testing-editor-frame-api.ts | 61 +++
.../testing/testing-editor-host.test.ts | 86 +++
.../testing/testing-editor-host.ts | 296 ++++++++++
.../frames/plugin-frame-entry-limits.ts | 8 +
34 files changed, 1991 insertions(+), 192 deletions(-)
create mode 100644 src/cli/plugin-authoring/plugin-editor-check-loader-rules.test.ts
create mode 100644 src/cli/plugin-authoring/plugin-editor-check.test.ts
create mode 100644 src/cli/plugin-authoring/plugin-editor-check.ts
create mode 100644 src/main/plugins/panels/plugin-frame-content-types.ts
create mode 100644 src/main/plugins/plugin-contained-artifact.ts
create mode 100644 src/main/plugins/testing/plugin-editor-html-scripts.ts
create mode 100644 src/main/plugins/testing/plugin-editor-static-check.ts
create mode 100644 src/shared/plugins/declarative/testing/testing-editor-frame-api.ts
create mode 100644 src/shared/plugins/declarative/testing/testing-editor-host.test.ts
create mode 100644 src/shared/plugins/declarative/testing/testing-editor-host.ts
create mode 100644 src/shared/plugins/frames/plugin-frame-entry-limits.ts
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(/