diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 3d1b9b723fb..eb5e8626832 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -10,6 +10,12 @@ ".github/actions/cloud-sql-rollout-lease/**", ".anti-slop-plugin/**", "src/main/runtime/__fixtures__/*.timing.json", - "resources/skills/released-digests.json" + "resources/skills/released-digests.json", + "docs/schemas/plugins/**/*.schema.json", + "examples/plugins/*/orca-plugin.v2.schema.json", + "docs/reference/plugin-manifest.md", + "docs/reference/plugin-catalog.md", + "docs/reference/plugin-host-api.md", + "docs/reference/plugin-theme-tokens.md" ] } diff --git a/config/build-plugins/main-build-entry-points.ts b/config/build-plugins/main-build-entry-points.ts index 6bbd8cc7c2a..87a47df9802 100644 --- a/config/build-plugins/main-build-entry-points.ts +++ b/config/build-plugins/main-build-entry-points.ts @@ -13,7 +13,7 @@ export const MAIN_BUILD_ENTRY_POINTS: Record = { 'src/main/browser/extensions/browser-extension-native-host-supervisor-entry.ts' ), 'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'), - 'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'), + 'plugin-host-entry': resolve('src/main/plugins/workers/plugin-host-entry.ts'), 'computer-sidecar': resolve('src/main/computer/computer-sidecar.ts'), 'stt-worker': resolve('src/main/speech/stt-worker.ts'), 'warp-theme-parser-worker': resolve('src/main/warp-themes/warp-theme-parser-worker.ts'), diff --git a/config/flaky-test-pattern-baseline.txt b/config/flaky-test-pattern-baseline.txt index 85b0299f1ff..6bea136aa22 100644 --- a/config/flaky-test-pattern-baseline.txt +++ b/config/flaky-test-pattern-baseline.txt @@ -952,7 +952,7 @@ 2 test-no-wall-clock:src/main/persistence/tracking-repos/persistence-deregistered-repo-residue.test.ts 2 test-no-wall-clock:src/main/persistence/tracking-repos/persistence-workspace-session-scrollback.test.ts 1 test-no-wall-clock:src/main/persistence/tracking-repos/worktree-metadata-normalization.test.ts -1 test-no-wall-clock:src/main/plugins/plugin-host-methods.test.ts +1 test-no-wall-clock:src/main/plugins/workers/plugin-host-methods.test.ts 2 test-no-wall-clock:src/main/provider-process/provider-group-orphan-guard.integration.test.ts 1 test-no-wall-clock:src/main/provider-process/provider-group-orphan-guard.test.ts 18 test-no-wall-clock:src/main/provider-process/provider-process-supervisor.integration.test.ts @@ -1203,7 +1203,7 @@ 2 test-no-wall-clock:src/shared/fish-query-reply-child-stdin.node-pty.test.ts 1 test-no-wall-clock:src/shared/git-binary-compatibility.test.ts 1 test-no-wall-clock:src/shared/mobile-push-contract.test.ts -1 test-no-wall-clock:src/shared/plugins/plugin-kill-list.test.ts +1 test-no-wall-clock:src/shared/plugins/catalog/plugin-kill-list.test.ts 2 test-no-wall-clock:src/shared/pty-reply-echo-shapes.node-pty.test.ts 4 test-no-wall-clock:src/shared/sidebar-list/smart-sort.test.ts 2 test-no-wall-clock:src/shared/startup-command-staging.node-pty.test.ts @@ -1333,7 +1333,7 @@ 2 test-timeout-needs-reason:src/main/persistence/persistence-initial-load.test.ts 1 test-timeout-needs-reason:src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts 1 test-timeout-needs-reason:src/main/persistence/terminal-topology/terminal-topology-model.test.ts -1 test-timeout-needs-reason:src/main/plugins/plugin-worker-supervision.integration.test.ts +1 test-timeout-needs-reason:src/main/plugins/workers/plugin-worker-supervision.integration.test.ts 1 test-timeout-needs-reason:src/main/ports/port-scan-command-client.test.ts 1 test-timeout-needs-reason:src/main/ports/port-scan-command-execution.test.ts 2 test-timeout-needs-reason:src/main/providers/agent-foreground-process-git-bash.win32.test.ts diff --git a/config/knip.json b/config/knip.json index f92123a5a85..46e3164d9a7 100644 --- a/config/knip.json +++ b/config/knip.json @@ -5,7 +5,7 @@ "src/preload/index.ts", "src/preload/browser-window-close.ts", "src/main/daemon/daemon-entry.ts", - "src/main/plugins/plugin-host-entry.ts", + "src/main/plugins/workers/plugin-host-entry.ts", "src/main/computer/computer-sidecar.ts", "src/main/speech/stt-worker.ts", "src/main/warp-themes/warp-theme-parser-worker.ts", diff --git a/config/main-sync-spawn-baseline.txt b/config/main-sync-spawn-baseline.txt index 8e69898d8a1..a37f5b5dfd4 100644 --- a/config/main-sync-spawn-baseline.txt +++ b/config/main-sync-spawn-baseline.txt @@ -35,7 +35,7 @@ 1 src/main/orcad/orcad-data-root-privacy.ts 2 src/main/persistence/profile-state/profile-state-access-identity.ts 1 src/main/persistence/profile-state/profile-state-recovery-copy.ts -1 src/main/plugins/plugin-private-marketplace-ssh-shim.cjs +1 src/main/plugins/catalog/plugin-private-marketplace-ssh-shim.cjs 1 src/main/pty/posix-pty-process-table.ts 1 src/main/pwsh.ts 1 src/main/runtime/runtime-skill-install-authority.ts diff --git a/config/process-host-direct-import-baseline.txt b/config/process-host-direct-import-baseline.txt index 957a2e595c6..6293dc4e3c1 100644 --- a/config/process-host-direct-import-baseline.txt +++ b/config/process-host-direct-import-baseline.txt @@ -82,8 +82,8 @@ src/main/native-chat/wsl-transcript-fs-process-slot.ts src/main/native-chat/wsl-transcript-fs-process-spawn.ts src/main/network/macos-system-resolver-health.ts src/main/network/macos-tailscale-dns-diagnostic.ts -src/main/plugins/plugin-host-process.ts -src/main/plugins/plugin-private-marketplace-ssh-shim.cjs +src/main/plugins/workers/plugin-host-process.ts +src/main/plugins/catalog/plugin-private-marketplace-ssh-shim.cjs src/main/ports/port-scan-command-execution.ts src/main/providers/macos-login-session-pty-probe.ts src/main/providers/process-cwd.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index ccb9d48ae2b..0214b04652d 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -19060,12 +19060,12 @@ "invariant": "terminal.sendText accepts only an explicit provider-owned terminal id present in one bounded inventory of the worktree resolved immediately before the send; an absent id causes zero send calls, and relay callers cannot supply their own capability grants or transport classification.", "oracle": "Resolve the active worktree once, list that worktree with the v0 terminal cap once, and assert zero sendTerminal calls for a mismatched opaque id versus exactly one send for matching local- and SSH-shaped ids; then run the same permission and schema cases through desktop-main and registered relay panel/worker adapters and compare error codes.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/plugins/plugin-host-methods.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/main/plugins/workers/plugin-host-methods.test.ts" ], - "testFiles": ["src/main/plugins/plugin-host-methods.test.ts"], + "testFiles": ["src/main/plugins/workers/plugin-host-methods.test.ts"], "assertionRefs": [ { - "file": "src/main/plugins/plugin-host-methods.test.ts", + "file": "src/main/plugins/workers/plugin-host-methods.test.ts", "assertions": [ "a terminal outside the freshly resolved worktree performs one capped list and zero sends", "matching local- and SSH-shaped opaque ids each perform one capped list and one exact send", @@ -19078,7 +19078,7 @@ "date": "2026-07-10", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/plugins/plugin-host-methods.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/plugins/workers/plugin-host-methods.test.ts", "result": "passed", "durationSeconds": 0.18, "summary": "2 files and 17 tests passed, covering the 13-method main/relay conformance matrix and exact terminal routing call counts." diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index fabaaf42fab..a684b9d9e9a 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -12621,18 +12621,18 @@ "src/shared/plugins/hello-orca-panel-messages.test.ts": 45, "src/shared/plugins/plugin-consent-fingerprint.test.ts": 122, "src/shared/plugins/plugin-consent-request.test.ts": 68, - "src/shared/plugins/plugin-content-pack-contributions.test.ts": 115, - "src/shared/plugins/plugin-install-lockfile.test.ts": 151, - "src/shared/plugins/plugin-kill-list.test.ts": 241, + "src/shared/plugins/contributions/plugin-content-pack-contributions.test.ts": 115, + "src/shared/plugins/installation/plugin-install-lockfile.test.ts": 151, + "src/shared/plugins/catalog/plugin-kill-list.test.ts": 241, "src/shared/plugins/plugin-language-pack-artifact.test.ts": 109, "src/shared/plugins/plugin-manifest.test.ts": 136, "src/shared/plugins/plugin-marketplace.test.ts": 205, - "src/shared/plugins/plugin-panel-call-admission.test.ts": 131, - "src/shared/plugins/plugin-panel-message-budget-traversal.test.ts": 180, - "src/shared/plugins/plugin-panel-message-budget.test.ts": 165, - "src/shared/plugins/plugin-panel-pong-frame.test.ts": 173, - "src/shared/plugins/plugin-panel-shell.test.ts": 216, - "src/shared/plugins/plugin-path-safety.test.ts": 94, + "src/shared/plugins/frames/plugin-panel-call-admission.test.ts": 131, + "src/shared/plugins/frames/plugin-panel-message-budget-traversal.test.ts": 180, + "src/shared/plugins/frames/plugin-panel-message-budget.test.ts": 165, + "src/shared/plugins/frames/plugin-panel-pong-frame.test.ts": 173, + "src/shared/plugins/frames/plugin-panel-shell.test.ts": 216, + "src/shared/plugins/installation/plugin-path-safety.test.ts": 94, "src/shared/plugins/plugin-vm-recipe-artifact.test.ts": 132, "src/shared/portuguese-ui-language-migration.test.ts": 114, "src/shared/posix-wait-status.test.ts": 31, diff --git a/config/scripts/export-legacy-plugin-catalog.mjs b/config/scripts/export-legacy-plugin-catalog.mjs new file mode 100644 index 00000000000..61bfacc6711 --- /dev/null +++ b/config/scripts/export-legacy-plugin-catalog.mjs @@ -0,0 +1,31 @@ +// Writes the frozen orca-marketplace.json that Orca builds before catalog format 2 read, from +// orca-catalog.json. Why generated: those builds reject any key they do not know, so the file +// carries only the original fields and authors should never edit it by hand. +// Usage: node export-legacy-plugin-catalog.mjs [orca-catalog.json] [orca-marketplace.json] +import { readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' + +/** Projects a format 2 catalog onto the frozen format 1 shape. */ +export function legacyPluginCatalog(catalog) { + return { + name: catalog.name, + owner: catalog.owner, + // Why skipped: format 1 has no `path`, so those builds would install the repository root. + plugins: catalog.plugins + .filter((entry) => !entry.source.path) + .map((entry) => ({ + id: entry.id, + source: { kind: entry.source.kind, url: entry.source.url, ref: entry.source.ref }, + ...(entry.description ? { description: entry.description } : {}), + categories: entry.categories ?? [] + })) + } +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { + const input = process.argv[2] ?? 'orca-catalog.json' + const output = process.argv[3] ?? 'orca-marketplace.json' + const catalog = JSON.parse(readFileSync(input, 'utf8')) + writeFileSync(output, `${JSON.stringify(legacyPluginCatalog(catalog), null, 2)}\n`) + console.log(`wrote ${output} with ${catalog.plugins.length} plugins`) +} diff --git a/config/scripts/generate-plugin-schemas.mjs b/config/scripts/generate-plugin-schemas.mjs new file mode 100644 index 00000000000..34db9c3e1ce --- /dev/null +++ b/config/scripts/generate-plugin-schemas.mjs @@ -0,0 +1,14 @@ +// Why: the zod schemas in src/shared/plugins are the only definition; this writes the published +// JSON Schemas and author references from them. The drift test in plugin-json-schema.test.ts +// fails when these files fall behind. +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { loadPluginSchemaModule, REPO_ROOT } from './plugin-schema-module-loader.mjs' + +const files = loadPluginSchemaModule('manifest/plugin-json-schema.ts').pluginSchemaGeneratedFiles() +for (const [relative, content] of Object.entries(files)) { + const target = path.join(REPO_ROOT, ...relative.split('/')) + mkdirSync(path.dirname(target), { recursive: true }) + writeFileSync(target, content) + console.log(`wrote ${relative}`) +} diff --git a/config/scripts/locale-ja-value-overrides.mjs b/config/scripts/locale-ja-value-overrides.mjs index 97457c9dc35..54d50e0c3f6 100644 --- a/config/scripts/locale-ja-value-overrides.mjs +++ b/config/scripts/locale-ja-value-overrides.mjs @@ -470,8 +470,8 @@ const JA_AUDIT_OVERRIDES = { 'ダークモードでのペイン間の分割線を制御します。', 'Countdown timer showing time until prompt cache expires (Claude agents).': 'プロンプトキャッシュが期限切れになるまでの時間を示すカウントダウンタイマー(Claude Agent )。', - 'Install and manage experimental Orca plugins.': - '実験的機能の Orca プラグインをインストール・管理します。', + 'Install and manage Orca plugins (Alpha).': + 'Orca プラグインをインストール・管理します(アルファ版)。', "Resident memory held by Orca plus the processes under each worktree's terminals.": 'Orca が保持する常駐メモリと、各ワークツリーのターミナル配下で実行中のプロセス。', 'No local Claude, Codex, or OpenCode usage found yet. The overview will populate after the next agent session writes token logs.': diff --git a/config/scripts/plugin-message-budget-benchmark.mjs b/config/scripts/plugin-message-budget-benchmark.mjs index 605eee85ea6..7a6fbfcf396 100644 --- a/config/scripts/plugin-message-budget-benchmark.mjs +++ b/config/scripts/plugin-message-budget-benchmark.mjs @@ -7,7 +7,7 @@ import { build } from 'esbuild' import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' const baseline = process.argv[2] ?? '20ab9950654' -const file = 'src/shared/plugins/plugin-panel-message-budget.ts' +const file = 'src/shared/plugins/frames/plugin-panel-message-budget.ts' async function load(contents) { const result = await build({ stdin: { contents, loader: 'ts', resolveDir: dirname(resolve(file)) }, diff --git a/config/scripts/plugin-schema-module-loader.mjs b/config/scripts/plugin-schema-module-loader.mjs new file mode 100644 index 00000000000..3b8ea696902 --- /dev/null +++ b/config/scripts/plugin-schema-module-loader.mjs @@ -0,0 +1,29 @@ +// Loads a src/shared/plugins TypeScript module from a plain Node script, so the generator and the +// validator run the exact runtime schemas without a TypeScript loader. +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { createRequire } from 'node:module' +import path from 'node:path' +import * as esbuild from 'esbuild' + +export const REPO_ROOT = path.resolve(import.meta.dirname, '..', '..') + +export function loadPluginSchemaModule(fileName) { + // Why under out/: the bundle keeps zod external, so it must resolve from the repo's node_modules. + mkdirSync(path.join(REPO_ROOT, 'out'), { recursive: true }) + const buildDir = mkdtempSync(path.join(REPO_ROOT, 'out', 'plugin-schemas-')) + try { + const outfile = path.join(buildDir, 'bundle.cjs') + esbuild.buildSync({ + entryPoints: [path.join(REPO_ROOT, 'src', 'shared', 'plugins', fileName)], + bundle: true, + platform: 'node', + format: 'cjs', + outfile, + logLevel: 'error', + packages: 'external' + }) + return createRequire(import.meta.url)(outfile) + } finally { + rmSync(buildDir, { recursive: true, force: true }) + } +} diff --git a/config/scripts/validate-plugin-files.mjs b/config/scripts/validate-plugin-files.mjs new file mode 100644 index 00000000000..a10d955e58e --- /dev/null +++ b/config/scripts/validate-plugin-files.mjs @@ -0,0 +1,216 @@ +// Strict check of plugin manifests and catalogs, for authors and catalog CI. +// Usage: node config/scripts/validate-plugin-files.mjs ... +// For a plugin directory in a Git repository it also prints the catalog release entry for the +// committed HEAD, with the `source.path` to list when the plugin is not the repository root. +import { createHash } from 'node:crypto' +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { loadPluginSchemaModule } from './plugin-schema-module-loader.mjs' + +const { validatePluginFile } = loadPluginSchemaModule('installation/plugin-file-validation.ts') +const { parsePluginManifest } = loadPluginSchemaModule('manifest/plugin-manifest.ts') +const { pluginCatalogReleaseEntry } = loadPluginSchemaModule('catalog/plugin-catalog-release.ts') +// Why the install code itself: the printed contentHash must be what Orca computes at install. +const { materializePluginGitTree } = loadPluginSchemaModule( + '../../main/plugins/installation/plugin-git-tree.ts' +) +const { hashPluginTree } = loadPluginSchemaModule('../../main/plugins/plugin-content-hash.ts') +const { gitExecFileAsync } = loadPluginSchemaModule('../../main/git/runner.ts') +const { formatPluginSchemaIssues } = loadPluginSchemaModule('manifest/plugin-schema-issues.ts') +const { isPluginRepositoryPath } = loadPluginSchemaModule('installation/plugin-path-safety.ts') +const { parseGitRepositoryIdentity } = loadPluginSchemaModule('catalog/plugin-marketplace.ts') +const { catalogImagesFromManifest } = loadPluginSchemaModule( + 'images/plugin-manifest-catalog-images.ts' +) +const ARTIFACT_PARSERS = { + themes: loadPluginSchemaModule('artifacts/plugin-theme-artifact.ts').parsePluginAppThemeArtifact, + terminalThemes: loadPluginSchemaModule('artifacts/plugin-terminal-theme-artifact.ts') + .parsePluginTerminalThemeArtifact, + iconThemes: loadPluginSchemaModule('artifacts/plugin-icon-theme-artifact.ts') + .parsePluginIconThemeArtifact +} +const { parsePluginLanguagePackArtifact } = loadPluginSchemaModule( + 'artifacts/plugin-language-pack-artifact.ts' +) + +// Why strict here: Orca skips unknown theme tokens and icon slots at runtime, so authors only +// learn about a typo from this check. +function artifactErrors(pluginDir, manifest) { + return Object.entries(ARTIFACT_PARSERS).flatMap(([point, parse]) => + manifest.contributes[point].flatMap((contribution) => { + let text = '' + try { + text = readFileSync(path.join(pluginDir, ...contribution.path.split('/')), 'utf8') + parse(text, 'strict') + return [] + } catch (error) { + const detail = error.issues + ? formatPluginSchemaIssues(error.issues, JSON.parse(text)) + : error.message + return [`contributes.${point} "${contribution.id}" (${contribution.path}): ${detail}`] + } + }) + ) +} + +// Why: Orca drops protected security copy from a pack at load, which authors would never notice. +function languagePackErrors(pluginDir, manifest) { + return manifest.contributes.languagePacks.flatMap((pack) => { + const file = path.join(pluginDir, ...pack.path.split('/')) + const parsed = existsSync(file) + ? parsePluginLanguagePackArtifact(readFileSync(file, 'utf8'), 'strict') + : { ok: false, error: 'file not found' } + return parsed.ok + ? [] + : [`contributes.languagePacks "${pack.locale}" (${pack.path}): ${parsed.error}`] + }) +} + +async function git(cwd, args) { + return (await gitExecFileAsync(args, { cwd })).stdout.trim() +} + +/** Release entry for HEAD of the repository holding `pluginDir`; a note when that is not possible. */ +async function catalogReleaseEntry(pluginDir) { + // Why --show-prefix: it is the plugin's directory in the repository, always with `/`. + const prefix = await git(pluginDir, ['rev-parse', '--show-prefix']).catch(() => null) + if (prefix === null) { + return { note: 'no catalog release entry: the plugin is not in a Git repository' } + } + const sourcePath = prefix.replace(/\/$/, '') || undefined + if (sourcePath !== undefined && !isPluginRepositoryPath(sourcePath)) { + return { note: `no catalog release entry: ${sourcePath} cannot be a catalog source path` } + } + const commit = await git(pluginDir, ['rev-parse', 'HEAD']) + const dirty = (await git(pluginDir, ['status', '--porcelain', '--', '.'])) !== '' + const tree = mkdtempSync(path.join(tmpdir(), 'orca-plugin-release-')) + try { + await materializePluginGitTree({ + repository: pluginDir, + commit, + destination: tree, + ...(sourcePath ? { path: sourcePath } : {}) + }) + const hashed = await hashPluginTree(tree) + const rawManifest = JSON.parse(readFileSync(path.join(tree, 'orca-plugin.json'), 'utf8')) + const parsed = parsePluginManifest(rawManifest) + const release = + hashed.ok && parsed.ok + ? pluginCatalogReleaseEntry(parsed.manifest, { commit, contentHash: hashed.hash }) + : null + // Why here: a new release entry must carry the manifest's images, or listings lose their covers. + const images = release + ? await catalogImagesFromManifest({ + manifest: rawManifest, + prefix: sourcePath, + read: async (repositoryPath, maxBytes) => { + const file = path.join( + tree, + repositoryPath.slice(sourcePath ? sourcePath.length + 1 : 0) + ) + return existsSync(file) && statSync(file).size <= maxBytes ? readFileSync(file) : null + }, + sha256: (bytes) => createHash('sha256').update(bytes).digest('hex') + }) + : null + const entry = release && images ? { ...release, images } : release + if (!entry) { + return { + note: `no catalog release entry: committed plugin is not installable (${hashed.ok ? (parsed.ok ? 'catalogs need manifest format 2' : parsed.error) : hashed.error})` + } + } + return { + entry, + sourcePath, + ...(dirty ? { note: 'uncommitted changes are not in this release; commit them first' } : {}) + } + } finally { + rmSync(tree, { recursive: true, force: true }) + } +} +// Why a warning: only the official and community catalogs require it; a private catalog may not. +async function publisherOwnerWarning(pluginDir) { + const remote = await git(pluginDir, ['remote', 'get-url', 'origin']).catch(() => '') + const owner = parseGitRepositoryIdentity(remote)?.owner + const { publisher } = JSON.parse(readFileSync(path.join(pluginDir, 'orca-plugin.json'), 'utf8')) + return owner && owner.toLowerCase() !== publisher.toLowerCase() + ? `warning: the official and community catalogs require publisher to match the repository owner "${owner}" (publisher is "${publisher}")` + : null +} + +const targets = process.argv.slice(2) +if (targets.length === 0) { + console.error( + 'usage: validate-plugin-files ...' + ) + process.exit(2) +} +let failed = false +for (const target of targets) { + const files = statSync(target).isDirectory() + ? ['orca-plugin.json', 'orca-catalog.json'] + .map((name) => path.join(target, name)) + .filter((file) => existsSync(file)) + : [target] + if (files.length === 0) { + console.error(`${target}: no orca-plugin.json or orca-catalog.json`) + failed = true + } + let manifestOk = false + for (const file of files) { + let raw + try { + raw = JSON.parse(readFileSync(file, 'utf8')) + } catch (error) { + console.error(`${file}: invalid JSON: ${error.message}`) + failed = true + continue + } + const root = path.dirname(file) + // Why contained: an image path must stay inside the catalog repository. + const readCatalogFile = (relative) => { + const target = path.resolve(root, relative) + if (!target.startsWith(`${path.resolve(root)}${path.sep}`) || !existsSync(target)) { + return null + } + const bytes = readFileSync(target) + return { bytes, sha256: createHash('sha256').update(bytes).digest('hex') } + } + const errors = validatePluginFile(path.basename(file), raw, readCatalogFile) + if (errors.length === 0 && path.basename(file) === 'orca-plugin.json') { + const { manifest } = parsePluginManifest(raw) + errors.push(...artifactErrors(root, manifest), ...languagePackErrors(root, manifest)) + } + for (const error of errors) { + console.error(`${file}: ${error}`) + } + failed ||= errors.length > 0 + if (errors.length === 0) { + console.log(`${file}: ok`) + manifestOk ||= path.basename(file) === 'orca-plugin.json' + } + } + if (manifestOk && statSync(target).isDirectory()) { + const { entry, sourcePath, note } = await catalogReleaseEntry(target) + if (sourcePath) { + console.log( + `\nCatalog source path (set "path" in this plugin's "source"): ${JSON.stringify(sourcePath)}` + ) + } + if (entry) { + console.log( + `\nCatalog release entry for HEAD (newest first in "releases"):\n${JSON.stringify(entry, null, 2)}` + ) + } + if (note) { + console.log(`${target}: ${note}`) + } + const ownerWarning = await publisherOwnerWarning(target) + if (ownerWarning) { + console.warn(`${target}: ${ownerWarning}`) + } + } +} +process.exit(failed ? 1 : 0) diff --git a/config/scripts/verify-packaged-plugin-resources.cjs b/config/scripts/verify-packaged-plugin-resources.cjs index 701c5e73214..89b8d35e8ff 100644 --- a/config/scripts/verify-packaged-plugin-resources.cjs +++ b/config/scripts/verify-packaged-plugin-resources.cjs @@ -74,8 +74,9 @@ function verifyPackagedPluginResources(resourcesDir) { } const index = readJsonFile(join(launchRoot, 'bundled-plugins.json'), 'bundled plugin index') readJsonFile(join(launchRoot, 'orca-marketplace.json'), 'marketplace index') - if (index?.version !== 1 || !Array.isArray(index.plugins) || index.plugins.length === 0) { - throw new Error('[verify-packaged-plugin-resources] bundled plugin index is empty or invalid') + // An empty index is valid: a release may ship no bundled plugins. + if (index?.version !== 1 || !Array.isArray(index.plugins)) { + throw new Error('[verify-packaged-plugin-resources] bundled plugin index is invalid') } const resolvedRoot = resolve(launchRoot) for (const entry of index.plugins) { @@ -108,4 +109,4 @@ function verifyPackagedPluginResources(resourcesDir) { ) } -module.exports = { verifyPackagedPluginResources } +module.exports = { hashPackagedPluginTree, verifyPackagedPluginResources } diff --git a/config/scripts/verify-packaged-plugin-resources.test.mjs b/config/scripts/verify-packaged-plugin-resources.test.mjs index e22d54b2ebd..19765947c43 100644 --- a/config/scripts/verify-packaged-plugin-resources.test.mjs +++ b/config/scripts/verify-packaged-plugin-resources.test.mjs @@ -5,7 +5,32 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' const require = createRequire(import.meta.url) -const { verifyPackagedPluginResources } = require('./verify-packaged-plugin-resources.cjs') +const { + hashPackagedPluginTree, + verifyPackagedPluginResources +} = require('./verify-packaged-plugin-resources.cjs') + +const FIXTURE_KEY = 'stablyai.orca-multipass-recipes' + +/** Copies the launch tree and indexes one shipped pack so byte checks have something to verify. */ +async function copyIndexedLaunchTree(resourcesDir) { + const launchRoot = join(resourcesDir, 'plugins', 'launch') + await cp(join(process.cwd(), 'resources', 'plugins', 'launch'), launchRoot, { recursive: true }) + await writeFile( + join(launchRoot, 'bundled-plugins.json'), + JSON.stringify({ + version: 1, + plugins: [ + { + pluginKey: FIXTURE_KEY, + path: FIXTURE_KEY, + contentHash: hashPackagedPluginTree(join(launchRoot, FIXTURE_KEY)) + } + ] + }) + ) + return launchRoot +} describe('verify packaged plugin resources', () => { it('accepts exact launch bytes copied into a packaged resources directory', async () => { @@ -26,17 +51,12 @@ describe('verify packaged plugin resources', () => { it('rejects mutated bytes in the packaged output', async () => { const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-packaged-plugins-')) try { - const launchRoot = join(resourcesDir, 'plugins', 'launch') - await cp(join(process.cwd(), 'resources', 'plugins', 'launch'), launchRoot, { - recursive: true - }) - await writeFile( - join(launchRoot, 'stablyai.orca-navigation-shortcuts', 'extra.json'), - '{"mutated":true}\n' - ) + const launchRoot = await copyIndexedLaunchTree(resourcesDir) + expect(() => verifyPackagedPluginResources(resourcesDir)).not.toThrow() + await writeFile(join(launchRoot, FIXTURE_KEY, 'extra.json'), '{"mutated":true}\n') expect(() => verifyPackagedPluginResources(resourcesDir)).toThrow( - 'packaged bytes do not match stablyai.orca-navigation-shortcuts' + `packaged bytes do not match ${FIXTURE_KEY}` ) } finally { await rm(resourcesDir, { recursive: true, force: true }) @@ -46,10 +66,7 @@ describe('verify packaged plugin resources', () => { it('rejects a CRLF checkout of the launch tree', async () => { const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-packaged-plugins-')) try { - const launchRoot = join(resourcesDir, 'plugins', 'launch') - await cp(join(process.cwd(), 'resources', 'plugins', 'launch'), launchRoot, { - recursive: true - }) + const launchRoot = await copyIndexedLaunchTree(resourcesDir) for (const entry of await readdir(launchRoot, { recursive: true })) { const path = join(launchRoot, entry) if (!(await stat(path)).isFile()) { diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index 11b5ee32ca3..a56b8038632 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -117,7 +117,7 @@ const NODE_RUNTIME_INCLUDE = [ // Times V8 string flattening, which is what Electron main runs. 'src/main/runtime/terminal-tail-redraw-wall-time.test.ts', 'src/main/agent-hooks/server-transport-interference.test.ts', - 'src/main/plugins/plugin-worker-supervision.integration.test.ts', + 'src/main/plugins/workers/plugin-worker-supervision.integration.test.ts', // Reconciliation loads the real native Parcel watcher; Bun can abort on Linux ARM. 'src/main/plugins/plugin-service-reconciliation.test.ts', 'src/main/usage/usage-scan-worker-event-loop.test.ts', diff --git a/docs/reference/plugin-authoring.md b/docs/reference/plugin-authoring.md new file mode 100644 index 00000000000..6a70d7ca69f --- /dev/null +++ b/docs/reference/plugin-authoring.md @@ -0,0 +1,249 @@ +# Writing an Orca plugin + +From an empty folder to a plugin anyone can install from a catalog. The field-by-field rules +live in the generated references: [manifest](plugin-manifest.md), [catalog](plugin-catalog.md), +[host API](plugin-host-api.md), [themes](plugin-themes.md) and [theme tokens](plugin-theme-tokens.md), +[syntaxes](plugin-syntax.md). + +## 1. Write + +A plugin is a folder (in practice its own Git repository) with `orca-plugin.json` at the root. +Start from `examples/plugins/orca-gleam-syntax`, which carries the JSON Schema for editor +completion, or from one of: + +| Example | Execution | Shows | +| ------------------------------------ | ---------------- | ------------------------------------ | +| `examples/plugins/orca-gleam-syntax` | `data` | A syntax, no code | +| `examples/plugins/hello-orca` | `trusted-worker` | A panel, a worker command and events | + +Set `publisher` to the GitHub or GitLab user or organization that owns your repository (`acme` +for `github.com/acme/gleam-syntax`), or the official and community catalogs reject the listing. +When copying the Gleam example, also drop its `stablyai` publisher and `orca-` id prefix: both are +reserved for Orca's own plugins. + +Pick the lowest `execution` profile that covers what you contribute; it decides what the user is +asked to approve: + +- `data`: themes, terminal themes, icon themes, syntaxes, translations. Installs in one click. +- `declarative`: commands that run Orca actions, shortcuts, VM recipes. Reviewed before they + are enabled. +- `sandboxed-ui`: panels and custom editors, in a sandboxed frame that talks to Orca only + through `window.orca` (see [Frames](#frames-windoworca)) and can call only the host methods + whose surfaces list `panel` or `editor`. + Orca's colors reach frame CSS as `--orca-background`, `--orca-foreground`, `--orca-primary` + and the rest of its panel token set, so they never collide with a frame's own variables. +- `trusted-worker`: `main` runs in a separate Node process and can call every host method its + `capabilities` allow. There is no global switch for these full-access plugins: each one runs + only after the user approves it on install. An enterprise policy to block full-access plugins + may come later. + +Set `engines.orca` to the newest `Since` of any field you use, and `pluginApi` (currently `1`) +only when a panel or worker calls the host API. + +### Frames: `window.orca` + +Before any plugin code runs, the frame shell defines a frozen `window.orca`. It is the only +supported way to talk to Orca; the messages underneath are internal and change without notice. + +```js +orca.api // { major: 1, minor: 0 }: the host API this Orca implements +if (orca.host.has('workspace.readContext')) { + // Synchronous: is the method offered to this surface by this Orca? + const controller = new AbortController() + try { + const context = await orca.host.call('workspace.readContext', {}, { signal: controller.signal }) + } catch (error) { + // error.code, error.message, error.retryable, error.retryAfterMs (rate_limited only) + } +} +const subscription = orca.on('theme', ({ colorScheme }) => {}) +subscription.dispose() +``` + +Methods, their surfaces and their errors are in the [host API reference](plugin-host-api.md). +Aborting the `signal` rejects the call with `cancelled`. Treat an error `code` you do not know as +`action_failed`. Params are strict: a field the running Orca does not know is refused with +`invalid_params`, so check `orca.host.has` or `orca.api.minor` before using a newer one. + +Workers get the same calls on the `orca` object passed to `activate(orca)`: +`orca.api`, `orca.host.call(method, params, { signal })`, `orca.host.has(method)`, plus +`orca.commands.register(id, (args, { signal }) => …)` and `orca.events.on(event, handler)`, which +both return `{ dispose() }`. A command's `signal` aborts when Orca stops waiting for it. In +`agent.status.changed`, `state` and `mainAgent.state` are one of `working`, `blocked`, `waiting` +and `done`. + +### Custom editors + +A custom editor opens files that end in one of its `extensions` (with the dot, such as +`.excalidraw`) or carry one of its exact `filenames` in the editor tab, beside Source. Syntaxes +match files the same way. It runs in the same sandboxed frame as a panel (no Node, no file system), and Orca keeps +the buffer, dirty state, saving, the external-change banner, read-only tabs and remote (SSH or +paired host) files; the frame only gets the document, never a path. Editor and panel plugins are +trusted code, and an editor can read every file it opens, so install ones you trust. + +An editor's `window.orca` adds `document`: + +```js +const doc = await orca.document.get() // { version, readOnly, content, fileType? } +// The current document right away, then each one Orca sends when the file changes outside the editor. +orca.document.onDidChange((doc) => render(doc)) +await orca.document.applyEdit({ baseVersion: doc.version, content: { kind: 'text', text } }) +orca.on('editor.flush', () => sendPendingEdit()) // before a save; Orca waits for the promise +``` + +`content` is `{ kind: 'text', text }` for a text editor (the default `contentKind`). An edit is +the full new content, made against document `baseVersion`; send one only when the user changes +the document, never on load. `applyEdit` rejects with `conflict` when the file changed outside +the editor since that version (edit the newer document instead), `too_large` past the size cap, +and `not_permitted` when `readOnly` is true (an agent transcript, say). `version` rises with each +document Orca sends, not with your own edits. + +Set `"contentKind": "bytes"` for a binary format such as a spreadsheet. Orca reads the file +itself (locally, over SSH or from a paired host) and the document's `content` is +`{ kind: 'bytes', bytes }`, where `bytes` is an `ArrayBuffer`, plus the matched suffix as +`fileType`. An edit is the whole new file the same way: `content: { kind: 'bytes', bytes }`. +Orca marks the tab dirty and saves the bytes with the tab's normal save, replacing the file +atomically; a save is refused if the file changed on disk since the document's version, and the +banner offers Reload or Overwrite. Saving to a remote host needs an Orca server new enough to +replace binary files. Source shows the binary placeholder, unsaved bytes are not kept across +restarts, and bytes tabs don't autosave. + +Documents and edits are capped at 64 MiB, text or bytes, on every route (local, SSH and paired +hosts): a larger file shows "too large to open", and a larger edit is rejected with `too_large`. +Past 16 MiB the tab notes that the file may take a moment. Orca saves on its own save shortcut pressed +inside the frame, never on a call alone. + +Theme changes do not reload the frame: the shell updates the `--orca-*` variables and the +`light`/`dark` class on `` in place and emits `theme`. Restyle from the variables, or +listen with `orca.on('theme', …)`, to follow it. + +## 2. Load it while you work + +1. Settings → Plugins: click **Turn on plugins**. +2. **Development folders…** → add the plugin folder. A development folder overrides an installed + copy with the same `.`. +3. Review and enable it. Nothing runs before that. + +Orca watches development folders and reloads the plugin when its manifest or files change. +Theme, syntax and translation edits show up as you save. Panels and workers are not bound to +their files in development, so saving does not ask for approval again; adding a capability or +moving to a higher execution profile does. Shortcuts and recipes are tied to their files, so +each change to them is reviewed again. Use **Restart** on the plugin row after changing a +worker, and the plugin's logs to see what it printed. + +## 3. Validate + +```sh +pnpm validate:plugin path/to/plugin +``` + +This runs the manifest checks Orca runs, strictly: unknown keys, limits, contributions beyond the +execution profile, and an `engines.orca` too low for the fields used. On a catalog it also +checks that listed images exist and fit. In a Git repository it warns when `publisher` is not the +owner of the `origin` remote. Run it in CI for the plugin and catalog repositories. + +## 4. Publish to a catalog + +A catalog is a Git repository with `orca-catalog.json` at its root that lists plugins and pins +each release to an exact commit and file hash, so Orca refuses bytes the catalog did not vouch +for. + +1. Commit the plugin and run `pnpm validate:plugin` on its directory. It prints the + release entry for `HEAD`: version, full commit SHA, `contentHash`, execution profile, minimum + Orca, contribution summary, capabilities and file types. Every field must match + what Orca finds at install, so paste it rather than writing it by hand. +2. Add or update the plugin's entry in the catalog's `orca-catalog.json`, newest release first: + + ```json + { + "id": "acme.gleam-syntax", + "source": { + "kind": "git", + "url": "https://github.com/acme/orca-gleam-syntax.git", + "ref": "main" + }, + "categories": ["languages"], + "releases": [{ "...": "the printed entry" }] + } + ``` + + `commit` must be a full 40-character SHA. A tag or branch name is refused by validation and + skipped by Orca. Keep the previous release listed when you raise `engines.orca`, so older + Orca still has something to install. + + **One repository can hold many plugins.** Put each in its own directory, such as + `plugins/gleam-syntax/orca-plugin.json`, and add `"path": "plugins/gleam-syntax"` to its + `source`; validating that directory prints the `path` to use. Orca installs only that + directory, and its `contentHash` covers only its files, so commits to sibling plugins never + change it. Moving a plugin to another directory counts as a different repository. + +3. Run `pnpm validate:plugin path/to/catalog` and regenerate the older + `orca-marketplace.json` as described in the [catalog reference](plugin-catalog.md#clients-before-format-2). +4. Users add the catalog under Settings → Plugins → ⋯ → **Catalog sources…** with its Git URL and ref. + +Users approve a plugin once per level of access. An update from the same repository that asks +for nothing new, code changes included, installs without asking again and stays enabled. Orca +asks again, listing what is new, when an update adds a capability, raises the execution profile, +adds a command, shortcut, panel or event subscription, ships virtual machine recipes, or comes from +a different repository. + +With **Update plugins automatically** on (the default), Orca installs such updates for enabled +catalog plugins by itself, checking at startup and every 6 hours. A release installs only once it +has been listed for 12 hours, or at once from the official catalog, so a takedown has time to land. +Only pinned catalog releases (a `releases` entry with its commit and content hash) install by +themselves; a listing without one updates only when the user clicks Update. This includes +full-access plugins: an update that asks for nothing new runs its new code with the access the user +already granted, the same way an editor's extensions update. Turn the setting off to review every +update. An update that needs approval is not installed: the current version keeps running and the +plugin shows **Update needs review**. + +## Planned contribution points + +These `contributes` keys are reserved: a manifest may list them only as empty arrays until +their format ships. Each is planned as declared data that Orca renders and enforces, not +arbitrary plugin UI: + +- `taskSources`: issue and task providers such as Asana, Linear or Jira, filled by a worker. +- `connections`: account providers with a host-run sign-in. +- `agentHarnesses`: new coding-agent harnesses described as data. +- `statusBarItems`: declarative status bar widgets whose values a worker updates. +- `sidebarCards`: declarative left-sidebar cards whose values a worker updates. +- `mcpServers`: MCP server definitions offered to the agents Orca launches. +- `skills`: agent skills shipped as files. +- `hooks`: agent lifecycle hooks for the agents Orca launches. +- `settings`: typed plugin settings shown in Orca's settings UI. + +`skills`, `mcpServers` and `hooks` will mean what they mean in agent-plugin formats such as +Claude Code's. Orca does not reserve `agents`, because those formats use it for subagent files. + +See [the manifest reference](plugin-manifest.md#planned-contribution-points) for details. + +## Stability + +Plugins are in Alpha: the manifest v2 format and catalog format are stable contracts; items +listed as experimental may change. These parts are stable and change only additively: + +- manifest format 2, capability names and execution profiles; +- host API 1 (`"pluginApi": 1`): method names, params, results, surfaces and error codes; +- the frame API `window.orca` (`api`, `host.call`, `host.has`, `on`, and `document` in custom + editors) and the document `version`/`content` shape; +- theme token names, and theme ids once published (users' settings store them); +- catalog format 2 (`orca-catalog.json`). + +Experimental; these may still change without a new format: + +- the `agent.status.changed` payload: `paneKey` is not yet a terminal id `terminal.sendText` + accepts; +- the worker process model: Node version, lifecycle and the channel to Orca. Only + `activate(orca)` and `orca.api`, `orca.host`, `orca.commands`, `orca.events` and `orca.log` are + promised, + and workers may be stopped at any time; +- language-pack keys, which change when Orca's source moves or its English copy changes; packs + get best-effort coverage per release; +- `pnpm validate:plugin` output; only its exit codes (`0` valid, `1` invalid, `2` usage) are + stable; +- `settings:own`, which stores untyped values until plugins can declare settings; +- manifest format 1, which is deprecated. Catalogs need format 2. + +Data folders, `orca-plugin-image://` URLs, tab keys and the `pluginRoot` a worker receives are +internal. diff --git a/docs/reference/plugin-catalog.md b/docs/reference/plugin-catalog.md new file mode 100644 index 00000000000..fcc9d080345 --- /dev/null +++ b/docs/reference/plugin-catalog.md @@ -0,0 +1,160 @@ +# Orca plugin catalog reference + + + +The orca-catalog.json file that lists installable plugins and their releases. + +Add `"$schema": "https://raw.githubusercontent.com/stablyai/orca/main/docs/schemas/plugins/orca-catalog.v2.schema.json"` to `orca-catalog.json` for completion and inline errors in your editor. +`Since` is the first Orca release that reads the field. Older releases ignore fields they do not know. + +## Fields + +| Field | Type | Required | Since | Description | +| --- | --- | --- | --- | --- | +| `$schema` | string | no | 1.4.245 | JSON Schema URL for editor completion. Ignored by Orca. | +| `catalogVersion` | `2` | yes | 1.4.245 | Catalog format. Raised only for a change older Orca cannot ignore; a newer Orca reads this one. | +| `name` | string | yes | 1.4.245 | Catalog display name. Example: `"Orca Plugins"` | +| `owner` | string | yes | 1.4.245 | Account that publishes the catalog. Example: `"stablyai"` | +| `plugins` | [CatalogEntry](#catalogentry)[] | yes | 1.4.245 | Listed plugins, at most 2048. Each `id` should appear once; Orca keeps the first. An entry this Orca cannot read is skipped, never fatal. | + +## CatalogEntry + +One installable plugin. + +| Field | Type | Required | Since | Description | +| --- | --- | --- | --- | --- | +| `id` | string | yes | 1.4.245 | `.`, matching the plugin's manifest. Each half is at most 64 characters, and the reserved names `constructor` and `prototype` are refused. Example: `"acme.gleam-syntax"` | +| `source` | [CatalogGitSource](#cataloggitsource) | yes | | Where the plugin source lives. | +| `description` | string | no | 1.4.245 | Listing text shown in the catalog. | +| `categories` | string[] | no | 1.4.245 | Lowercase slugs used to filter the catalog, each listed once. At most 16; extras are ignored. | +| `releases` | [CatalogRelease](#catalogrelease)[] | yes | 1.4.245 | Installable releases, newest first. Orca installs the first one whose `minimumOrca` it meets, so keep older releases listed when you raise `engines.orca`. At most 32. | + +## CatalogGitSource + +Where the plugin source lives. + +| Field | Type | Required | Since | Description | +| --- | --- | --- | --- | --- | +| `kind` | `"git"` | yes | 1.4.245 | Source kind. Only `git` is supported. | +| `url` | string | yes | 1.4.245 | HTTPS or SSH URL of the plugin repository. Example: `"https://github.com/acme/orca-gleam-syntax.git"` | +| `ref` | string | yes | 1.4.245 | Branch or tag the author publishes from. Each release pins an exact commit. Example: `"main"` | +| `path` | string | no | 1.4.245 | Directory inside the repository that holds `orca-plugin.json`, so one repository can publish many plugins. Use `/` separators, with no leading `/`, `.` or `..`. Omit when the plugin is the repository root. Example: `"plugins/gleam-syntax"` | + +## CatalogRelease + +The exact release the catalog vouches for. Orca refuses bytes that differ. + +| Field | Type | Required | Since | Description | +| --- | --- | --- | --- | --- | +| `version` | string | yes | 1.4.245 | The manifest's `version` for this release. Example: `"1.2.0"` | +| `commit` | string | yes | 1.4.245 | Full SHA-1 commit Orca checks out for this release. | +| `contentHash` | string | yes | 1.4.245 | SHA-256 of the plugin's installed files, as Orca computes it at install. | +| `execution` | `"data"` \| `"declarative"` \| `"sandboxed-ui"` \| `"trusted-worker"` | yes | 1.4.245 | The manifest's `execution` profile. Example: `"data"` | +| `minimumOrca` | string | yes | 1.4.245 | The manifest's `engines.orca` without `>=`. Older Orca lists the plugin but will not install it. Example: `"1.4.245"` | +| `types` | (`"interface-language"` \| `"app-theme"` \| `"terminal-theme"` \| `"icon-theme"` \| `"syntax"` \| `"instructions"` \| `"panel"` \| `"editor"` \| `"worker"`)[] | no | 1.4.245 | Optional; Orca derives it from `contributes` and `execution`. When present, every kind of contribution the release carries, matching its manifest exactly. `instructions` needs the `declarative` profile or higher, `panel` and `editor` need `sandboxed-ui`, and `worker` needs `trusted-worker`. Example: `["syntax"]` | +| `name` | string | no | 1.4.245 | The manifest's `name`, so Orca can suggest the plugin before downloading it. Example: `"Gleam"` | +| `license` | string | no | 1.4.245 | SPDX license expression. Example: `"MIT"` | +| `repository` | string | no | 1.4.245 | Source repository page, https only. Example: `"https://github.com/acme/orca-gleam-syntax"` | +| `homepage` | string | no | 1.4.245 | Project homepage, https only. Example: `"https://example.com/gleam"` | +| `updatedAt` | string | no | 1.4.245 | When this release was published, as an ISO 8601 date or date-time. Example: `"2026-10-20"` | +| `changelog` | string | no | 1.4.245 | What changed in this release, as plain text. At most 2000 characters. Example: `"Adds highlighting for use expressions."` | +| `images` | [CatalogImages](#catalogimages) | no | 1.4.245 | Images extracted from the release by catalog CI, which re-encodes them and strips metadata. Same rules as the manifest: icon PNG, WEBP, static, at least 128×128, square, at most 256 KB. Cover PNG, JPEG, WEBP, static, at least 800×500, at most 1024 KB. Screenshots PNG, JPEG, WEBP, static, at least 1280×800, at most 1536 KB. Images must not imitate Orca's trust badges. | +| `themePreview` | [CatalogThemePreview](#catalogthemepreview)[] | no | 1.4.245 | Up to 4 theme palettes for listing art. Display only; the installed theme files decide the real colors. | +| `contributes` | [CatalogContribution](#catalogcontribution)[] | no | 1.4.245 | What the release adds, as the listing shows it. Generate it with the catalog tooling; Orca refuses an install whose manifest says otherwise. | +| `capabilities` | (`"workspace:read"` \| `"agent:read"` \| `"terminal:send"` \| `"notifications:show"` \| `"storage:own"` \| `"secrets:own"` \| `"settings:own"`)[] | no | 1.4.245 | Every capability the release asks for. Orca refuses an install whose manifest asks for different ones. Example: `["notifications:show"]` | +| `fileTypes` | string[] | no | 1.4.245 | Extensions, without the dot, that this release opens. Orca suggests the plugin when such a file has no viewer. | +| `editorFileTypes` | string[] | no | 1.4.245 | The `fileTypes` this release opens in a custom editor. Orca suggests the plugin for such a file even when it already opens it as text. | + +## CatalogImages + +Images extracted from the release by catalog CI, which re-encodes them and strips metadata. Same rules as the manifest: icon PNG, WEBP, static, at least 128×128, square, at most 256 KB. Cover PNG, JPEG, WEBP, static, at least 800×500, at most 1024 KB. Screenshots PNG, JPEG, WEBP, static, at least 1280×800, at most 1536 KB. Images must not imitate Orca's trust badges. + +| Field | Type | Required | Since | Description | +| --- | --- | --- | --- | --- | +| `icon` | [CatalogImage](#catalogimage) | no | | An image stored in the catalog repository. | +| `cover` | [CatalogImage](#catalogimage) | no | | An image stored in the catalog repository. | +| `screenshots` | [CatalogImage](#catalogimage)[] | no | 1.4.245 | Screenshots, at most 6. | + +## CatalogImage + +An image stored in the catalog repository. + +| Field | Type | Required | Since | Description | +| --- | --- | --- | --- | --- | +| `path` | string | yes | 1.4.245 | File in the catalog repository, relative to its root. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused. Example: `"assets/acme.gleam/cover.png"` | +| `alt` | string | yes | 1.4.245 | What the image shows, for screen readers. Required. Example: `"The Dusk theme on a terminal"` | +| `sha256` | string | yes | 1.4.245 | SHA-256 of the file's bytes; Orca refuses bytes that differ. | +| `width` | integer | yes | 1.4.245 | Width in pixels. Example: `1600` | +| `height` | integer | yes | 1.4.245 | Height in pixels. Example: `1000` | + +## CatalogThemePreview + +Colors to draw a theme card before install. + +| Field | Type | Required | Since | Description | +| --- | --- | --- | --- | --- | +| `label` | string | yes | 1.4.245 | Theme name. Example: `"Dusk"` | +| `base` | `"light"` \| `"dark"` | yes | 1.4.245 | Light or dark theme. | +| `background` | string | yes | 1.4.245 | Window background. Example: `"#1b1430"` | +| `foreground` | string | yes | 1.4.245 | Main text. Example: `"#1b1430"` | +| `accent` | string | yes | 1.4.245 | Accent, such as buttons. Example: `"#1b1430"` | +| `surface` | string | yes | 1.4.245 | Cards and sidebars. Example: `"#1b1430"` | + +## CatalogContribution + +One thing the release adds to Orca. + +| Field | Type | Required | Since | Description | +| --- | --- | --- | --- | --- | +| `kind` | `"panels"` \| `"commands"` \| `"events"` \| `"languagePacks"` \| `"keybindings"` \| `"vmRecipes"` \| `"themes"` \| `"terminalThemes"` \| `"iconThemes"` \| `"syntaxes"` \| `"customEditors"` \| `"newFiles"` | yes | 1.4.245 | Contribution point. Example: `"themes"` | +| `name` | string | yes | 1.4.245 | What the item is called in Orca. Example: `"Dusk"` | +| `detail` | string | no | 1.4.245 | Extra context, such as file extensions. | + +## Versions and compatibility + +- **What only lists is tolerant.** A catalog never decides what runs (installing re-checks the exact bytes), so Orca keeps whatever it can read: unknown keys are ignored at every level, an entry it cannot read is skipped, an invalid display-only field is dropped, a list over its limit is trimmed, and a repeated `id` keeps the first entry. Validation in CI is where these become errors. +- **The file name never changes.** `catalogVersion` (now 2) rises only for a change older clients cannot ignore; an Orca that sees a higher number refuses the catalog with "update Orca to browse this catalog" rather than misreading it. +- **Releases are a list, newest first.** Orca offers the first release whose `minimumOrca` it meets, so keep the previous release listed when a new one raises `engines.orca`; Orca shows "Needs Orca X" only when none fits. + +## Publisher names and trust + +- **Publisher is the repository owner.** The official and community catalogs list a plugin only when its `publisher` equals the GitHub or GitLab user or organization that owns its repository (`acme` for `github.com/acme/gleam-syntax`). Their CI enforces this; Orca does not check it for catalogs you add yourself. +- **Reserved names install only from Orca's official catalog.** The publishers `stablyai`, `official`, `anthropic`, `openai`, `github` and any starting `orca` (any case), and ids starting `orca-`, are refused from any other catalog, a Git URL or a local folder. A catalog that lists one is refused whole. `pnpm validate:plugin` rejects these publishers except `stablyai`. +- **Trust comes from the catalog's address.** Orca labels a plugin with where it came from: the Official mark for Orca's catalog, otherwise the catalog's name and the owner in its Git URL (`github.com/acme`, `gitlab.com/acme/tools`), never the manifest's text. Development folders show `Dev · unverified`, Git URL installs `Unverified`. +- **An install stays with its catalog.** Orca records the catalog each plugin was installed from and updates it only from there. When another catalog lists the same id, the plugin page says so, and switching means removing the plugin and installing it again. + +## One repository can hold many plugins + +Give each plugin its own directory and set `source.path` to it. Orca downloads the pinned commit but installs only that directory, and `contentHash` covers only its files, so a commit that changes a sibling plugin leaves this plugin's hash, and its users, untouched. Moving a plugin to another directory counts as moving repositories: users review the update before it installs. + +```json +{ "id": "acme.gleam-syntax", "source": { "kind": "git", "url": "https://github.com/acme/orca-plugins.git", "ref": "main", "path": "plugins/gleam-syntax" } } +``` + +Run `pnpm validate:plugin plugins/gleam-syntax` in that repository to print the directory's `path` and its release entry. Listing images stay relative to the catalog repository. + +## Install links + +An install link opens Orca at a plugin's page in Settings → Plugins. It never installs: the user reads the page and presses Install, and the usual review applies. If plugins are off, the page waits behind **Turn on plugins**. + +```text +orca://plugins/.?catalog= +``` + +- `catalog` defaults to `official`. `community` is Orca's community catalog. Any other value must be an HTTPS or SSH Git URL of at most 512 characters, read at its `main` branch, without a password or token. Orca asks **Add catalog …?** before fetching a catalog the user hasn't added, and that includes `community`. +- Reserved publishers (above) are accepted only with the official catalog. A link longer than 2048 characters, with another query parameter, or with an id that isn't `.` shows a short error and does nothing else. Repeats of the same link within a few seconds are ignored. +- Browsers and README viewers often block custom schemes, so link to the directory page instead. It opens the `orca://` link and shows install steps when Orca is missing: `https://onorca.dev/plugins/.?catalog=`. + +For a README: + +```md +[Open in Orca](https://onorca.dev/plugins/acme.gleam-syntax?catalog=community) +``` + +## Clients before format 2 + +Orca reads `orca-catalog.json` first and falls back to `orca-marketplace.json`. That older file is frozen: shipped clients reject any key they do not know, so it carries only `name`, `owner` and each plugin's `id`, `source`, `description` and `categories`, and leaves out plugins with a `source.path`, which those clients would install from the repository root. Generate it from `orca-catalog.json` rather than editing two files: + +```sh +node config/scripts/export-legacy-plugin-catalog.mjs orca-catalog.json orca-marketplace.json +``` diff --git a/docs/reference/plugin-host-api.md b/docs/reference/plugin-host-api.md new file mode 100644 index 00000000000..0ca76671dc6 --- /dev/null +++ b/docs/reference/plugin-host-api.md @@ -0,0 +1,147 @@ +# Orca plugin host API reference + + + +Host API 1.0 (`"pluginApi": 1`). Workers call a method with `await orca.host.call(name, params)`; panels and custom editors call the same methods through `window.orca.host.call(name, params)`. Each method lists the surfaces allowed to call it; any other surface gets `not_permitted`. `orca.host.has(name)` answers synchronously whether this Orca offers a method on the current surface, and `orca.api` is `{ major, minor }`. See [Plugin authoring](./plugin-authoring.md) for the frame API. + +Every call is checked against the capabilities the user approved, and params are validated before Orca runs anything: params are strict, so a field this Orca does not know is refused with `invalid_params` instead of being dropped. Mutations are recorded in the audit log as `plugin:.`. + +## Methods + +| Method | Capability | Surfaces | Mutation | Since | Summary | +| --- | --- | --- | --- | --- | --- | +| [`workspace.readContext`](#workspacereadcontext) | `workspace:read` | worker, panel, editor | no | 1.0 | Branch, name and terminal ids of the focused workspace, or null when none is focused. | +| [`terminal.sendText`](#terminalsendtext) | `terminal:send` | worker | yes | 1.0 | Types text into one terminal named by id, optionally pressing Enter. | +| [`notifications.show`](#notificationsshow) | `notifications:show` | worker, panel, editor | yes | 1.0 | Shows a desktop notification. | +| [`storage.get`](#storageget) | `storage:own` | worker | no | 1.0 | Reads a JSON value from the plugin's private store; null when absent. | +| [`storage.set`](#storageset) | `storage:own` | worker | yes | 1.0 | Writes a JSON value to the plugin's private store. | +| [`storage.delete`](#storagedelete) | `storage:own` | worker | yes | 1.0 | Removes a key from the plugin's private store. | +| [`storage.keys`](#storagekeys) | `storage:own` | worker | no | 1.0 | Lists the keys in the plugin's private store. | +| [`secrets.get`](#secretsget) | `secrets:own` | worker | no | 1.0 | Reads a secret from the plugin's encrypted secrets file; null when absent. | +| [`secrets.set`](#secretsset) | `secrets:own` | worker | yes | 1.0 | Stores a secret in the plugin's encrypted secrets file. | +| [`secrets.delete`](#secretsdelete) | `secrets:own` | worker | yes | 1.0 | Removes a secret from the plugin's encrypted secrets file. | +| [`settings.get`](#settingsget) | `settings:own` | worker | no | 1.0 | Reads every setting the plugin owns. | +| [`settings.set`](#settingsset) | `settings:own` | worker | yes | 1.0 | Writes one setting the plugin owns. | + +### `workspace.readContext` + +Branch, name and terminal ids of the focused workspace, or null when none is focused. + +```ts +(params: {}) => { branch: string; displayName: string; terminals: { id: string }[] } | null +``` + +### `terminal.sendText` + +Types text into one terminal named by id, optionally pressing Enter. + +```ts +(params: { terminalId: string; text: string; enter?: boolean }) => { accepted: boolean } +``` + +### `notifications.show` + +Shows a desktop notification. + +```ts +(params: { title: string; body?: string }) => { delivered: boolean } +``` + +### `storage.get` + +Reads a JSON value from the plugin's private store; null when absent. + +```ts +(params: { key: string }) => { value: JSON } +``` + +### `storage.set` + +Writes a JSON value to the plugin's private store. + +```ts +(params: { key: string; value: JSON }) => { ok: true } +``` + +### `storage.delete` + +Removes a key from the plugin's private store. + +```ts +(params: { key: string }) => { ok: true } +``` + +### `storage.keys` + +Lists the keys in the plugin's private store. + +```ts +(params: {}) => { keys: string[] } +``` + +### `secrets.get` + +Reads a secret from the plugin's encrypted secrets file; null when absent. + +```ts +(params: { key: string }) => { value: string | null } +``` + +### `secrets.set` + +Stores a secret in the plugin's encrypted secrets file. + +```ts +(params: { key: string; value: string }) => { ok: true } +``` + +### `secrets.delete` + +Removes a secret from the plugin's encrypted secrets file. + +```ts +(params: { key: string }) => { ok: true } +``` + +### `settings.get` + +Reads every setting the plugin owns. + +```ts +(params: {}) => { settings: Record } +``` + +### `settings.set` + +Writes one setting the plugin owns. + +```ts +(params: { key: string; value: JSON }) => { ok: true } +``` + +## Errors + +A failed call rejects with an `Error` carrying `code`, `message`, `retryable` and, for `rate_limited`, `retryAfterMs`. Treat a code not listed here as `action_failed`: + +- `invalid_request`: the call itself is malformed. +- `invalid_params`: params do not match the method, including a field this Orca does not know; the message names it. +- `unknown_method`: the method does not exist in this Orca. +- `capability_denied`: the manifest does not declare the capability. +- `consent_required`: the plugin is disabled or the user has not approved this version. +- `not_permitted`: the method or document is not available here, e.g. a worker-only method from a panel. +- `rate_limited`: calls arrive faster than the budget; `retryAfterMs` says when to try again. +- `unavailable`: the service behind the method is not running yet. +- `action_failed`: the method ran and failed. +- `cancelled`: the caller aborted the call through its `signal`. +- `timeout`: Orca gave up waiting. +- `conflict`: the call was made against a stale version, e.g. an editor edit after an outside change. +- `too_large`: the message is larger than Orca accepts. + +## Stability and deprecation + +Plugins are in Alpha: the manifest v2 format and catalog format are stable contracts; items listed as experimental in [Plugin authoring](./plugin-authoring.md#stability) may change. + +- **Host API 1 is frozen.** Within `pluginApi` 1, methods, params and results only grow: a new method or a new optional param arrives with a higher `Since` minor, and nothing listed here is removed, renamed or narrowed. +- **Deprecation.** A method to be retired is marked deprecated in this table and keeps working for the rest of the major. +- **New majors.** A breaking change ships as a new `pluginApi` major beside the old one. Orca refuses a manifest whose `pluginApi` names a major it does not implement, with "update Orca", instead of loading it against the wrong API. +- **Detect, do not assume.** Check `orca.host.has(name)` or `orca.api.minor` before calling a method newer than the oldest Orca you support; a call that slips through fails with `unknown_method`. diff --git a/docs/reference/plugin-manifest.md b/docs/reference/plugin-manifest.md new file mode 100644 index 00000000000..ae05b65cbe9 --- /dev/null +++ b/docs/reference/plugin-manifest.md @@ -0,0 +1,219 @@ +# Orca plugin manifest reference + + + +The orca-plugin.json file at the root of an Orca plugin (manifest format 2). + +Add `"$schema": "https://raw.githubusercontent.com/stablyai/orca/main/docs/schemas/plugins/orca-plugin.v2.schema.json"` to `orca-plugin.json` for completion and inline errors in your editor. +`Since` is the first Orca release that reads the field; raise `engines.orca` when you use a newer one. + +## Fields + +| Field | Type | Required | Since | Description | +| --- | --- | --- | --- | --- | +| `$schema` | string | no | 1.4.245 | JSON Schema URL for editor completion. Ignored by Orca. | +| `manifestVersion` | `2` | yes | 1.4.245 | Manifest format. Use 2 for new plugins. | +| `execution` | `"data"` \| `"declarative"` \| `"sandboxed-ui"` \| `"trusted-worker"` | yes | 1.4.245 | Most authority the plugin needs, least first: `data` (themes, syntaxes, translations), `declarative` (commands, shortcuts, recipes), `sandboxed-ui` (panels), `trusted-worker` (`main` and events). Contributions beyond it fail validation. Example: `"data"` | +| `id` | string | yes | 1.4.160 | Plugin id, kebab-case. The install identity is `.`. The reserved names `constructor` and `prototype` are refused. Example: `"gleam-syntax"` | +| `publisher` | string | yes | 1.4.160 | Publisher slug, kebab-case. Must be the GitHub or GitLab user or organization that owns the repository (`acme` for `github.com/acme/gleam-syntax`), or the official and community catalogs reject the listing. The reserved names `constructor` and `prototype` are refused. The publishers `stablyai`, `official`, `anthropic`, `openai`, `github` and any starting `orca` (any case), and ids starting `orca-`, install only from Orca's official catalog. Example: `"acme"` | +| `name` | string | yes | 1.4.160 | Display name. Example: `"Gleam"` | +| `version` | string | yes | 1.4.160 | Plugin version, semver. Example: `"1.2.0"` | +| `description` | string | no | 1.4.160 | One or two sentences shown in the plugin list. | +| `author` | object | no | 1.4.160 | Who maintains the plugin. | +| `author.name` | string | yes | 1.4.160 | Author or organization name. Example: `"Acme"` | +| `author.url` | string | no | 1.4.160 | Author homepage. Example: `"https://example.com"` | +| `repository` | string | no | 1.4.160 | Source repository URL. Example: `"https://github.com/acme/orca-gleam-syntax"` | +| `license` | string | no | 1.4.245 | SPDX license expression. Example: `"MIT"` | +| `homepage` | string | no | 1.4.245 | Project homepage, https only. Example: `"https://example.com/gleam"` | +| `icon` | string | no | 1.4.160 | Square image shown beside the plugin name. PNG, WEBP, static, at least 128×128, square, at most 256 KB. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused. Example: `"assets/icon.png"` | +| `engines` | object | yes | 1.4.160 | Host versions this plugin supports. Older hosts refuse to load it. | +| `engines.orca` | string | yes | 1.4.160 | Oldest Orca release this plugin runs on, as `>=x.y.z`. Raise it to the newest "Since" of any field you use; a format 2 manifest needs at least `>=1.4.245`, because older Orca cannot read format 2. Example: `">=1.4.245"` | +| `main` | string | no | 1.4.160 | Node entry run in an out-of-process plugin worker. Requires the `trusted-worker` execution profile, and is required by it, by `contributes.events` and by any command without an `action`. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused. Example: `"dist/main.mjs"` | +| `contributes` | object | no | 1.4.160 | What the plugin adds to Orca. Every point defaults to empty. | +| `contributes.panels` | [PanelContribution](#panelcontribution)[] | no | 1.4.160 | Sidebar panels. At most 64. | +| `contributes.commands` | [CommandContribution](#commandcontribution)[] | no | 1.4.160 | Command palette entries. At most 256. | +| `contributes.events` | [EventContribution](#eventcontribution)[] | no | 1.4.160 | Events delivered to the plugin worker. At most 3. | +| `contributes.languagePacks` | [LanguagePackContribution](#languagepackcontribution)[] | no | 1.4.160 | Interface translations. At most 16. | +| `contributes.keybindings` | [KeybindingContribution](#keybindingcontribution)[] | no | 1.4.160 | Keyboard shortcuts for contributed commands. At most 256. | +| `contributes.vmRecipes` | [VmRecipeContribution](#vmrecipecontribution)[] | no | 1.4.160 | Virtual machine environment recipes. At most 64. | +| `contributes.themes` | [ThemeContribution](#themecontribution)[] | no | 1.4.245 | App color themes. At most 64. | +| `contributes.terminalThemes` | [ThemeContribution](#themecontribution)[] | no | 1.4.245 | Terminal ANSI palettes. At most 64. | +| `contributes.iconThemes` | [ThemeContribution](#themecontribution)[] | no | 1.4.245 | File icon themes. At most 32. | +| `contributes.syntaxes` | [SyntaxContribution](#syntaxcontribution)[] | no | 1.4.245 | Syntax highlighting for editor languages. At most 16. | +| `contributes.customEditors` | [CustomEditorContribution](#customeditorcontribution)[] | no | 1.4.245 | Editors that open files of given types in the editor tab. At most 16. | +| `contributes.newFiles` | [NewFileContribution](#newfilecontribution)[] | no | 1.4.245 | Untitled files the user can create from the + menu and the command palette. At most 16. | +| `contributes.agentHarnesses` | any[] | no | 1.4.245 | Reserved for coding-agent harness definitions; must be empty until their format is defined. | +| `contributes.skills` | any[] | no | 1.4.245 | Reserved for agent skills; must be empty until their format is defined. | +| `contributes.mcpServers` | any[] | no | 1.4.245 | Reserved for MCP server definitions; must be empty until their format is defined. | +| `contributes.hooks` | any[] | no | 1.4.245 | Reserved for agent hooks; must be empty until their format is defined. | +| `contributes.taskSources` | any[] | no | 1.4.245 | Reserved for task sources; must be empty until their format is defined. | +| `contributes.connections` | any[] | no | 1.4.245 | Reserved for account connections; must be empty until their format is defined. | +| `contributes.settings` | any[] | no | 1.4.245 | Reserved for plugin settings; must be empty until their format is defined. | +| `contributes.statusBarItems` | any[] | no | 1.4.245 | Reserved for status bar items; must be empty until their format is defined. | +| `contributes.sidebarCards` | any[] | no | 1.4.245 | Reserved for sidebar cards; must be empty until their format is defined. | +| `capabilities` | [Capability](#capability)[] | no | 1.4.160 | Host APIs the plugin asks the user to grant. At most 32. | +| `cover` | object | no | 1.4.245 | Wide image at the top of the plugin page. | +| `cover.path` | string | yes | 1.4.245 | Cover image shown on the plugin page, 16:10 recommended (1600×1000). PNG, JPEG, WEBP, static, at least 800×500, at most 1024 KB. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused. Example: `"images/cover.png"` | +| `cover.alt` | string | yes | 1.4.245 | What the image shows, for screen readers. Required. Example: `"The Dusk theme on a terminal"` | +| `screenshots` | [Screenshot](#screenshot)[] | no | 1.4.245 | Screenshots for the plugin page, at most 6. All images together may be at most 8 MB. | +| `pluginApi` | `1` | no | 1.4.160 | Major version of the plugin host API this plugin targets. Optional (defaults to 1), and refused on `data` and `declarative` plugins, which never call it. | + +## PanelContribution + +A sidebar panel. Requires the `sandboxed-ui` execution profile or higher. + +| Field | Type | Required | Since | Description | +| --- | --- | --- | --- | --- | +| `id` | string | yes | 1.4.160 | Panel id, unique among this plugin's panels. The reserved names `constructor` and `prototype` are refused. Example: `"dashboard"` | +| `title` | string | yes | 1.4.160 | Title shown on the panel tab and its tooltip. Example: `"Dashboard"` | +| `icon` | `"activity"` \| `"bar-chart-3"` \| `"bell"` \| `"blocks"` \| `"book"` \| `"bot"` \| `"bug"` \| `"calendar"` \| `"cloud"` \| `"code"` \| `"database"` \| `"file-text"` \| `"flag"` \| `"folder"` \| `"gauge"` \| `"globe"` \| `"hammer"` \| `"layers"` \| `"lightbulb"` \| `"package"` \| `"plug"` \| `"puzzle"` \| `"rocket"` \| `"star"` \| `"terminal"` \| `"wrench"` \| `"zap"` | no | 1.4.160 | Icon shown in the right-sidebar activity bar. Defaults to `plug`. Example: `"database"` | +| `entry` | string | yes | 1.4.160 | HTML file, relative to the plugin root, rendered inside a sandboxed panel frame. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused. Example: `"dist/panel.html"` | + +## CommandContribution + +A command palette entry. Requires the `declarative` execution profile or higher. + +| Field | Type | Required | Since | Description | +| --- | --- | --- | --- | --- | +| `id` | string | yes | 1.4.160 | Command id, unique among this plugin's commands. Dotted namespaces are allowed. Example: `"jupyter.restartKernel"` | +| `title` | string | yes | 1.4.160 | Title shown in the command palette. Example: `"Restart kernel"` | +| `when` | `"global"` \| `"workspace"` | no | 1.4.160 | Where the command is available. `workspace` hides it when no workspace is active. Defaults to `global`. | +| `action` | `"worktree.history.back"` \| `"worktree.history.forward"` \| `"sidebar.left.toggle"` \| `"sidebar.sleepingWorkspaces.toggle"` \| `"floatingWorkspace.maximize"` \| `"tab.rename"` \| `"workspace.rename"` \| `"workspace.openBoard"` \| `"view.tasks"` \| `"sidebar.right.toggle"` \| `"sidebar.explorer.toggle"` \| `"sidebar.search.toggle"` \| `"sidebar.sourceControl.toggle"` \| `"sidebar.checks.toggle"` \| `"sidebar.ports.toggle"` | no | 1.4.160 | Built-in Orca action this command runs. A command with an action is declarative and never starts the plugin worker; one without needs `main`. Example: `"view.tasks"` | + +## EventContribution + +An event subscription. Requires `main`, the `trusted-worker` execution profile and the capability of the event's domain: `workspace:read` for workspace events, `agent:read` for agent events. + +| Field | Type | Required | Since | Description | +| --- | --- | --- | --- | --- | +| `on` | `"workspace.created"` \| `"workspace.removed"` \| `"agent.status.changed"` | yes | 1.4.160 | Event delivered to the plugin worker. | + +## LanguagePackContribution + +An interface translation. + +| Field | Type | Required | Since | Description | +| --- | --- | --- | --- | --- | +| `locale` | string | yes | 1.4.160 | BCP 47 locale the pack translates Orca into. Each locale appears once, ignoring case. Example: `"pt-BR"` | +| `path` | string | yes | 1.4.160 | JSON file of translated interface strings. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused. Example: `"locales/pt-BR.json"` | +| `displayName` | string | no | 1.4.245 | Native-language name for the Settings picker. Omitted packs show `{locale} — {pluginKey}`. Example: `"Português (Brasil)"` | + +## KeybindingContribution + +A keyboard shortcut for a command. + +| Field | Type | Required | Since | Description | +| --- | --- | --- | --- | --- | +| `command` | string | yes | 1.4.160 | Id of a command this plugin contributes in `contributes.commands`. Example: `"jupyter.restartKernel"` | +| `key` | string | yes | 1.4.160 | Key chord Orca recognizes, such as `Mod+Shift+K`. `Mod` is Cmd on macOS and Ctrl elsewhere. Each chord may be bound once on every platform. Example: `"Mod+Shift+K"` | +| `when` | `"global"` \| `"workspace"` | no | 1.4.160 | Where the shortcut is active; must match its command's `when`. | + +## VmRecipeContribution + +A virtual machine environment recipe. + +| Field | Type | Required | Since | Description | +| --- | --- | --- | --- | --- | +| `path` | string | yes | 1.4.160 | JSON environment recipe file, listed once. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused. Example: `"recipes/ubuntu.json"` | + +## ThemeContribution + +A titled theme file. + +| Field | Type | Required | Since | Description | +| --- | --- | --- | --- | --- | +| `id` | string | yes | 1.4.245 | Theme id, unique within its family in this plugin. The reserved names `constructor` and `prototype` are refused. Example: `"dusk"` | +| `title` | string | yes | 1.4.245 | Name shown in the theme picker. Example: `"Dusk"` | +| `path` | string | yes | 1.4.245 | JSON theme file, at most 16 KiB. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused. Example: `"themes/dusk.json"` | + +## SyntaxContribution + +Syntax highlighting for one language. + +| Field | Type | Required | Since | Description | +| --- | --- | --- | --- | --- | +| `id` | string | yes | 1.4.245 | Language id, unique among this plugin's syntaxes. The reserved names `constructor` and `prototype` are refused. Example: `"gleam"` | +| `title` | string | yes | 1.4.245 | Language name shown in the editor. Example: `"Gleam"` | +| `scopeName` | string | yes | 1.4.245 | The grammar's root TextMate scope, unique among this plugin's syntaxes. Example: `"source.gleam"` | +| `path` | string | yes | 1.4.245 | TextMate grammar JSON file. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused. Example: `"syntaxes/gleam.tmLanguage.json"` | +| `configuration` | string | no | 1.4.245 | Language configuration JSON file: comments, brackets and auto-closing pairs. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused. Example: `"syntaxes/language-configuration.json"` | +| `extensions` | string[] | no | 1.4.245 | File-name suffixes, with the dot, this language opens, ignoring case. A longer suffix such as `.excalidraw.json` wins over a shorter one, and a file named only the suffix never matches. At most 32. Example: `[".gleam"]` | +| `filenames` | string[] | no | 1.4.245 | Exact file names this language opens, ignoring case; a name beats any extension. Names must be portable: no path separators, `.`, `..` or characters Windows forbids. At most 32. Example: `["gleam.toml"]` | + +## CustomEditorContribution + +An editor for the listed files, shown in the editor tab beside Source. It must list at least one extension or file name. Requires the `sandboxed-ui` execution profile or higher. + +| Field | Type | Required | Since | Description | +| --- | --- | --- | --- | --- | +| `id` | string | yes | 1.4.245 | Editor id, unique among this plugin's custom editors. The reserved names `constructor` and `prototype` are refused. Example: `"excalidraw"` | +| `title` | string | yes | 1.4.245 | Name shown in the editor's view toggle beside Source, and on the tab of a file it opens. Example: `"Excalidraw"` | +| `icon` | string | no | 1.4.245 | Square image shown on the tab of a file this editor opens. Defaults to the plugin icon. PNG, WEBP, static, at least 128×128, square, at most 256 KB. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused. Example: `"images/editor-icon.png"` | +| `extensions` | string[] | no | 1.4.245 | File-name suffixes, with the dot, this editor opens, ignoring case. A longer suffix such as `.excalidraw.json` wins over a shorter one, and a file named only the suffix never matches. At most 32. Example: `[".excalidraw",".excalidraw.json"]` | +| `filenames` | string[] | no | 1.4.245 | Exact file names this editor opens, ignoring case; a name beats any extension. Names must be portable: no path separators, `.`, `..` or characters Windows forbids. At most 32. Example: `[]` | +| `entry` | string | yes | 1.4.245 | HTML file rendered inside a sandboxed editor frame, like a panel. It receives the open document and posts edits back; it never gets a file path or file system. It is trusted code: install editors you trust. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused. Example: `"editor/index.html"` | +| `priority` | `"default"` \| `"option"` | no | 1.4.245 | `default` (the default) opens matching files in this editor; `option` only offers it in the view toggle, so files open in Source. Files Orca opens itself (such as `.json`, `.ts`, `.md`, `.csv`, `.yaml`, lockfiles and `.env` files) always behave as `option`. Example: `"default"` | +| `contentKind` | `"text"` \| `"bytes"` | no | 1.4.245 | `text` (the default) sends the file as text and accepts full-text edits. `bytes` sends the file's raw bytes, for binary formats such as spreadsheets, and accepts full-byte edits. Example: `"bytes"` | + +## NewFileContribution + +A kind of untitled file the user can create from the tab bar + menu or the command palette, in the active workspace. + +| Field | Type | Required | Since | Description | +| --- | --- | --- | --- | --- | +| `id` | string | yes | 1.4.245 | New-file id, unique among this plugin's new files. The reserved names `constructor` and `prototype` are refused. Example: `"drawing"` | +| `title` | string | yes | 1.4.245 | Menu item shown in the tab bar + menu and the command palette. Example: `"New Excalidraw drawing"` | +| `extension` | string | yes | 1.4.245 | Suffix, with the dot, of the untitled file it creates. The file opens in whatever editor takes that suffix, such as a custom editor from this plugin. Example: `".excalidraw"` | +| `template` | string | no | 1.4.245 | UTF-8 file copied into the new file, at most 256 KB. Without one the file starts empty. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused. Example: `"templates/empty.excalidraw"` | +| `icon` | string | no | 1.4.245 | Square image shown beside the menu item. Defaults to the plugin icon. PNG, WEBP, static, at least 128×128, square, at most 256 KB. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused. Example: `"images/editor-icon.png"` | + +## Capability + +A host API the plugin may call once the user consents. + +| Field | Type | Required | Since | Description | +| --- | --- | --- | --- | --- | +| `kind` | `"workspace:read"` \| `"agent:read"` \| `"terminal:send"` \| `"notifications:show"` \| `"storage:own"` \| `"secrets:own"` \| `"settings:own"` | yes | 1.4.160 | Capability granted on user consent. | + +## Screenshot + +One screenshot of the plugin in use. + +| Field | Type | Required | Since | Description | +| --- | --- | --- | --- | --- | +| `path` | string | yes | 1.4.245 | Screenshot, 16:10 recommended (up to 2560×1600). PNG, JPEG, WEBP, static, at least 1280×800, at most 1536 KB. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused. Example: `"images/editor.png"` | +| `alt` | string | yes | 1.4.245 | What the image shows, for screen readers. Required. Example: `"The Dusk theme on a terminal"` | +| `caption` | string | no | 1.4.245 | Short caption under the screenshot. Example: `"Gleam in the editor"` | +| `theme` | `"light"` \| `"dark"` | no | 1.4.245 | Show this screenshot only with a light or dark Orca theme. | + +## Versions and compatibility + +- **What runs is strict.** Orca refuses a manifest with any key it does not know, at every level, so an older Orca never silently ignores something a plugin relies on. +- **`engines.orca` is the only version gate.** It must be at least the newest `Since` of every field and every value (capability, event, built-in action) the manifest uses. Orca checks this when it validates, lists or installs a plugin, so an older Orca can always say "requires Orca >=X (this is Y)" instead of listing unknown fields. +- **`pluginApi` is optional.** It names the major version of the host API ([reference](plugin-host-api.md)), which only grows within major 1; Orca refuses a major it does not implement. Only `sandboxed-ui` and `trusted-worker` plugins call that API, so `data` and `declarative` plugins must leave it out. +- **`terminal:send` needs `trusted-worker`.** Panels get no host method that types into a terminal, so Orca refuses the capability on any other profile instead of asking the user to grant something the plugin can never use. +- **New fields, contribution points and values are additive.** They arrive with a `Since` version; nothing else changes. `manifestVersion` rises only if an existing field changes meaning. +- **Deprecation.** A field to be removed is marked deprecated here and keeps working for at least one release; it is removed only together with a higher `engines.orca` requirement, and its name is never reused. +- **Reserved contribution points.** `contributes.agentHarnesses`, `skills`, `mcpServers`, `hooks`, `taskSources`, `connections`, `settings`, `statusBarItems` and `sidebarCards` are held for planned features and must stay empty until their format is defined. Each will arrive as an additive point with its own `Since`, without a new manifest format. `skills`, `mcpServers` and `hooks` keep the meaning agent-plugin formats such as Claude Code's give them; `agents` stays unclaimed because those formats use it for subagent files. + +## Planned contribution points + +Each reserved point is planned as declared data that Orca renders and enforces, never arbitrary plugin UI. The intended shapes, which may still change: + +- `taskSources`: Issue and task providers such as Asana, Linear or Jira, declared as data that a worker fills with tasks. +- `connections`: Account providers with a host-run sign-in, so a plugin never handles credentials itself. +- `agentHarnesses`: New coding-agent harnesses described as data: how to launch them and read their status. +- `statusBarItems`: Declarative status bar widgets (label, icon, tooltip) whose values a worker updates. +- `sidebarCards`: Declarative left-sidebar cards with fixed layouts whose values a worker updates. +- `mcpServers`: MCP server definitions that Orca offers to the agents it launches. +- `skills`: Agent skills shipped as files that Orca installs for the agents it launches. +- `hooks`: Agent lifecycle hooks, such as before and after a tool call, for the agents Orca launches. +- `settings`: Typed plugin settings that Orca renders in its own settings UI. + +## Legacy format 1 + +Orca still loads `"manifestVersion": 1` manifests (read since 1.4.160), but new plugins should use format 2. Format 1 differs in three ways: + +- It has no `execution` field. Orca derives the least profile that can run what the manifest declares. +- Unknown top-level keys are ignored instead of rejected. +- The published JSON Schema does not describe it, so editors offer no completion for it. diff --git a/docs/reference/plugin-syntax.md b/docs/reference/plugin-syntax.md new file mode 100644 index 00000000000..f4878c3da81 --- /dev/null +++ b/docs/reference/plugin-syntax.md @@ -0,0 +1,42 @@ +# Plugin syntax highlighting + +A plugin adds highlighting for a language through `contributes.syntaxes` in `orca-plugin.json`: + +```json +{ + "id": "typst", + "label": "Typst", + "scopeName": "source.typst", + "path": "syntaxes/typst.tmLanguage.json", + "configuration": "syntaxes/language-configuration.json", + "extensions": [".typ"], + "filenames": [] +} +``` + +- `path` is a TextMate grammar in JSON (at most 1 MiB) whose `scopeName` matches the entry. + Keys the TextMate engine does not use are dropped. +- `include` may name `#repository` entries, `$self`, `$base`, or another `syntaxes` scope in the + same plugin; those must resolve when the plugin is approved, or its card shows the error. An + include of a scope outside the plugin (for example an embedded `source.js`) is allowed but + resolves to nothing, so that region stays plain. +- `configuration` (optional, at most 64 KiB) sets comment markers, brackets, auto-closing and + surrounding pairs. +- A plugin can contribute up to 16 syntaxes. Scope names must be unique within the plugin. + +## How Orca uses it + +- A plugin syntax applies only to files Orca would otherwise show as plain text. An exact file + name wins over an extension, and a longer extension wins over a shorter one. There is no + per-file override yet, so a plugin that claims a broad extension such as `.txt` highlights every + such file while it is enabled. +- Grammars load when a matching file opens, compile once in a worker, and stay warm while you + type. Each line has the same limits as Orca's own grammars (1,000 characters, 50 ms); from a + line that exceeds them, the rest of the file stays plain. +- Highlighting is applied as Monaco semantic tokens, so while a new pass runs the previous colors + stay in place, shifted by your edits. +- A worker that stops reporting progress for 2 seconds or crashes is terminated, the language stays + plain, and the plugin card says highlighting stopped. +- Files over 500,000 characters or 20,000 lines stay plain. +- Highlighting follows the plugin live: enabling, updating or disabling it re-highlights open + files without a reload. diff --git a/docs/reference/plugin-theme-tokens.md b/docs/reference/plugin-theme-tokens.md new file mode 100644 index 00000000000..6c0879b2dcc --- /dev/null +++ b/docs/reference/plugin-theme-tokens.md @@ -0,0 +1,45 @@ +# Orca app theme tokens + + + +Every key an app theme may set in `tokens` (see [plugin themes](plugin-themes.md)). Any other key is refused. An omitted token falls back as listed; when that source is omitted too, it falls back the same way in turn. + +| Token | Required | When omitted | +| --- | --- | --- | +| `--background` | yes | | +| `--editor-surface` | no | same as `--background` | +| `--foreground` | yes | | +| `--card` | no | same as `--background` | +| `--card-foreground` | no | same as `--foreground` | +| `--popover` | no | same as `--card` | +| `--popover-foreground` | no | same as `--card-foreground` | +| `--primary` | no | same as `--foreground` | +| `--primary-foreground` | no | same as `--background` | +| `--secondary` | no | same as `--muted` | +| `--secondary-foreground` | no | same as `--foreground` | +| `--muted` | no | same as `--secondary` | +| `--muted-foreground` | no | `--foreground` mixed 70% into `--background` | +| `--accent` | no | `--foreground` mixed 12% into `--background` | +| `--accent-foreground` | no | same as `--foreground` | +| `--border` | no | `--foreground` mixed 16% into `--background` | +| `--input` | no | same as `--border` | +| `--ring` | no | same as `--primary` | +| `--chart-1` | no | same as `--primary` | +| `--chart-2` | no | Orca's built-in color | +| `--chart-3` | no | Orca's built-in color | +| `--chart-4` | no | Orca's built-in color | +| `--chart-5` | no | Orca's built-in color | +| `--sidebar` | no | `--foreground` mixed 8% into `--background` | +| `--sidebar-foreground` | no | same as `--foreground` | +| `--sidebar-primary` | no | same as `--primary` | +| `--sidebar-primary-foreground` | no | same as `--primary-foreground` | +| `--sidebar-accent` | no | same as `--accent` | +| `--sidebar-accent-foreground` | no | same as `--accent-foreground` | +| `--sidebar-border` | no | same as `--border` | +| `--sidebar-ring` | no | same as `--ring` | +| `--worktree-sidebar` | no | same as `--sidebar` | +| `--worktree-sidebar-foreground` | no | same as `--sidebar-foreground` | +| `--worktree-sidebar-accent` | no | same as `--sidebar-accent` | +| `--worktree-sidebar-accent-foreground` | no | same as `--sidebar-accent-foreground` | +| `--worktree-sidebar-border` | no | same as `--sidebar-border` | +| `--worktree-sidebar-ring` | no | same as `--sidebar-ring` | diff --git a/docs/reference/plugin-themes.md b/docs/reference/plugin-themes.md index 321259c8951..ac8d120bedc 100644 --- a/docs/reference/plugin-themes.md +++ b/docs/reference/plugin-themes.md @@ -7,16 +7,24 @@ Each entry is `{ "id", "label", "path" }` pointing at a JSON file of at most 16 ## App themes ```json -{ "base": "dark", "tokens": { "--background": "#1b1430", "--foreground": "#e8e0ff" } } +{ + "schemaVersion": 1, + "base": "dark", + "tokens": { "--background": "#1b1430", "--foreground": "#e8e0ff" } +} ``` -- `base` (`light` or `dark`) becomes the app's light/dark mode while the theme is selected. +- `schemaVersion` is optional for app themes and must be `1` when present. +- `base` (`light` or `dark`) picks the slot the theme fills: users choose a Light theme and a + Dark theme separately, and Orca paints the one for its current mode (following the OS under + System). Contribute one theme of each base to ship a pair; installing it fills both slots. - `--background` and `--foreground` are required. Every other public token is optional; an - omitted token is derived from the ones you set (cards and sidebars from `--background`, text - from `--foreground`, sidebar accents from `--accent`, rings from `--primary`, and so on), so a - two-token theme still colors the whole app. -- The public tokens are `PLUGIN_APP_THEME_TOKENS` in - `src/shared/plugins/plugin-theme-artifact.ts`. Other variables cannot be set. + omitted token is derived from the ones you set (cards from `--background`, sidebars lifted a + step off `--background` so the left sidebar keeps a visible edge, text from `--foreground`, + sidebar accents from `--accent`, rings from `--primary`, and so on), so a two-token theme still + colors the whole app. +- [Theme tokens](plugin-theme-tokens.md) lists every public token and what it falls back to + when omitted. Other variables cannot be set. - Values must be colors: hex, a color function (`rgb`, `hsl`, `oklch`, …) or a color name. `transparent`, `currentColor`, CSS-wide keywords, lengths, `calc()`, `var()` and `url()` are refused. @@ -24,6 +32,42 @@ Each entry is `{ "id", "label", "path" }` pointing at a JSON file of at most 16 `--primary`/`--primary-foreground`) must be opaque with at least 4.5:1 contrast; a pair that fails is dropped and keeps Orca's built-in colors. - Trust and approval dialogs always use Orca's own colors, whatever theme is active. +- `pnpm validate:plugin` refuses unknown keys, tokens and slots in every theme file. Orca itself + skips them, so a theme that sets a token added in a newer release still loads on older ones. + +### Code colors + +An app theme also colors the code editor: its background, text, line numbers, cursor and +selection follow the theme's tokens. To color code itself, add `tokenColors` (Orca 1.4.245 and +later) in the same shape as a VS Code theme's, so most VS Code theme lists paste in unchanged (`validate:plugin` flags entries without a `scope` and any setting other than these two): + +```json +{ + "base": "dark", + "tokens": { "--background": "#1b1430", "--foreground": "#e8e0ff" }, + "tokenColors": [ + { "scope": "comment", "settings": { "foreground": "#8f84ad", "fontStyle": "italic" } }, + { "scope": ["keyword", "storage"], "settings": { "foreground": "#ff7ad9" } }, + { "scope": "string", "settings": { "foreground": "#a8e6a1" } }, + { "scope": "constant.numeric", "settings": { "foreground": "#ffb86c" } }, + { "scope": "entity.name.function", "settings": { "foreground": "#82cfff" } }, + { "scope": "markup.heading", "settings": { "foreground": "#ff7ad9", "fontStyle": "bold" } } + ] +} +``` + +- `scope` is a TextMate scope or a list of them (a comma-separated string works too). Scopes + follow the [TextMate naming convention](https://macromates.com/manual/en/language_grammars#naming_conventions): + `comment`, `string`, `constant.numeric`, `keyword.control`, `keyword.operator`, + `entity.name.function`, `entity.name.type`, `variable`, `punctuation`, `markup.heading`, + `markup.underline.link`, `invalid`, and so on. A scope also colors everything beneath it + (`keyword` covers `keyword.control.flow`); the longest match wins, then the later rule. +- `settings.foreground` is a hex color; `settings.fontStyle` is `italic`, `bold`, `underline`, + `strikethrough`, a space-separated combination, or `""` for none. `name` is allowed and ignored. +- Orca applies them to its built-in languages and to plugin syntaxes alike. Selectors with + spaces (descendant scopes) and `semanticTokenColors` are not read. +- A color under 3:1 contrast with the editor background is dropped, and anything a theme leaves + out keeps the built-in light or dark code colors. ## Terminal themes diff --git a/docs/schemas/plugins/orca-catalog.v2.schema.json b/docs/schemas/plugins/orca-catalog.v2.schema.json new file mode 100644 index 00000000000..3b3bf00c10e --- /dev/null +++ b/docs/schemas/plugins/orca-catalog.v2.schema.json @@ -0,0 +1,579 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/stablyai/orca/main/docs/schemas/plugins/orca-catalog.v2.schema.json", + "title": "Orca plugin catalog", + "description": "The orca-catalog.json file that lists installable plugins and their releases.", + "type": "object", + "properties": { + "$schema": { + "description": "JSON Schema URL for editor completion. Ignored by Orca.", + "x-orca-since": "1.4.245", + "type": "string", + "maxLength": 2048 + }, + "catalogVersion": { + "type": "number", + "const": 2, + "description": "Catalog format. Raised only for a change older Orca cannot ignore; a newer Orca reads this one.", + "x-orca-since": "1.4.245" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Catalog display name.", + "x-orca-since": "1.4.245", + "examples": [ + "Orca Plugins" + ] + }, + "owner": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$", + "description": "Account that publishes the catalog.", + "x-orca-since": "1.4.245", + "examples": [ + "stablyai" + ] + }, + "plugins": { + "maxItems": 2048, + "type": "array", + "items": { + "$ref": "#/$defs/CatalogEntry" + }, + "description": "Listed plugins, at most 2048. Each `id` should appear once; Orca keeps the first. An entry this Orca cannot read is skipped, never fatal.", + "x-orca-since": "1.4.245" + } + }, + "required": [ + "catalogVersion", + "name", + "owner", + "plugins" + ], + "$defs": { + "CatalogEntry": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*\\.[a-z0-9]+(?:-[a-z0-9]+)*$", + "description": "`.`, matching the plugin's manifest. Each half is at most 64 characters, and the reserved names `constructor` and `prototype` are refused.", + "x-orca-since": "1.4.245", + "examples": [ + "acme.gleam-syntax" + ] + }, + "source": { + "$ref": "#/$defs/CatalogGitSource" + }, + "description": { + "description": "Listing text shown in the catalog.", + "x-orca-since": "1.4.245", + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "categories": { + "description": "Lowercase slugs used to filter the catalog, each listed once. At most 16; extras are ignored.", + "x-orca-since": "1.4.245", + "uniqueItems": true, + "maxItems": 16, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + } + }, + "releases": { + "description": "Installable releases, newest first. Orca installs the first one whose `minimumOrca` it meets, so keep older releases listed when you raise `engines.orca`. At most 32.", + "x-orca-since": "1.4.245", + "minItems": 1, + "maxItems": 32, + "type": "array", + "items": { + "$ref": "#/$defs/CatalogRelease" + } + } + }, + "required": [ + "id", + "source", + "releases" + ], + "description": "One installable plugin." + }, + "CatalogGitSource": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "git", + "description": "Source kind. Only `git` is supported.", + "x-orca-since": "1.4.245" + }, + "url": { + "type": "string", + "minLength": 1, + "maxLength": 32768, + "description": "HTTPS or SSH URL of the plugin repository.", + "x-orca-since": "1.4.245", + "examples": [ + "https://github.com/acme/orca-gleam-syntax.git" + ] + }, + "ref": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Branch or tag the author publishes from. Each release pins an exact commit.", + "x-orca-since": "1.4.245", + "examples": [ + "main" + ] + }, + "path": { + "description": "Directory inside the repository that holds `orca-plugin.json`, so one repository can publish many plugins. Use `/` separators, with no leading `/`, `.` or `..`. Omit when the plugin is the repository root.", + "x-orca-since": "1.4.245", + "examples": [ + "plugins/gleam-syntax" + ], + "type": "string", + "maxLength": 1024, + "pattern": "^[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ](?:\\/[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ])*$" + } + }, + "required": [ + "kind", + "url", + "ref" + ], + "description": "Where the plugin source lives." + }, + "CatalogRelease": { + "type": "object", + "properties": { + "version": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "The manifest's `version` for this release.", + "x-orca-since": "1.4.245", + "examples": [ + "1.2.0" + ] + }, + "commit": { + "type": "string", + "pattern": "^[0-9a-f]{40}$", + "description": "Full SHA-1 commit Orca checks out for this release.", + "x-orca-since": "1.4.245" + }, + "contentHash": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "description": "SHA-256 of the plugin's installed files, as Orca computes it at install.", + "x-orca-since": "1.4.245" + }, + "execution": { + "type": "string", + "enum": [ + "data", + "declarative", + "sandboxed-ui", + "trusted-worker" + ], + "description": "The manifest's `execution` profile.", + "x-orca-since": "1.4.245", + "examples": [ + "data" + ] + }, + "minimumOrca": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+$", + "description": "The manifest's `engines.orca` without `>=`. Older Orca lists the plugin but will not install it.", + "x-orca-since": "1.4.245", + "examples": [ + "1.4.245" + ] + }, + "types": { + "description": "Optional; Orca derives it from `contributes` and `execution`. When present, every kind of contribution the release carries, matching its manifest exactly. `instructions` needs the `declarative` profile or higher, `panel` and `editor` need `sandboxed-ui`, and `worker` needs `trusted-worker`.", + "x-orca-since": "1.4.245", + "examples": [ + [ + "syntax" + ] + ], + "uniqueItems": true, + "minItems": 1, + "maxItems": 9, + "type": "array", + "items": { + "type": "string", + "enum": [ + "interface-language", + "app-theme", + "terminal-theme", + "icon-theme", + "syntax", + "instructions", + "panel", + "editor", + "worker" + ] + } + }, + "name": { + "description": "The manifest's `name`, so Orca can suggest the plugin before downloading it.", + "x-orca-since": "1.4.245", + "examples": [ + "Gleam" + ], + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "license": { + "description": "SPDX license expression.", + "x-orca-since": "1.4.245", + "examples": [ + "MIT" + ], + "type": "string", + "maxLength": 128, + "pattern": "^\\(?[A-Za-z0-9][A-Za-z0-9.+-]*(?: (?:AND|OR|WITH) \\(?[A-Za-z0-9][A-Za-z0-9.+-]*\\)?)*\\)?$" + }, + "repository": { + "description": "Source repository page, https only.", + "x-orca-since": "1.4.245", + "examples": [ + "https://github.com/acme/orca-gleam-syntax" + ], + "type": "string", + "maxLength": 2048, + "pattern": "^https:\\/\\/\\S+$" + }, + "homepage": { + "description": "Project homepage, https only.", + "x-orca-since": "1.4.245", + "examples": [ + "https://example.com/gleam" + ], + "type": "string", + "maxLength": 2048, + "pattern": "^https:\\/\\/\\S+$" + }, + "updatedAt": { + "description": "When this release was published, as an ISO 8601 date or date-time.", + "x-orca-since": "1.4.245", + "examples": [ + "2026-10-20" + ], + "anyOf": [ + { + "type": "string", + "format": "date", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$" + }, + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + } + ] + }, + "changelog": { + "description": "What changed in this release, as plain text. At most 2000 characters.", + "x-orca-since": "1.4.245", + "examples": [ + "Adds highlighting for use expressions." + ], + "type": "string", + "minLength": 1, + "maxLength": 2000 + }, + "images": { + "description": "Icon, cover and screenshots for the listing, shown before anything is downloaded.", + "x-orca-since": "1.4.245", + "$ref": "#/$defs/CatalogImages" + }, + "themePreview": { + "description": "Up to 4 theme palettes for listing art. Display only; the installed theme files decide the real colors.", + "x-orca-since": "1.4.245", + "maxItems": 4, + "type": "array", + "items": { + "$ref": "#/$defs/CatalogThemePreview" + } + }, + "contributes": { + "description": "What the release adds, as the listing shows it. Generate it with the catalog tooling; Orca refuses an install whose manifest says otherwise.", + "x-orca-since": "1.4.245", + "maxItems": 64, + "type": "array", + "items": { + "$ref": "#/$defs/CatalogContribution" + } + }, + "capabilities": { + "description": "Every capability the release asks for. Orca refuses an install whose manifest asks for different ones.", + "x-orca-since": "1.4.245", + "examples": [ + [ + "notifications:show" + ] + ], + "maxItems": 7, + "type": "array", + "items": { + "type": "string", + "enum": [ + "workspace:read", + "agent:read", + "terminal:send", + "notifications:show", + "storage:own", + "secrets:own", + "settings:own" + ] + } + }, + "fileTypes": { + "description": "Extensions, without the dot, that this release opens. Orca suggests the plugin when such a file has no viewer.", + "x-orca-since": "1.4.245", + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9+_-]{0,15}$" + } + }, + "editorFileTypes": { + "description": "The `fileTypes` this release opens in a custom editor. Orca suggests the plugin for such a file even when it already opens it as text.", + "x-orca-since": "1.4.245", + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9+_-]{0,15}$" + } + } + }, + "required": [ + "version", + "commit", + "contentHash", + "execution", + "minimumOrca" + ], + "description": "The exact release the catalog vouches for. Orca refuses bytes that differ." + }, + "CatalogImages": { + "type": "object", + "properties": { + "icon": { + "$ref": "#/$defs/CatalogImage" + }, + "cover": { + "$ref": "#/$defs/CatalogImage" + }, + "screenshots": { + "description": "Screenshots, at most 6.", + "x-orca-since": "1.4.245", + "maxItems": 6, + "type": "array", + "items": { + "$ref": "#/$defs/CatalogImage" + } + } + }, + "additionalProperties": false, + "description": "Images extracted from the release by catalog CI, which re-encodes them and strips metadata. Same rules as the manifest: icon PNG, WEBP, static, at least 128×128, square, at most 256 KB. Cover PNG, JPEG, WEBP, static, at least 800×500, at most 1024 KB. Screenshots PNG, JPEG, WEBP, static, at least 1280×800, at most 1536 KB. Images must not imitate Orca's trust badges." + }, + "CatalogImage": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ](?:[\\\\/][^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ])*$", + "description": "File in the catalog repository, relative to its root. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused.", + "x-orca-since": "1.4.245", + "examples": [ + "assets/acme.gleam/cover.png" + ] + }, + "alt": { + "type": "string", + "minLength": 1, + "maxLength": 160, + "description": "What the image shows, for screen readers. Required.", + "x-orca-since": "1.4.245", + "examples": [ + "The Dusk theme on a terminal" + ] + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "description": "SHA-256 of the file's bytes; Orca refuses bytes that differ.", + "x-orca-since": "1.4.245" + }, + "width": { + "type": "integer", + "minimum": 1, + "maximum": 4096, + "description": "Width in pixels.", + "x-orca-since": "1.4.245", + "examples": [ + 1600 + ] + }, + "height": { + "type": "integer", + "minimum": 1, + "maximum": 4096, + "description": "Height in pixels.", + "x-orca-since": "1.4.245", + "examples": [ + 1000 + ] + } + }, + "required": [ + "path", + "alt", + "sha256", + "width", + "height" + ], + "additionalProperties": false, + "description": "An image stored in the catalog repository." + }, + "CatalogThemePreview": { + "type": "object", + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Theme name.", + "x-orca-since": "1.4.245", + "examples": [ + "Dusk" + ] + }, + "base": { + "type": "string", + "enum": [ + "light", + "dark" + ], + "description": "Light or dark theme.", + "x-orca-since": "1.4.245" + }, + "background": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$", + "description": "Window background.", + "x-orca-since": "1.4.245", + "examples": [ + "#1b1430" + ] + }, + "foreground": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$", + "description": "Main text.", + "x-orca-since": "1.4.245", + "examples": [ + "#1b1430" + ] + }, + "accent": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$", + "description": "Accent, such as buttons.", + "x-orca-since": "1.4.245", + "examples": [ + "#1b1430" + ] + }, + "surface": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$", + "description": "Cards and sidebars.", + "x-orca-since": "1.4.245", + "examples": [ + "#1b1430" + ] + } + }, + "required": [ + "label", + "base", + "background", + "foreground", + "accent", + "surface" + ], + "additionalProperties": false, + "description": "Colors to draw a theme card before install." + }, + "CatalogContribution": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "panels", + "commands", + "events", + "languagePacks", + "keybindings", + "vmRecipes", + "themes", + "terminalThemes", + "iconThemes", + "syntaxes", + "customEditors", + "newFiles" + ], + "description": "Contribution point.", + "x-orca-since": "1.4.245", + "examples": [ + "themes" + ] + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 160, + "description": "What the item is called in Orca.", + "x-orca-since": "1.4.245", + "examples": [ + "Dusk" + ] + }, + "detail": { + "description": "Extra context, such as file extensions.", + "x-orca-since": "1.4.245", + "type": "string", + "minLength": 1, + "maxLength": 160 + } + }, + "required": [ + "kind", + "name" + ], + "description": "One thing the release adds to Orca." + } + } +} diff --git a/docs/schemas/plugins/orca-plugin.v2.schema.json b/docs/schemas/plugins/orca-plugin.v2.schema.json new file mode 100644 index 00000000000..01e3392d8ef --- /dev/null +++ b/docs/schemas/plugins/orca-plugin.v2.schema.json @@ -0,0 +1,1149 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/stablyai/orca/main/docs/schemas/plugins/orca-plugin.v2.schema.json", + "title": "Orca plugin manifest", + "description": "The orca-plugin.json file at the root of an Orca plugin (manifest format 2).", + "type": "object", + "properties": { + "$schema": { + "description": "JSON Schema URL for editor completion. Ignored by Orca.", + "x-orca-since": "1.4.245", + "type": "string", + "maxLength": 2048 + }, + "manifestVersion": { + "type": "number", + "const": 2, + "description": "Manifest format. Use 2 for new plugins.", + "x-orca-since": "1.4.245" + }, + "execution": { + "type": "string", + "enum": [ + "data", + "declarative", + "sandboxed-ui", + "trusted-worker" + ], + "description": "Most authority the plugin needs, least first: `data` (themes, syntaxes, translations), `declarative` (commands, shortcuts, recipes), `sandboxed-ui` (panels), `trusted-worker` (`main` and events). Contributions beyond it fail validation.", + "x-orca-since": "1.4.245", + "examples": [ + "data" + ] + }, + "id": { + "type": "string", + "maxLength": 64, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "description": "Plugin id, kebab-case. The install identity is `.`. The reserved names `constructor` and `prototype` are refused.", + "x-orca-since": "1.4.160", + "examples": [ + "gleam-syntax" + ] + }, + "publisher": { + "type": "string", + "maxLength": 64, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "description": "Publisher slug, kebab-case. Must be the GitHub or GitLab user or organization that owns the repository (`acme` for `github.com/acme/gleam-syntax`), or the official and community catalogs reject the listing. The reserved names `constructor` and `prototype` are refused. The publishers `stablyai`, `official`, `anthropic`, `openai`, `github` and any starting `orca` (any case), and ids starting `orca-`, install only from Orca's official catalog.", + "x-orca-since": "1.4.160", + "examples": [ + "acme" + ] + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Display name.", + "x-orca-since": "1.4.160", + "examples": [ + "Gleam" + ] + }, + "version": { + "type": "string", + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$", + "description": "Plugin version, semver.", + "x-orca-since": "1.4.160", + "examples": [ + "1.2.0" + ] + }, + "description": { + "description": "One or two sentences shown in the plugin list.", + "x-orca-since": "1.4.160", + "type": "string", + "maxLength": 4096 + }, + "author": { + "description": "Who maintains the plugin.", + "x-orca-since": "1.4.160", + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Author or organization name.", + "x-orca-since": "1.4.160", + "examples": [ + "Acme" + ] + }, + "url": { + "description": "Author homepage.", + "x-orca-since": "1.4.160", + "examples": [ + "https://example.com" + ], + "type": "string", + "maxLength": 2048 + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + "repository": { + "description": "Source repository URL.", + "x-orca-since": "1.4.160", + "examples": [ + "https://github.com/acme/orca-gleam-syntax" + ], + "type": "string", + "maxLength": 2048 + }, + "license": { + "description": "SPDX license expression.", + "x-orca-since": "1.4.245", + "examples": [ + "MIT", + "Apache-2.0 OR MIT" + ], + "type": "string", + "maxLength": 128, + "pattern": "^\\(?[A-Za-z0-9][A-Za-z0-9.+-]*(?: (?:AND|OR|WITH) \\(?[A-Za-z0-9][A-Za-z0-9.+-]*\\)?)*\\)?$" + }, + "homepage": { + "description": "Project homepage, https only.", + "x-orca-since": "1.4.245", + "examples": [ + "https://example.com/gleam" + ], + "type": "string", + "maxLength": 2048, + "pattern": "^https:\\/\\/\\S+$" + }, + "icon": { + "description": "Square image shown beside the plugin name. PNG, WEBP, static, at least 128×128, square, at most 256 KB. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused.", + "x-orca-since": "1.4.160", + "examples": [ + "assets/icon.png" + ], + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ](?:[\\\\/][^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ])*$" + }, + "engines": { + "type": "object", + "properties": { + "orca": { + "type": "string", + "maxLength": 64, + "pattern": "^>=\\d+\\.\\d+\\.\\d+$", + "description": "Oldest Orca release this plugin runs on, as `>=x.y.z`. Raise it to the newest \"Since\" of any field you use; a format 2 manifest needs at least `>=1.4.245`, because older Orca cannot read format 2.", + "x-orca-since": "1.4.160", + "examples": [ + ">=1.4.245" + ] + } + }, + "required": [ + "orca" + ], + "additionalProperties": false, + "description": "Host versions this plugin supports. Older hosts refuse to load it.", + "x-orca-since": "1.4.160" + }, + "main": { + "description": "Node entry run in an out-of-process plugin worker. Requires the `trusted-worker` execution profile, and is required by it, by `contributes.events` and by any command without an `action`. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused.", + "x-orca-since": "1.4.160", + "examples": [ + "dist/main.mjs" + ], + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ](?:[\\\\/][^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ])*$" + }, + "contributes": { + "description": "What the plugin adds to Orca. Every point defaults to empty.", + "x-orca-since": "1.4.160", + "default": {}, + "type": "object", + "properties": { + "panels": { + "default": [], + "description": "Sidebar panels. At most 64.", + "x-orca-since": "1.4.160", + "maxItems": 64, + "type": "array", + "items": { + "$ref": "#/$defs/PanelContribution" + } + }, + "commands": { + "default": [], + "description": "Command palette entries. At most 256.", + "x-orca-since": "1.4.160", + "maxItems": 256, + "type": "array", + "items": { + "$ref": "#/$defs/CommandContribution" + } + }, + "events": { + "default": [], + "description": "Events delivered to the plugin worker. At most 3.", + "x-orca-since": "1.4.160", + "maxItems": 3, + "type": "array", + "items": { + "$ref": "#/$defs/EventContribution" + } + }, + "languagePacks": { + "default": [], + "description": "Interface translations. At most 16.", + "x-orca-since": "1.4.160", + "maxItems": 16, + "type": "array", + "items": { + "$ref": "#/$defs/LanguagePackContribution" + } + }, + "keybindings": { + "description": "Keyboard shortcuts for contributed commands. At most 256.", + "x-orca-since": "1.4.160", + "maxItems": 256, + "type": "array", + "items": { + "$ref": "#/$defs/KeybindingContribution" + } + }, + "vmRecipes": { + "default": [], + "description": "Virtual machine environment recipes. At most 64.", + "x-orca-since": "1.4.160", + "maxItems": 64, + "type": "array", + "items": { + "$ref": "#/$defs/VmRecipeContribution" + } + }, + "themes": { + "default": [], + "description": "App color themes. At most 64.", + "x-orca-since": "1.4.245", + "maxItems": 64, + "type": "array", + "items": { + "$ref": "#/$defs/ThemeContribution" + } + }, + "terminalThemes": { + "default": [], + "description": "Terminal ANSI palettes. At most 64.", + "x-orca-since": "1.4.245", + "maxItems": 64, + "type": "array", + "items": { + "$ref": "#/$defs/ThemeContribution" + } + }, + "iconThemes": { + "default": [], + "description": "File icon themes. At most 32.", + "x-orca-since": "1.4.245", + "maxItems": 32, + "type": "array", + "items": { + "$ref": "#/$defs/ThemeContribution" + } + }, + "syntaxes": { + "default": [], + "description": "Syntax highlighting for editor languages. At most 16.", + "x-orca-since": "1.4.245", + "maxItems": 16, + "type": "array", + "items": { + "$ref": "#/$defs/SyntaxContribution" + } + }, + "customEditors": { + "default": [], + "description": "Editors that open files of given types in the editor tab. At most 16.", + "x-orca-since": "1.4.245", + "maxItems": 16, + "type": "array", + "items": { + "$ref": "#/$defs/CustomEditorContribution" + } + }, + "newFiles": { + "default": [], + "description": "Untitled files the user can create from the + menu and the command palette. At most 16.", + "x-orca-since": "1.4.245", + "maxItems": 16, + "type": "array", + "items": { + "$ref": "#/$defs/NewFileContribution" + } + }, + "agentHarnesses": { + "description": "Reserved for coding-agent harness definitions; must be empty until their format is defined.", + "x-orca-since": "1.4.245", + "maxItems": 0, + "type": "array", + "items": {} + }, + "skills": { + "description": "Reserved for agent skills; must be empty until their format is defined.", + "x-orca-since": "1.4.245", + "maxItems": 0, + "type": "array", + "items": {} + }, + "mcpServers": { + "description": "Reserved for MCP server definitions; must be empty until their format is defined.", + "x-orca-since": "1.4.245", + "maxItems": 0, + "type": "array", + "items": {} + }, + "hooks": { + "description": "Reserved for agent hooks; must be empty until their format is defined.", + "x-orca-since": "1.4.245", + "maxItems": 0, + "type": "array", + "items": {} + }, + "taskSources": { + "description": "Reserved for task sources; must be empty until their format is defined.", + "x-orca-since": "1.4.245", + "maxItems": 0, + "type": "array", + "items": {} + }, + "connections": { + "description": "Reserved for account connections; must be empty until their format is defined.", + "x-orca-since": "1.4.245", + "maxItems": 0, + "type": "array", + "items": {} + }, + "settings": { + "description": "Reserved for plugin settings; must be empty until their format is defined.", + "x-orca-since": "1.4.245", + "maxItems": 0, + "type": "array", + "items": {} + }, + "statusBarItems": { + "description": "Reserved for status bar items; must be empty until their format is defined.", + "x-orca-since": "1.4.245", + "maxItems": 0, + "type": "array", + "items": {} + }, + "sidebarCards": { + "description": "Reserved for sidebar cards; must be empty until their format is defined.", + "x-orca-since": "1.4.245", + "maxItems": 0, + "type": "array", + "items": {} + } + }, + "additionalProperties": false + }, + "capabilities": { + "default": [], + "description": "Host APIs the plugin asks the user to grant. At most 32.", + "x-orca-since": "1.4.160", + "maxItems": 32, + "type": "array", + "items": { + "$ref": "#/$defs/Capability" + } + }, + "cover": { + "description": "Wide image at the top of the plugin page.", + "x-orca-since": "1.4.245", + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ](?:[\\\\/][^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ])*$", + "description": "Cover image shown on the plugin page, 16:10 recommended (1600×1000). PNG, JPEG, WEBP, static, at least 800×500, at most 1024 KB. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused.", + "x-orca-since": "1.4.245", + "examples": [ + "images/cover.png" + ] + }, + "alt": { + "type": "string", + "minLength": 1, + "maxLength": 160, + "description": "What the image shows, for screen readers. Required.", + "x-orca-since": "1.4.245", + "examples": [ + "The Dusk theme on a terminal" + ] + } + }, + "required": [ + "path", + "alt" + ], + "additionalProperties": false + }, + "screenshots": { + "description": "Screenshots for the plugin page, at most 6. All images together may be at most 8 MB.", + "x-orca-since": "1.4.245", + "maxItems": 6, + "type": "array", + "items": { + "$ref": "#/$defs/Screenshot" + } + }, + "pluginApi": { + "description": "Major version of the plugin host API this plugin targets. Optional (defaults to 1), and refused on `data` and `declarative` plugins, which never call it.", + "x-orca-since": "1.4.160", + "type": "number", + "const": 1 + } + }, + "required": [ + "manifestVersion", + "execution", + "id", + "publisher", + "name", + "version", + "engines" + ], + "additionalProperties": false, + "$defs": { + "PanelContribution": { + "type": "object", + "properties": { + "id": { + "type": "string", + "maxLength": 64, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "description": "Panel id, unique among this plugin's panels. The reserved names `constructor` and `prototype` are refused.", + "x-orca-since": "1.4.160", + "examples": [ + "dashboard" + ] + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Title shown on the panel tab and its tooltip.", + "x-orca-since": "1.4.160", + "examples": [ + "Dashboard" + ] + }, + "icon": { + "description": "Icon shown in the right-sidebar activity bar. Defaults to `plug`.", + "x-orca-since": "1.4.160", + "examples": [ + "database" + ], + "type": "string", + "enum": [ + "activity", + "bar-chart-3", + "bell", + "blocks", + "book", + "bot", + "bug", + "calendar", + "cloud", + "code", + "database", + "file-text", + "flag", + "folder", + "gauge", + "globe", + "hammer", + "layers", + "lightbulb", + "package", + "plug", + "puzzle", + "rocket", + "star", + "terminal", + "wrench", + "zap" + ] + }, + "entry": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ](?:[\\\\/][^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ])*$", + "description": "HTML file, relative to the plugin root, rendered inside a sandboxed panel frame. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused.", + "x-orca-since": "1.4.160", + "examples": [ + "dist/panel.html" + ] + } + }, + "required": [ + "id", + "title", + "entry" + ], + "additionalProperties": false, + "description": "A sidebar panel. Requires the `sandboxed-ui` execution profile or higher." + }, + "CommandContribution": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*$", + "description": "Command id, unique among this plugin's commands. Dotted namespaces are allowed.", + "x-orca-since": "1.4.160", + "examples": [ + "jupyter.restartKernel" + ] + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Title shown in the command palette.", + "x-orca-since": "1.4.160", + "examples": [ + "Restart kernel" + ] + }, + "when": { + "description": "Where the command is available. `workspace` hides it when no workspace is active. Defaults to `global`.", + "x-orca-since": "1.4.160", + "x-orca-enum-since": { + "global": "1.4.160", + "workspace": "1.4.245" + }, + "type": "string", + "enum": [ + "global", + "workspace" + ] + }, + "action": { + "description": "Built-in Orca action this command runs. A command with an action is declarative and never starts the plugin worker; one without needs `main`.", + "x-orca-since": "1.4.160", + "examples": [ + "view.tasks" + ], + "enum": [ + "worktree.history.back", + "worktree.history.forward", + "sidebar.left.toggle", + "sidebar.sleepingWorkspaces.toggle", + "floatingWorkspace.maximize", + "tab.rename", + "workspace.rename", + "workspace.openBoard", + "view.tasks", + "sidebar.right.toggle", + "sidebar.explorer.toggle", + "sidebar.search.toggle", + "sidebar.sourceControl.toggle", + "sidebar.checks.toggle", + "sidebar.ports.toggle" + ], + "x-orca-enum-since": { + "worktree.history.back": "1.4.160", + "worktree.history.forward": "1.4.160", + "sidebar.left.toggle": "1.4.160", + "sidebar.sleepingWorkspaces.toggle": "1.4.160", + "floatingWorkspace.maximize": "1.4.160", + "tab.rename": "1.4.160", + "workspace.rename": "1.4.160", + "workspace.openBoard": "1.4.160", + "view.tasks": "1.4.160", + "sidebar.right.toggle": "1.4.160", + "sidebar.explorer.toggle": "1.4.160", + "sidebar.search.toggle": "1.4.160", + "sidebar.sourceControl.toggle": "1.4.160", + "sidebar.checks.toggle": "1.4.160", + "sidebar.ports.toggle": "1.4.160" + }, + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*$" + } + }, + "required": [ + "id", + "title" + ], + "additionalProperties": false, + "description": "A command palette entry. Requires the `declarative` execution profile or higher." + }, + "EventContribution": { + "type": "object", + "properties": { + "on": { + "type": "string", + "enum": [ + "workspace.created", + "workspace.removed", + "agent.status.changed" + ], + "description": "Event delivered to the plugin worker.", + "x-orca-since": "1.4.160", + "x-orca-enum-since": { + "workspace.created": "1.4.245", + "workspace.removed": "1.4.245", + "agent.status.changed": "1.4.160" + } + } + }, + "required": [ + "on" + ], + "additionalProperties": false, + "description": "An event subscription. Requires `main`, the `trusted-worker` execution profile and the capability of the event's domain: `workspace:read` for workspace events, `agent:read` for agent events." + }, + "LanguagePackContribution": { + "type": "object", + "properties": { + "locale": { + "type": "string", + "minLength": 2, + "maxLength": 35, + "pattern": "^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$", + "description": "BCP 47 locale the pack translates Orca into. Each locale appears once, ignoring case.", + "x-orca-since": "1.4.160", + "examples": [ + "pt-BR" + ] + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ](?:[\\\\/][^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ])*$", + "description": "JSON file of translated interface strings. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused.", + "x-orca-since": "1.4.160", + "examples": [ + "locales/pt-BR.json" + ] + }, + "displayName": { + "description": "Native-language name for the Settings picker. Omitted packs show `{locale} — {pluginKey}`.", + "x-orca-since": "1.4.245", + "examples": [ + "Português (Brasil)" + ], + "type": "string", + "minLength": 1, + "maxLength": 80 + } + }, + "required": [ + "locale", + "path" + ], + "additionalProperties": false, + "description": "An interface translation." + }, + "KeybindingContribution": { + "type": "object", + "properties": { + "command": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*$", + "description": "Id of a command this plugin contributes in `contributes.commands`.", + "x-orca-since": "1.4.160", + "examples": [ + "jupyter.restartKernel" + ] + }, + "key": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Key chord Orca recognizes, such as `Mod+Shift+K`. `Mod` is Cmd on macOS and Ctrl elsewhere. Each chord may be bound once on every platform.", + "x-orca-since": "1.4.160", + "examples": [ + "Mod+Shift+K" + ] + }, + "when": { + "description": "Where the shortcut is active; must match its command's `when`.", + "x-orca-since": "1.4.160", + "x-orca-enum-since": { + "global": "1.4.160", + "workspace": "1.4.245" + }, + "type": "string", + "enum": [ + "global", + "workspace" + ] + } + }, + "required": [ + "command", + "key" + ], + "additionalProperties": false, + "description": "A keyboard shortcut for a command." + }, + "VmRecipeContribution": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ](?:[\\\\/][^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ])*$", + "description": "JSON environment recipe file, listed once. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused.", + "x-orca-since": "1.4.160", + "examples": [ + "recipes/ubuntu.json" + ] + } + }, + "required": [ + "path" + ], + "additionalProperties": false, + "description": "A virtual machine environment recipe." + }, + "ThemeContribution": { + "type": "object", + "properties": { + "id": { + "type": "string", + "maxLength": 64, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "description": "Theme id, unique within its family in this plugin. The reserved names `constructor` and `prototype` are refused.", + "x-orca-since": "1.4.245", + "examples": [ + "dusk" + ] + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Name shown in the theme picker.", + "x-orca-since": "1.4.245", + "examples": [ + "Dusk" + ] + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ](?:[\\\\/][^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ])*$", + "description": "JSON theme file, at most 16 KiB. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused.", + "x-orca-since": "1.4.245", + "examples": [ + "themes/dusk.json" + ] + } + }, + "required": [ + "id", + "title", + "path" + ], + "additionalProperties": false, + "description": "A titled theme file." + }, + "SyntaxContribution": { + "type": "object", + "properties": { + "id": { + "type": "string", + "maxLength": 64, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "description": "Language id, unique among this plugin's syntaxes. The reserved names `constructor` and `prototype` are refused.", + "x-orca-since": "1.4.245", + "examples": [ + "gleam" + ] + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Language name shown in the editor.", + "x-orca-since": "1.4.245", + "examples": [ + "Gleam" + ] + }, + "scopeName": { + "type": "string", + "maxLength": 128, + "pattern": "^[A-Za-z][A-Za-z0-9_-]*(?:\\.[A-Za-z0-9_-]+)+$", + "description": "The grammar's root TextMate scope, unique among this plugin's syntaxes.", + "x-orca-since": "1.4.245", + "examples": [ + "source.gleam" + ] + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ](?:[\\\\/][^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ])*$", + "description": "TextMate grammar JSON file. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused.", + "x-orca-since": "1.4.245", + "examples": [ + "syntaxes/gleam.tmLanguage.json" + ] + }, + "configuration": { + "description": "Language configuration JSON file: comments, brackets and auto-closing pairs. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused.", + "x-orca-since": "1.4.245", + "examples": [ + "syntaxes/language-configuration.json" + ], + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ](?:[\\\\/][^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ])*$" + }, + "extensions": { + "default": [], + "description": "File-name suffixes, with the dot, this language opens, ignoring case. A longer suffix such as `.excalidraw.json` wins over a shorter one, and a file named only the suffix never matches. At most 32.", + "x-orca-since": "1.4.245", + "examples": [ + [ + ".gleam" + ] + ], + "maxItems": 32, + "type": "array", + "items": { + "type": "string", + "maxLength": 64, + "pattern": "^\\.[A-Za-z0-9_+-]+(?:\\.[A-Za-z0-9_+-]+)*$" + } + }, + "filenames": { + "default": [], + "description": "Exact file names this language opens, ignoring case; a name beats any extension. Names must be portable: no path separators, `.`, `..` or characters Windows forbids. At most 32.", + "x-orca-since": "1.4.245", + "examples": [ + [ + "gleam.toml" + ] + ], + "maxItems": 32, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + } + }, + "required": [ + "id", + "title", + "scopeName", + "path" + ], + "additionalProperties": false, + "description": "Syntax highlighting for one language." + }, + "CustomEditorContribution": { + "type": "object", + "properties": { + "id": { + "type": "string", + "maxLength": 64, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "description": "Editor id, unique among this plugin's custom editors. The reserved names `constructor` and `prototype` are refused.", + "x-orca-since": "1.4.245", + "examples": [ + "excalidraw" + ] + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "description": "Name shown in the editor's view toggle beside Source, and on the tab of a file it opens.", + "x-orca-since": "1.4.245", + "examples": [ + "Excalidraw" + ] + }, + "icon": { + "description": "Square image shown on the tab of a file this editor opens. Defaults to the plugin icon. PNG, WEBP, static, at least 128×128, square, at most 256 KB. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused.", + "x-orca-since": "1.4.245", + "examples": [ + "images/editor-icon.png" + ], + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ](?:[\\\\/][^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ])*$" + }, + "extensions": { + "default": [], + "description": "File-name suffixes, with the dot, this editor opens, ignoring case. A longer suffix such as `.excalidraw.json` wins over a shorter one, and a file named only the suffix never matches. At most 32.", + "x-orca-since": "1.4.245", + "examples": [ + [ + ".excalidraw", + ".excalidraw.json" + ] + ], + "maxItems": 32, + "type": "array", + "items": { + "type": "string", + "maxLength": 64, + "pattern": "^\\.[A-Za-z0-9_+-]+(?:\\.[A-Za-z0-9_+-]+)*$" + } + }, + "filenames": { + "default": [], + "description": "Exact file names this editor opens, ignoring case; a name beats any extension. Names must be portable: no path separators, `.`, `..` or characters Windows forbids. At most 32.", + "x-orca-since": "1.4.245", + "examples": [ + [] + ], + "maxItems": 32, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "entry": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ](?:[\\\\/][^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ])*$", + "description": "HTML file rendered inside a sandboxed editor frame, like a panel. It receives the open document and posts edits back; it never gets a file path or file system. It is trusted code: install editors you trust. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused.", + "x-orca-since": "1.4.245", + "examples": [ + "editor/index.html" + ] + }, + "priority": { + "description": "`default` (the default) opens matching files in this editor; `option` only offers it in the view toggle, so files open in Source. Files Orca opens itself (such as `.json`, `.ts`, `.md`, `.csv`, `.yaml`, lockfiles and `.env` files) always behave as `option`.", + "x-orca-since": "1.4.245", + "examples": [ + "default" + ], + "type": "string", + "enum": [ + "default", + "option" + ] + }, + "contentKind": { + "description": "`text` (the default) sends the file as text and accepts full-text edits. `bytes` sends the file's raw bytes, for binary formats such as spreadsheets, and accepts full-byte edits.", + "x-orca-since": "1.4.245", + "examples": [ + "bytes" + ], + "type": "string", + "enum": [ + "text", + "bytes" + ] + } + }, + "required": [ + "id", + "title", + "entry" + ], + "additionalProperties": false, + "description": "An editor for the listed files, shown in the editor tab beside Source. It must list at least one extension or file name. Requires the `sandboxed-ui` execution profile or higher." + }, + "NewFileContribution": { + "type": "object", + "properties": { + "id": { + "type": "string", + "maxLength": 64, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "description": "New-file id, unique among this plugin's new files. The reserved names `constructor` and `prototype` are refused.", + "x-orca-since": "1.4.245", + "examples": [ + "drawing" + ] + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "description": "Menu item shown in the tab bar + menu and the command palette.", + "x-orca-since": "1.4.245", + "examples": [ + "New Excalidraw drawing" + ] + }, + "extension": { + "type": "string", + "maxLength": 64, + "pattern": "^\\.[A-Za-z0-9_+-]+(?:\\.[A-Za-z0-9_+-]+)*$", + "description": "Suffix, with the dot, of the untitled file it creates. The file opens in whatever editor takes that suffix, such as a custom editor from this plugin.", + "x-orca-since": "1.4.245", + "examples": [ + ".excalidraw" + ] + }, + "template": { + "description": "UTF-8 file copied into the new file, at most 256 KB. Without one the file starts empty. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused.", + "x-orca-since": "1.4.245", + "examples": [ + "templates/empty.excalidraw" + ], + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ](?:[\\\\/][^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ])*$" + }, + "icon": { + "description": "Square image shown beside the menu item. Defaults to the plugin icon. PNG, WEBP, static, at least 128×128, square, at most 256 KB. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused.", + "x-orca-since": "1.4.245", + "examples": [ + "images/editor-icon.png" + ], + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ](?:[\\\\/][^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ])*$" + } + }, + "required": [ + "id", + "title", + "extension" + ], + "additionalProperties": false, + "description": "A kind of untitled file the user can create from the tab bar + menu or the command palette, in the active workspace." + }, + "Capability": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "workspace:read", + "agent:read", + "terminal:send", + "notifications:show", + "storage:own", + "secrets:own", + "settings:own" + ], + "description": "Capability granted on user consent.", + "x-orca-since": "1.4.160", + "x-orca-enum-since": { + "workspace:read": "1.4.160", + "agent:read": "1.4.245", + "terminal:send": "1.4.160", + "notifications:show": "1.4.160", + "storage:own": "1.4.245", + "secrets:own": "1.4.245", + "settings:own": "1.4.160" + } + } + }, + "required": [ + "kind" + ], + "additionalProperties": false, + "description": "A host API the plugin may call once the user consents." + }, + "Screenshot": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ](?:[\\\\/][^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ])*$", + "description": "Screenshot, 16:10 recommended (up to 2560×1600). PNG, JPEG, WEBP, static, at least 1280×800, at most 1536 KB. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused.", + "x-orca-since": "1.4.245", + "examples": [ + "images/editor.png" + ] + }, + "alt": { + "type": "string", + "minLength": 1, + "maxLength": 160, + "description": "What the image shows, for screen readers. Required.", + "x-orca-since": "1.4.245", + "examples": [ + "The Dusk theme on a terminal" + ] + }, + "caption": { + "description": "Short caption under the screenshot.", + "x-orca-since": "1.4.245", + "examples": [ + "Gleam in the editor" + ], + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "theme": { + "description": "Show this screenshot only with a light or dark Orca theme.", + "x-orca-since": "1.4.245", + "type": "string", + "enum": [ + "light", + "dark" + ] + } + }, + "required": [ + "path", + "alt" + ], + "additionalProperties": false, + "description": "One screenshot of the plugin in use." + } + } +} diff --git a/docs/site/content/docs/settings.mdx b/docs/site/content/docs/settings.mdx index 99952825bcd..ca656d4c7bf 100644 --- a/docs/site/content/docs/settings.mdx +++ b/docs/site/content/docs/settings.mdx @@ -156,7 +156,7 @@ Settings are grouped into panes. Everything here is searchable with `Cmd-,` then - **Terminal Directory** — start directory for new floating terminal tabs (`~` = home). - **Toggle Button Location** — where the floating-workspace toggle appears; the keyboard shortcut works regardless of button placement. -## Plugins (Experimental) +## Plugins (Alpha) - **Plugin system** — Settings → Plugins. Turn the system on, then review and enable each plugin individually. Nothing runs until you consent. - **Marketplaces** — add a git marketplace source, browse plugins, preview capabilities (panels, commands, language packs, VM recipes), install, update, or roll back. diff --git a/docs/styleguide/ux-review-rubric.md b/docs/styleguide/ux-review-rubric.md index ef3e637ea73..6e3041037e0 100644 --- a/docs/styleguide/ux-review-rubric.md +++ b/docs/styleguide/ux-review-rubric.md @@ -20,6 +20,7 @@ Use this rubric when reviewing any Orca IDE screen, screenshot, or prototype, an - **Shortcut labels:** show shortcut chips only for shortcuts that are actually implemented and useful at that location. Labels must match the platform binding. If a shortcut strategy is undecided, do not expose a placeholder label in product UI. - **Alignment:** rows and columns must line up to a visible grid. Left-align text and labels for scanability; right-align numbers, counts, shortcuts, and trailing metadata when comparison matters; center-align only compact icon controls, empty states, and table cells where symmetry is the clearest read. - **Copy quality:** displayed text must be typo-free, concise, and specific. Prefer direct verbs and concrete nouns. Remove filler like "please", "simply", "just", "you can", and generic success language that is not backed by state. +- **Settings subtext:** at most one line per section, row or card (aim for 80 characters). Say what the thing is, not how it works: no storage, fallback or lifecycle mechanics, and no reassurance outside security copy. Omit subtext the title already says. Errors name what failed and one action; details belong in logs. - **Dialogs and overlays:** choose a dialog size that matches the amount of input. Short confirmations stay compact; forms with multi-line text, path pickers, provider setup, or review content need a larger dialog or sheet. Floating surfaces must use the documented shadow/elevation and background treatment so they read as above the page. - **Empty and error states:** when data is missing, show a direct action to acquire or configure that data. Use toasts for transient failures or confirmations; persist errors inline when the user needs to read, retry, copy, or act on the message. - **External links:** add direct links when the user may need provider docs, token settings, billing/setup pages, Git provider resources, or troubleshooting context. Put links near the relevant empty state, error, helper text, or setup step instead of burying them in a generic menu. diff --git a/examples/plugins/hello-orca/main.mjs b/examples/plugins/hello-orca/main.mjs index 6e57cea2144..bcede5e2a55 100644 --- a/examples/plugins/hello-orca/main.mjs +++ b/examples/plugins/hello-orca/main.mjs @@ -1,21 +1,22 @@ // Sample plugin runs in a lazily forked plain-Node worker; its default export receives commands, events and capability-gated host API. export default function activate(orca) { - orca.commands.register('hello-ping', async (args) => { + orca.commands.register('hello-ping', async () => { const stored = await orca.host.call('storage.get', { key: 'pings' }) const count = (typeof stored?.value === 'number' ? stored.value : 0) + 1 await orca.host.call('storage.set', { key: 'pings', value: count }) - return { pong: true, count, args: args ?? null } + // A string result is shown as-is in Orca; objects are shown as JSON. + return count === 1 ? 'Pong! First ping.' : `Pong! ${count} pings so far.` }) - orca.events.on('worktree.created', async (payload) => { - orca.log(`worktree created: ${payload.worktreeId} at ${payload.path}`) + orca.events.on('workspace.created', async (payload) => { + orca.log(`${payload.kind} created: ${payload.workspaceId} at ${payload.path}`) await orca.host.call('notifications.show', { - title: 'Worktree created', + title: 'Workspace created', body: payload.path }) }) orca.events.on('agent.status.changed', (payload) => { - orca.log(`agent status: ${payload.state} in ${payload.worktreeId ?? 'unknown worktree'}`) + orca.log(`agent status: ${payload.state} in ${payload.workspaceId ?? 'unknown workspace'}`) }) } diff --git a/examples/plugins/hello-orca/orca-plugin.json b/examples/plugins/hello-orca/orca-plugin.json index da41d0f7e72..6d2a0c01f0f 100644 --- a/examples/plugins/hello-orca/orca-plugin.json +++ b/examples/plugins/hello-orca/orca-plugin.json @@ -1,23 +1,23 @@ { - "manifestVersion": 1, + "manifestVersion": 2, + "execution": "trusted-worker", "id": "hello-orca", - "publisher": "orca-samples", + "publisher": "your-github-user", "name": "Hello Orca", "version": "1.0.0", "description": "Sample plugin combining a sandboxed panel, a worker command, and event subscriptions.", - "engines": { "orca": ">=1.4.0" }, + "engines": { "orca": ">=1.4.245" }, "pluginApi": 1, "main": "main.mjs", "contributes": { "panels": [{ "id": "hello", "title": "Hello Orca", "icon": "plug", "entry": "panel.html" }], "commands": [{ "id": "hello-ping", "title": "Hello: Ping" }], - "events": [{ "on": "worktree.created" }, { "on": "agent.status.changed" }] + "events": [{ "on": "workspace.created" }, { "on": "agent.status.changed" }] }, "capabilities": [ { "kind": "workspace:read" }, - { "kind": "terminal:send" }, { "kind": "notifications:show" }, - { "kind": "storage" }, - { "kind": "events:subscribe" } + { "kind": "storage:own" }, + { "kind": "agent:read" } ] } diff --git a/examples/plugins/hello-orca/panel.html b/examples/plugins/hello-orca/panel.html index c1503441cce..cfd36d25144 100644 --- a/examples/plugins/hello-orca/panel.html +++ b/examples/plugins/hello-orca/panel.html @@ -9,8 +9,8 @@ padding: 12px; font-family: system-ui, sans-serif; font-size: 13px; - color: var(--foreground, #ddd); - background: var(--background, transparent); + color: var(--orca-foreground, #ddd); + background: var(--orca-background, transparent); } h1 { font-size: 14px; @@ -20,21 +20,17 @@ display: block; margin: 6px 0; padding: 6px 10px; - border: 1px solid var(--border, #555); + border: 1px solid var(--orca-border, #555); border-radius: 6px; - background: var(--secondary, #2a2a2a); - color: var(--foreground, #ddd); + background: var(--orca-secondary, #2a2a2a); + color: var(--orca-foreground, #ddd); cursor: pointer; font: inherit; } - select { - margin: 6px 0; - max-width: 100%; - } .status { margin-top: 10px; min-height: 1.4em; - color: var(--muted-foreground, #999); + color: var(--orca-muted-foreground, #999); white-space: pre-wrap; } @@ -43,82 +39,41 @@

Hello Orca 👋

Panel + worker command + events, gated by consent.

- -

diff --git a/examples/plugins/hostile-panel/orca-plugin.json b/examples/plugins/hostile-panel/orca-plugin.json index cf3a2f90a94..47bbea46ad4 100644 --- a/examples/plugins/hostile-panel/orca-plugin.json +++ b/examples/plugins/hostile-panel/orca-plugin.json @@ -1,11 +1,12 @@ { - "manifestVersion": 1, + "manifestVersion": 2, + "execution": "sandboxed-ui", "id": "hostile-panel", - "publisher": "orca-samples", + "publisher": "samples", "name": "Hostile Panel (security fixture)", "version": "1.0.0", "description": "Deliberately hostile panel used by the plugin containment tests: exfiltration, navigation, message floods, busy loops. Never grant it anything.", - "engines": { "orca": ">=1.4.0" }, + "engines": { "orca": ">=1.4.245" }, "pluginApi": 1, "contributes": { "panels": [ diff --git a/examples/plugins/hostile-panel/panel.html b/examples/plugins/hostile-panel/panel.html index e0e82523d1c..8313d56d12f 100644 --- a/examples/plugins/hostile-panel/panel.html +++ b/examples/plugins/hostile-panel/panel.html @@ -127,64 +127,48 @@ }) // Probe 7: an oversized valid-looking call must receive a real refusal. - window.addEventListener('message', function (event) { - var data = event.data - if (event.source !== window.parent || !data || data.type !== 'orca-panel-action-result') { - return - } - if (data.requestId === 'oversized-probe') { - report( - 'oversized-message', - !data.ok && data.errorCode === 'invalid_request', - data.errorCode || 'unexpected success' - ) - } - if (data.requestId === 'flood-result') { - report( - 'message-flood', - !data.ok && data.errorCode === 'rate_limited', - data.errorCode || 'unexpected success' - ) - } - }) - - document.getElementById('bridge').addEventListener('click', function () { - window.parent.postMessage( - { - type: 'orca-panel-action', - requestId: 'oversized-probe', - action: 'workspace.readContext', - params: { padding: 'x'.repeat(128 * 1024) } + function expectRefusal(name, call, code) { + var settled = false + call.then( + function () { + settled = true + report(name, false, 'unexpected success') }, - '*' + function (error) { + settled = true + report(name, error.code === code, error.code) + } ) setTimeout(function () { - report('oversized-message', false, 'host sent no refusal') + if (!settled) { + report(name, false, 'host sent no refusal') + } }, 2000) + } + + document.getElementById('bridge').addEventListener('click', function () { + expectRefusal( + 'oversized-message', + window.orca.host.call('workspace.readContext', { padding: 'x'.repeat(128 * 1024) }), + 'too_large' + ) // Invalid, pong and binary floods must spend rate budget before parsing; the final valid call proves it. setTimeout(function () { for (var i = 0; i < 40; i++) { var payload = i % 3 === 0 - ? { type: 'orca-panel-pong', pingId: i } + ? { orca: 1, kind: 'evt', event: 'pong', payload: i } : i % 3 === 1 ? { type: 'invalid-hostile-message', sequence: i } : new Uint8Array(2048) window.parent.postMessage(payload, '*') } - window.parent.postMessage( - { - type: 'orca-panel-action', - requestId: 'flood-result', - action: 'workspace.readContext', - params: {} - }, - '*' + expectRefusal( + 'message-flood', + window.orca.host.call('workspace.readContext', {}), + 'rate_limited' ) - setTimeout(function () { - report('message-flood', false, 'host sent no rate-limit refusal') - }, 2000) }, 150) }) diff --git a/examples/plugins/orca-gleam-syntax/README.md b/examples/plugins/orca-gleam-syntax/README.md new file mode 100644 index 00000000000..a7db86370c3 --- /dev/null +++ b/examples/plugins/orca-gleam-syntax/README.md @@ -0,0 +1,5 @@ +# Gleam syntax (example) + +An official-style data plugin: one `syntaxes` contribution with a vendored TextMate grammar +(Apache-2.0, see `syntaxes/gleam-LICENSE.txt`). It runs no code. A catalog release for it would +list `"types": ["syntax"]` and `"fileTypes": ["gleam"]`, so opening a `.gleam` file suggests it. diff --git a/examples/plugins/orca-gleam-syntax/orca-plugin.json b/examples/plugins/orca-gleam-syntax/orca-plugin.json new file mode 100644 index 00000000000..de915388ad6 --- /dev/null +++ b/examples/plugins/orca-gleam-syntax/orca-plugin.json @@ -0,0 +1,23 @@ +{ + "$schema": "./orca-plugin.v2.schema.json", + "manifestVersion": 2, + "execution": "data", + "id": "orca-gleam-syntax", + "publisher": "stablyai", + "name": "Gleam", + "version": "1.0.0", + "description": "Syntax highlighting for Gleam files.", + "engines": { "orca": ">=1.4.245" }, + "contributes": { + "syntaxes": [ + { + "id": "gleam", + "title": "Gleam", + "scopeName": "source.gleam", + "path": "syntaxes/gleam.tmLanguage.json", + "configuration": "syntaxes/language-configuration.json", + "extensions": [".gleam"] + } + ] + } +} diff --git a/examples/plugins/orca-gleam-syntax/orca-plugin.v2.schema.json b/examples/plugins/orca-gleam-syntax/orca-plugin.v2.schema.json new file mode 100644 index 00000000000..01e3392d8ef --- /dev/null +++ b/examples/plugins/orca-gleam-syntax/orca-plugin.v2.schema.json @@ -0,0 +1,1149 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/stablyai/orca/main/docs/schemas/plugins/orca-plugin.v2.schema.json", + "title": "Orca plugin manifest", + "description": "The orca-plugin.json file at the root of an Orca plugin (manifest format 2).", + "type": "object", + "properties": { + "$schema": { + "description": "JSON Schema URL for editor completion. Ignored by Orca.", + "x-orca-since": "1.4.245", + "type": "string", + "maxLength": 2048 + }, + "manifestVersion": { + "type": "number", + "const": 2, + "description": "Manifest format. Use 2 for new plugins.", + "x-orca-since": "1.4.245" + }, + "execution": { + "type": "string", + "enum": [ + "data", + "declarative", + "sandboxed-ui", + "trusted-worker" + ], + "description": "Most authority the plugin needs, least first: `data` (themes, syntaxes, translations), `declarative` (commands, shortcuts, recipes), `sandboxed-ui` (panels), `trusted-worker` (`main` and events). Contributions beyond it fail validation.", + "x-orca-since": "1.4.245", + "examples": [ + "data" + ] + }, + "id": { + "type": "string", + "maxLength": 64, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "description": "Plugin id, kebab-case. The install identity is `.`. The reserved names `constructor` and `prototype` are refused.", + "x-orca-since": "1.4.160", + "examples": [ + "gleam-syntax" + ] + }, + "publisher": { + "type": "string", + "maxLength": 64, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "description": "Publisher slug, kebab-case. Must be the GitHub or GitLab user or organization that owns the repository (`acme` for `github.com/acme/gleam-syntax`), or the official and community catalogs reject the listing. The reserved names `constructor` and `prototype` are refused. The publishers `stablyai`, `official`, `anthropic`, `openai`, `github` and any starting `orca` (any case), and ids starting `orca-`, install only from Orca's official catalog.", + "x-orca-since": "1.4.160", + "examples": [ + "acme" + ] + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Display name.", + "x-orca-since": "1.4.160", + "examples": [ + "Gleam" + ] + }, + "version": { + "type": "string", + "pattern": "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$", + "description": "Plugin version, semver.", + "x-orca-since": "1.4.160", + "examples": [ + "1.2.0" + ] + }, + "description": { + "description": "One or two sentences shown in the plugin list.", + "x-orca-since": "1.4.160", + "type": "string", + "maxLength": 4096 + }, + "author": { + "description": "Who maintains the plugin.", + "x-orca-since": "1.4.160", + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Author or organization name.", + "x-orca-since": "1.4.160", + "examples": [ + "Acme" + ] + }, + "url": { + "description": "Author homepage.", + "x-orca-since": "1.4.160", + "examples": [ + "https://example.com" + ], + "type": "string", + "maxLength": 2048 + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + "repository": { + "description": "Source repository URL.", + "x-orca-since": "1.4.160", + "examples": [ + "https://github.com/acme/orca-gleam-syntax" + ], + "type": "string", + "maxLength": 2048 + }, + "license": { + "description": "SPDX license expression.", + "x-orca-since": "1.4.245", + "examples": [ + "MIT", + "Apache-2.0 OR MIT" + ], + "type": "string", + "maxLength": 128, + "pattern": "^\\(?[A-Za-z0-9][A-Za-z0-9.+-]*(?: (?:AND|OR|WITH) \\(?[A-Za-z0-9][A-Za-z0-9.+-]*\\)?)*\\)?$" + }, + "homepage": { + "description": "Project homepage, https only.", + "x-orca-since": "1.4.245", + "examples": [ + "https://example.com/gleam" + ], + "type": "string", + "maxLength": 2048, + "pattern": "^https:\\/\\/\\S+$" + }, + "icon": { + "description": "Square image shown beside the plugin name. PNG, WEBP, static, at least 128×128, square, at most 256 KB. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused.", + "x-orca-since": "1.4.160", + "examples": [ + "assets/icon.png" + ], + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ](?:[\\\\/][^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ])*$" + }, + "engines": { + "type": "object", + "properties": { + "orca": { + "type": "string", + "maxLength": 64, + "pattern": "^>=\\d+\\.\\d+\\.\\d+$", + "description": "Oldest Orca release this plugin runs on, as `>=x.y.z`. Raise it to the newest \"Since\" of any field you use; a format 2 manifest needs at least `>=1.4.245`, because older Orca cannot read format 2.", + "x-orca-since": "1.4.160", + "examples": [ + ">=1.4.245" + ] + } + }, + "required": [ + "orca" + ], + "additionalProperties": false, + "description": "Host versions this plugin supports. Older hosts refuse to load it.", + "x-orca-since": "1.4.160" + }, + "main": { + "description": "Node entry run in an out-of-process plugin worker. Requires the `trusted-worker` execution profile, and is required by it, by `contributes.events` and by any command without an `action`. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused.", + "x-orca-since": "1.4.160", + "examples": [ + "dist/main.mjs" + ], + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ](?:[\\\\/][^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ])*$" + }, + "contributes": { + "description": "What the plugin adds to Orca. Every point defaults to empty.", + "x-orca-since": "1.4.160", + "default": {}, + "type": "object", + "properties": { + "panels": { + "default": [], + "description": "Sidebar panels. At most 64.", + "x-orca-since": "1.4.160", + "maxItems": 64, + "type": "array", + "items": { + "$ref": "#/$defs/PanelContribution" + } + }, + "commands": { + "default": [], + "description": "Command palette entries. At most 256.", + "x-orca-since": "1.4.160", + "maxItems": 256, + "type": "array", + "items": { + "$ref": "#/$defs/CommandContribution" + } + }, + "events": { + "default": [], + "description": "Events delivered to the plugin worker. At most 3.", + "x-orca-since": "1.4.160", + "maxItems": 3, + "type": "array", + "items": { + "$ref": "#/$defs/EventContribution" + } + }, + "languagePacks": { + "default": [], + "description": "Interface translations. At most 16.", + "x-orca-since": "1.4.160", + "maxItems": 16, + "type": "array", + "items": { + "$ref": "#/$defs/LanguagePackContribution" + } + }, + "keybindings": { + "description": "Keyboard shortcuts for contributed commands. At most 256.", + "x-orca-since": "1.4.160", + "maxItems": 256, + "type": "array", + "items": { + "$ref": "#/$defs/KeybindingContribution" + } + }, + "vmRecipes": { + "default": [], + "description": "Virtual machine environment recipes. At most 64.", + "x-orca-since": "1.4.160", + "maxItems": 64, + "type": "array", + "items": { + "$ref": "#/$defs/VmRecipeContribution" + } + }, + "themes": { + "default": [], + "description": "App color themes. At most 64.", + "x-orca-since": "1.4.245", + "maxItems": 64, + "type": "array", + "items": { + "$ref": "#/$defs/ThemeContribution" + } + }, + "terminalThemes": { + "default": [], + "description": "Terminal ANSI palettes. At most 64.", + "x-orca-since": "1.4.245", + "maxItems": 64, + "type": "array", + "items": { + "$ref": "#/$defs/ThemeContribution" + } + }, + "iconThemes": { + "default": [], + "description": "File icon themes. At most 32.", + "x-orca-since": "1.4.245", + "maxItems": 32, + "type": "array", + "items": { + "$ref": "#/$defs/ThemeContribution" + } + }, + "syntaxes": { + "default": [], + "description": "Syntax highlighting for editor languages. At most 16.", + "x-orca-since": "1.4.245", + "maxItems": 16, + "type": "array", + "items": { + "$ref": "#/$defs/SyntaxContribution" + } + }, + "customEditors": { + "default": [], + "description": "Editors that open files of given types in the editor tab. At most 16.", + "x-orca-since": "1.4.245", + "maxItems": 16, + "type": "array", + "items": { + "$ref": "#/$defs/CustomEditorContribution" + } + }, + "newFiles": { + "default": [], + "description": "Untitled files the user can create from the + menu and the command palette. At most 16.", + "x-orca-since": "1.4.245", + "maxItems": 16, + "type": "array", + "items": { + "$ref": "#/$defs/NewFileContribution" + } + }, + "agentHarnesses": { + "description": "Reserved for coding-agent harness definitions; must be empty until their format is defined.", + "x-orca-since": "1.4.245", + "maxItems": 0, + "type": "array", + "items": {} + }, + "skills": { + "description": "Reserved for agent skills; must be empty until their format is defined.", + "x-orca-since": "1.4.245", + "maxItems": 0, + "type": "array", + "items": {} + }, + "mcpServers": { + "description": "Reserved for MCP server definitions; must be empty until their format is defined.", + "x-orca-since": "1.4.245", + "maxItems": 0, + "type": "array", + "items": {} + }, + "hooks": { + "description": "Reserved for agent hooks; must be empty until their format is defined.", + "x-orca-since": "1.4.245", + "maxItems": 0, + "type": "array", + "items": {} + }, + "taskSources": { + "description": "Reserved for task sources; must be empty until their format is defined.", + "x-orca-since": "1.4.245", + "maxItems": 0, + "type": "array", + "items": {} + }, + "connections": { + "description": "Reserved for account connections; must be empty until their format is defined.", + "x-orca-since": "1.4.245", + "maxItems": 0, + "type": "array", + "items": {} + }, + "settings": { + "description": "Reserved for plugin settings; must be empty until their format is defined.", + "x-orca-since": "1.4.245", + "maxItems": 0, + "type": "array", + "items": {} + }, + "statusBarItems": { + "description": "Reserved for status bar items; must be empty until their format is defined.", + "x-orca-since": "1.4.245", + "maxItems": 0, + "type": "array", + "items": {} + }, + "sidebarCards": { + "description": "Reserved for sidebar cards; must be empty until their format is defined.", + "x-orca-since": "1.4.245", + "maxItems": 0, + "type": "array", + "items": {} + } + }, + "additionalProperties": false + }, + "capabilities": { + "default": [], + "description": "Host APIs the plugin asks the user to grant. At most 32.", + "x-orca-since": "1.4.160", + "maxItems": 32, + "type": "array", + "items": { + "$ref": "#/$defs/Capability" + } + }, + "cover": { + "description": "Wide image at the top of the plugin page.", + "x-orca-since": "1.4.245", + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ](?:[\\\\/][^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ])*$", + "description": "Cover image shown on the plugin page, 16:10 recommended (1600×1000). PNG, JPEG, WEBP, static, at least 800×500, at most 1024 KB. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused.", + "x-orca-since": "1.4.245", + "examples": [ + "images/cover.png" + ] + }, + "alt": { + "type": "string", + "minLength": 1, + "maxLength": 160, + "description": "What the image shows, for screen readers. Required.", + "x-orca-since": "1.4.245", + "examples": [ + "The Dusk theme on a terminal" + ] + } + }, + "required": [ + "path", + "alt" + ], + "additionalProperties": false + }, + "screenshots": { + "description": "Screenshots for the plugin page, at most 6. All images together may be at most 8 MB.", + "x-orca-since": "1.4.245", + "maxItems": 6, + "type": "array", + "items": { + "$ref": "#/$defs/Screenshot" + } + }, + "pluginApi": { + "description": "Major version of the plugin host API this plugin targets. Optional (defaults to 1), and refused on `data` and `declarative` plugins, which never call it.", + "x-orca-since": "1.4.160", + "type": "number", + "const": 1 + } + }, + "required": [ + "manifestVersion", + "execution", + "id", + "publisher", + "name", + "version", + "engines" + ], + "additionalProperties": false, + "$defs": { + "PanelContribution": { + "type": "object", + "properties": { + "id": { + "type": "string", + "maxLength": 64, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "description": "Panel id, unique among this plugin's panels. The reserved names `constructor` and `prototype` are refused.", + "x-orca-since": "1.4.160", + "examples": [ + "dashboard" + ] + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Title shown on the panel tab and its tooltip.", + "x-orca-since": "1.4.160", + "examples": [ + "Dashboard" + ] + }, + "icon": { + "description": "Icon shown in the right-sidebar activity bar. Defaults to `plug`.", + "x-orca-since": "1.4.160", + "examples": [ + "database" + ], + "type": "string", + "enum": [ + "activity", + "bar-chart-3", + "bell", + "blocks", + "book", + "bot", + "bug", + "calendar", + "cloud", + "code", + "database", + "file-text", + "flag", + "folder", + "gauge", + "globe", + "hammer", + "layers", + "lightbulb", + "package", + "plug", + "puzzle", + "rocket", + "star", + "terminal", + "wrench", + "zap" + ] + }, + "entry": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ](?:[\\\\/][^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ])*$", + "description": "HTML file, relative to the plugin root, rendered inside a sandboxed panel frame. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused.", + "x-orca-since": "1.4.160", + "examples": [ + "dist/panel.html" + ] + } + }, + "required": [ + "id", + "title", + "entry" + ], + "additionalProperties": false, + "description": "A sidebar panel. Requires the `sandboxed-ui` execution profile or higher." + }, + "CommandContribution": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*$", + "description": "Command id, unique among this plugin's commands. Dotted namespaces are allowed.", + "x-orca-since": "1.4.160", + "examples": [ + "jupyter.restartKernel" + ] + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Title shown in the command palette.", + "x-orca-since": "1.4.160", + "examples": [ + "Restart kernel" + ] + }, + "when": { + "description": "Where the command is available. `workspace` hides it when no workspace is active. Defaults to `global`.", + "x-orca-since": "1.4.160", + "x-orca-enum-since": { + "global": "1.4.160", + "workspace": "1.4.245" + }, + "type": "string", + "enum": [ + "global", + "workspace" + ] + }, + "action": { + "description": "Built-in Orca action this command runs. A command with an action is declarative and never starts the plugin worker; one without needs `main`.", + "x-orca-since": "1.4.160", + "examples": [ + "view.tasks" + ], + "enum": [ + "worktree.history.back", + "worktree.history.forward", + "sidebar.left.toggle", + "sidebar.sleepingWorkspaces.toggle", + "floatingWorkspace.maximize", + "tab.rename", + "workspace.rename", + "workspace.openBoard", + "view.tasks", + "sidebar.right.toggle", + "sidebar.explorer.toggle", + "sidebar.search.toggle", + "sidebar.sourceControl.toggle", + "sidebar.checks.toggle", + "sidebar.ports.toggle" + ], + "x-orca-enum-since": { + "worktree.history.back": "1.4.160", + "worktree.history.forward": "1.4.160", + "sidebar.left.toggle": "1.4.160", + "sidebar.sleepingWorkspaces.toggle": "1.4.160", + "floatingWorkspace.maximize": "1.4.160", + "tab.rename": "1.4.160", + "workspace.rename": "1.4.160", + "workspace.openBoard": "1.4.160", + "view.tasks": "1.4.160", + "sidebar.right.toggle": "1.4.160", + "sidebar.explorer.toggle": "1.4.160", + "sidebar.search.toggle": "1.4.160", + "sidebar.sourceControl.toggle": "1.4.160", + "sidebar.checks.toggle": "1.4.160", + "sidebar.ports.toggle": "1.4.160" + }, + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*$" + } + }, + "required": [ + "id", + "title" + ], + "additionalProperties": false, + "description": "A command palette entry. Requires the `declarative` execution profile or higher." + }, + "EventContribution": { + "type": "object", + "properties": { + "on": { + "type": "string", + "enum": [ + "workspace.created", + "workspace.removed", + "agent.status.changed" + ], + "description": "Event delivered to the plugin worker.", + "x-orca-since": "1.4.160", + "x-orca-enum-since": { + "workspace.created": "1.4.245", + "workspace.removed": "1.4.245", + "agent.status.changed": "1.4.160" + } + } + }, + "required": [ + "on" + ], + "additionalProperties": false, + "description": "An event subscription. Requires `main`, the `trusted-worker` execution profile and the capability of the event's domain: `workspace:read` for workspace events, `agent:read` for agent events." + }, + "LanguagePackContribution": { + "type": "object", + "properties": { + "locale": { + "type": "string", + "minLength": 2, + "maxLength": 35, + "pattern": "^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$", + "description": "BCP 47 locale the pack translates Orca into. Each locale appears once, ignoring case.", + "x-orca-since": "1.4.160", + "examples": [ + "pt-BR" + ] + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ](?:[\\\\/][^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ])*$", + "description": "JSON file of translated interface strings. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused.", + "x-orca-since": "1.4.160", + "examples": [ + "locales/pt-BR.json" + ] + }, + "displayName": { + "description": "Native-language name for the Settings picker. Omitted packs show `{locale} — {pluginKey}`.", + "x-orca-since": "1.4.245", + "examples": [ + "Português (Brasil)" + ], + "type": "string", + "minLength": 1, + "maxLength": 80 + } + }, + "required": [ + "locale", + "path" + ], + "additionalProperties": false, + "description": "An interface translation." + }, + "KeybindingContribution": { + "type": "object", + "properties": { + "command": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*$", + "description": "Id of a command this plugin contributes in `contributes.commands`.", + "x-orca-since": "1.4.160", + "examples": [ + "jupyter.restartKernel" + ] + }, + "key": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Key chord Orca recognizes, such as `Mod+Shift+K`. `Mod` is Cmd on macOS and Ctrl elsewhere. Each chord may be bound once on every platform.", + "x-orca-since": "1.4.160", + "examples": [ + "Mod+Shift+K" + ] + }, + "when": { + "description": "Where the shortcut is active; must match its command's `when`.", + "x-orca-since": "1.4.160", + "x-orca-enum-since": { + "global": "1.4.160", + "workspace": "1.4.245" + }, + "type": "string", + "enum": [ + "global", + "workspace" + ] + } + }, + "required": [ + "command", + "key" + ], + "additionalProperties": false, + "description": "A keyboard shortcut for a command." + }, + "VmRecipeContribution": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ](?:[\\\\/][^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ])*$", + "description": "JSON environment recipe file, listed once. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused.", + "x-orca-since": "1.4.160", + "examples": [ + "recipes/ubuntu.json" + ] + } + }, + "required": [ + "path" + ], + "additionalProperties": false, + "description": "A virtual machine environment recipe." + }, + "ThemeContribution": { + "type": "object", + "properties": { + "id": { + "type": "string", + "maxLength": 64, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "description": "Theme id, unique within its family in this plugin. The reserved names `constructor` and `prototype` are refused.", + "x-orca-since": "1.4.245", + "examples": [ + "dusk" + ] + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Name shown in the theme picker.", + "x-orca-since": "1.4.245", + "examples": [ + "Dusk" + ] + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ](?:[\\\\/][^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ])*$", + "description": "JSON theme file, at most 16 KiB. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused.", + "x-orca-since": "1.4.245", + "examples": [ + "themes/dusk.json" + ] + } + }, + "required": [ + "id", + "title", + "path" + ], + "additionalProperties": false, + "description": "A titled theme file." + }, + "SyntaxContribution": { + "type": "object", + "properties": { + "id": { + "type": "string", + "maxLength": 64, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "description": "Language id, unique among this plugin's syntaxes. The reserved names `constructor` and `prototype` are refused.", + "x-orca-since": "1.4.245", + "examples": [ + "gleam" + ] + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Language name shown in the editor.", + "x-orca-since": "1.4.245", + "examples": [ + "Gleam" + ] + }, + "scopeName": { + "type": "string", + "maxLength": 128, + "pattern": "^[A-Za-z][A-Za-z0-9_-]*(?:\\.[A-Za-z0-9_-]+)+$", + "description": "The grammar's root TextMate scope, unique among this plugin's syntaxes.", + "x-orca-since": "1.4.245", + "examples": [ + "source.gleam" + ] + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ](?:[\\\\/][^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ])*$", + "description": "TextMate grammar JSON file. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused.", + "x-orca-since": "1.4.245", + "examples": [ + "syntaxes/gleam.tmLanguage.json" + ] + }, + "configuration": { + "description": "Language configuration JSON file: comments, brackets and auto-closing pairs. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused.", + "x-orca-since": "1.4.245", + "examples": [ + "syntaxes/language-configuration.json" + ], + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ](?:[\\\\/][^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ])*$" + }, + "extensions": { + "default": [], + "description": "File-name suffixes, with the dot, this language opens, ignoring case. A longer suffix such as `.excalidraw.json` wins over a shorter one, and a file named only the suffix never matches. At most 32.", + "x-orca-since": "1.4.245", + "examples": [ + [ + ".gleam" + ] + ], + "maxItems": 32, + "type": "array", + "items": { + "type": "string", + "maxLength": 64, + "pattern": "^\\.[A-Za-z0-9_+-]+(?:\\.[A-Za-z0-9_+-]+)*$" + } + }, + "filenames": { + "default": [], + "description": "Exact file names this language opens, ignoring case; a name beats any extension. Names must be portable: no path separators, `.`, `..` or characters Windows forbids. At most 32.", + "x-orca-since": "1.4.245", + "examples": [ + [ + "gleam.toml" + ] + ], + "maxItems": 32, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + } + }, + "required": [ + "id", + "title", + "scopeName", + "path" + ], + "additionalProperties": false, + "description": "Syntax highlighting for one language." + }, + "CustomEditorContribution": { + "type": "object", + "properties": { + "id": { + "type": "string", + "maxLength": 64, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "description": "Editor id, unique among this plugin's custom editors. The reserved names `constructor` and `prototype` are refused.", + "x-orca-since": "1.4.245", + "examples": [ + "excalidraw" + ] + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "description": "Name shown in the editor's view toggle beside Source, and on the tab of a file it opens.", + "x-orca-since": "1.4.245", + "examples": [ + "Excalidraw" + ] + }, + "icon": { + "description": "Square image shown on the tab of a file this editor opens. Defaults to the plugin icon. PNG, WEBP, static, at least 128×128, square, at most 256 KB. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused.", + "x-orca-since": "1.4.245", + "examples": [ + "images/editor-icon.png" + ], + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ](?:[\\\\/][^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ])*$" + }, + "extensions": { + "default": [], + "description": "File-name suffixes, with the dot, this editor opens, ignoring case. A longer suffix such as `.excalidraw.json` wins over a shorter one, and a file named only the suffix never matches. At most 32.", + "x-orca-since": "1.4.245", + "examples": [ + [ + ".excalidraw", + ".excalidraw.json" + ] + ], + "maxItems": 32, + "type": "array", + "items": { + "type": "string", + "maxLength": 64, + "pattern": "^\\.[A-Za-z0-9_+-]+(?:\\.[A-Za-z0-9_+-]+)*$" + } + }, + "filenames": { + "default": [], + "description": "Exact file names this editor opens, ignoring case; a name beats any extension. Names must be portable: no path separators, `.`, `..` or characters Windows forbids. At most 32.", + "x-orca-since": "1.4.245", + "examples": [ + [] + ], + "maxItems": 32, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "entry": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ](?:[\\\\/][^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ])*$", + "description": "HTML file rendered inside a sandboxed editor frame, like a panel. It receives the open document and posts edits back; it never gets a file path or file system. It is trusted code: install editors you trust. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused.", + "x-orca-since": "1.4.245", + "examples": [ + "editor/index.html" + ] + }, + "priority": { + "description": "`default` (the default) opens matching files in this editor; `option` only offers it in the view toggle, so files open in Source. Files Orca opens itself (such as `.json`, `.ts`, `.md`, `.csv`, `.yaml`, lockfiles and `.env` files) always behave as `option`.", + "x-orca-since": "1.4.245", + "examples": [ + "default" + ], + "type": "string", + "enum": [ + "default", + "option" + ] + }, + "contentKind": { + "description": "`text` (the default) sends the file as text and accepts full-text edits. `bytes` sends the file's raw bytes, for binary formats such as spreadsheets, and accepts full-byte edits.", + "x-orca-since": "1.4.245", + "examples": [ + "bytes" + ], + "type": "string", + "enum": [ + "text", + "bytes" + ] + } + }, + "required": [ + "id", + "title", + "entry" + ], + "additionalProperties": false, + "description": "An editor for the listed files, shown in the editor tab beside Source. It must list at least one extension or file name. Requires the `sandboxed-ui` execution profile or higher." + }, + "NewFileContribution": { + "type": "object", + "properties": { + "id": { + "type": "string", + "maxLength": 64, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "description": "New-file id, unique among this plugin's new files. The reserved names `constructor` and `prototype` are refused.", + "x-orca-since": "1.4.245", + "examples": [ + "drawing" + ] + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "description": "Menu item shown in the tab bar + menu and the command palette.", + "x-orca-since": "1.4.245", + "examples": [ + "New Excalidraw drawing" + ] + }, + "extension": { + "type": "string", + "maxLength": 64, + "pattern": "^\\.[A-Za-z0-9_+-]+(?:\\.[A-Za-z0-9_+-]+)*$", + "description": "Suffix, with the dot, of the untitled file it creates. The file opens in whatever editor takes that suffix, such as a custom editor from this plugin.", + "x-orca-since": "1.4.245", + "examples": [ + ".excalidraw" + ] + }, + "template": { + "description": "UTF-8 file copied into the new file, at most 256 KB. Without one the file starts empty. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused.", + "x-orca-since": "1.4.245", + "examples": [ + "templates/empty.excalidraw" + ], + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ](?:[\\\\/][^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ])*$" + }, + "icon": { + "description": "Square image shown beside the menu item. Defaults to the plugin icon. PNG, WEBP, static, at least 128×128, square, at most 256 KB. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused.", + "x-orca-since": "1.4.245", + "examples": [ + "images/editor-icon.png" + ], + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ](?:[\\\\/][^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ])*$" + } + }, + "required": [ + "id", + "title", + "extension" + ], + "additionalProperties": false, + "description": "A kind of untitled file the user can create from the tab bar + menu or the command palette, in the active workspace." + }, + "Capability": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "workspace:read", + "agent:read", + "terminal:send", + "notifications:show", + "storage:own", + "secrets:own", + "settings:own" + ], + "description": "Capability granted on user consent.", + "x-orca-since": "1.4.160", + "x-orca-enum-since": { + "workspace:read": "1.4.160", + "agent:read": "1.4.245", + "terminal:send": "1.4.160", + "notifications:show": "1.4.160", + "storage:own": "1.4.245", + "secrets:own": "1.4.245", + "settings:own": "1.4.160" + } + } + }, + "required": [ + "kind" + ], + "additionalProperties": false, + "description": "A host API the plugin may call once the user consents." + }, + "Screenshot": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^[^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ](?:[\\\\/][^<>:\"|?*\\x00-\\x1f\\\\/]*[^<>:\"|?*\\x00-\\x1f\\\\/. ])*$", + "description": "Screenshot, 16:10 recommended (up to 2560×1600). PNG, JPEG, WEBP, static, at least 1280×800, at most 1536 KB. Relative to the plugin root; `..` segments and Windows device names such as `CON` are refused.", + "x-orca-since": "1.4.245", + "examples": [ + "images/editor.png" + ] + }, + "alt": { + "type": "string", + "minLength": 1, + "maxLength": 160, + "description": "What the image shows, for screen readers. Required.", + "x-orca-since": "1.4.245", + "examples": [ + "The Dusk theme on a terminal" + ] + }, + "caption": { + "description": "Short caption under the screenshot.", + "x-orca-since": "1.4.245", + "examples": [ + "Gleam in the editor" + ], + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "theme": { + "description": "Show this screenshot only with a light or dark Orca theme.", + "x-orca-since": "1.4.245", + "type": "string", + "enum": [ + "light", + "dark" + ] + } + }, + "required": [ + "path", + "alt" + ], + "additionalProperties": false, + "description": "One screenshot of the plugin in use." + } + } +} diff --git a/examples/plugins/orca-gleam-syntax/syntaxes/gleam-LICENSE.txt b/examples/plugins/orca-gleam-syntax/syntaxes/gleam-LICENSE.txt new file mode 100644 index 00000000000..2bbe4ac4b09 --- /dev/null +++ b/examples/plugins/orca-gleam-syntax/syntaxes/gleam-LICENSE.txt @@ -0,0 +1,216 @@ +Gleam TextMate grammar + +Source: https://github.com/gleam-lang/vscode-gleam/blob/d6ce8c4358c278245e913b99a281d118637c9399/syntaxes/gleam.tmLanguage.json +Modifications by Orca: JSON formatting; the $schema key is removed. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2016 - present Louis Pilfold + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + +## Runtime Library Exception to the Apache 2.0 License: ## + + + As an exception, if you use this Software to compile your source code and + portions of this Software are embedded into the binary product as a result, + you may redistribute such product without providing attribution as would + otherwise be required by Sections 4(a), 4(b) and 4(d) of the License. diff --git a/examples/plugins/orca-gleam-syntax/syntaxes/gleam.tmLanguage.json b/examples/plugins/orca-gleam-syntax/syntaxes/gleam.tmLanguage.json new file mode 100644 index 00000000000..bb3ce9e3557 --- /dev/null +++ b/examples/plugins/orca-gleam-syntax/syntaxes/gleam.tmLanguage.json @@ -0,0 +1,172 @@ +{ + "name": "Gleam", + "patterns": [ + { + "include": "#comments" + }, + { + "include": "#keywords" + }, + { + "include": "#strings" + }, + { + "include": "#constant" + }, + { + "include": "#entity" + }, + { + "include": "#discards" + } + ], + "repository": { + "keywords": { + "patterns": [ + { + "name": "keyword.control.gleam", + "match": "\\b(as|use|case|if|fn|import|let|assert|pub|type|opaque|const|todo|panic|else|echo)\\b" + }, + { + "name": "keyword.operator.arrow.gleam", + "match": "(<\\-|\\->)" + }, + { + "name": "keyword.operator.pipe.gleam", + "match": "\\|>" + }, + { + "name": "keyword.operator.splat.gleam", + "match": "\\.\\." + }, + { + "name": "keyword.operator.comparison.gleam", + "match": "(==|!=)" + }, + { + "name": "keyword.operator.comparison.float.gleam", + "match": "(<=\\.|>=\\.|<\\.|>\\.)" + }, + { + "name": "keyword.operator.comparison.int.gleam", + "match": "(<=|>=|<|>)" + }, + { + "name": "keyword.operator.logical.gleam", + "match": "(&&|\\|\\|)" + }, + { + "name": "keyword.operator.string.gleam", + "match": "<>" + }, + { + "name": "keyword.operator.other.gleam", + "match": "\\|" + }, + { + "name": "keyword.operator.arithmetic.float.gleam", + "match": "(\\+\\.|\\-\\.|/\\.|\\*\\.)" + }, + { + "name": "keyword.operator.arithmetic.int.gleam", + "match": "(\\+|\\-|/|\\*|%)" + }, + { + "name": "keyword.operator.assignment.gleam", + "match": "=" + } + ] + }, + "strings": { + "name": "string.quoted.double.gleam", + "begin": "\"", + "end": "\"", + "patterns": [ + { + "name": "constant.character.escape.gleam", + "match": "\\\\." + } + ] + }, + "comments": { + "patterns": [ + { + "name": "comment.line.gleam", + "match": "//.*" + } + ] + }, + "constant": { + "patterns": [ + { + "include": "#binary_number" + }, + { + "include": "#octal_number" + }, + { + "include": "#hexadecimal_number" + }, + { + "include": "#decimal_number" + }, + { + "name": "entity.name.type.gleam", + "match": "[[:upper:]][[:alnum:]]*" + } + ] + }, + "binary_number": { + "name": "constant.numeric.binary.gleam", + "match": "\\b0[bB][01_]*\\b", + "patterns": [] + }, + "octal_number": { + "name": "constant.numeric.octal.gleam", + "match": "\\b0[oO][0-7_]*\\b", + "patterns": [] + }, + "decimal_number": { + "name": "constant.numeric.decimal.gleam", + "match": "\\b([0-9][0-9_]*)(\\.([0-9_]*)?(e-?[0-9]+)?)?\\b", + "patterns": [] + }, + "hexadecimal_number": { + "name": "constant.numeric.hexadecimal.gleam", + "match": "\\b0[xX][0-9a-fA-F_]+\\b", + "patterns": [] + }, + "entity": { + "patterns": [ + { + "begin": "\\b([[:lower:]][[:word:]]*)\\b[[:space:]]*\\(", + "end": "\\)", + "patterns": [ + { + "include": "$self" + } + ], + "captures": { + "1": { + "name": "entity.name.function.gleam" + } + } + }, + { + "name": "variable.parameter.gleam", + "match": "\\b([[:lower:]][[:word:]]*):\\s" + }, + { + "name": "entity.name.namespace.gleam", + "match": "\\b([[:lower:]][[:word:]]*):" + } + ] + }, + "discards": { + "name": "comment.unused.gleam", + "match": "\\b_(?:[[:word:]]+)?\\b" + } + }, + "scopeName": "source.gleam", + "fileTypes": ["gleam"] +} diff --git a/examples/plugins/orca-gleam-syntax/syntaxes/language-configuration.json b/examples/plugins/orca-gleam-syntax/syntaxes/language-configuration.json new file mode 100644 index 00000000000..6d6f75579d2 --- /dev/null +++ b/examples/plugins/orca-gleam-syntax/syntaxes/language-configuration.json @@ -0,0 +1,20 @@ +{ + "comments": { "lineComment": "//" }, + "brackets": [ + ["{", "}"], + ["[", "]"], + ["(", ")"] + ], + "autoClosingPairs": [ + { "open": "{", "close": "}" }, + { "open": "[", "close": "]" }, + { "open": "(", "close": ")" }, + { "open": "\"", "close": "\"" } + ], + "surroundingPairs": [ + { "open": "{", "close": "}" }, + { "open": "[", "close": "]" }, + { "open": "(", "close": ")" }, + { "open": "\"", "close": "\"" } + ] +} diff --git a/package.json b/package.json index e7a2689b4bf..4a6f074d5b1 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,8 @@ "smoke:orcad-terminal": "node config/scripts/ensure-native-runtime.mjs --runtime=node && pnpm run build:orcad && node config/scripts/runtime-serve-terminal-smoke.mjs --target orcad", "smoke:serve-terminal": "node config/scripts/runtime-serve-terminal-smoke.mjs", "check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs", + "generate:plugin-schemas": "node config/scripts/generate-plugin-schemas.mjs", + "validate:plugin": "node config/scripts/validate-plugin-files.mjs", "generate:rpc-params-catalog": "node config/scripts/generate-rpc-params-catalog.mjs", "verify:rpc-params-catalog": "node config/scripts/generate-rpc-params-catalog.mjs --check", "generate:acp-protocol": "node config/scripts/acp/generate-protocol.mjs", @@ -298,6 +300,7 @@ "@xterm/addon-web-links": "0.13.0-beta.301", "@xterm/addon-webgl": "0.20.0-beta.300", "@xterm/xterm": "6.1.0-beta.304", + "ajv": "8.20.0", "bun": "1.4.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7e5abb17a9..8e6047af570 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -432,6 +432,9 @@ importers: '@xterm/xterm': specifier: 6.1.0-beta.304 version: 6.1.0-beta.304(patch_hash=6a96a556cb32825d4084043f7e18fe16203686d8352affd2f01613c0b464a6ed) + ajv: + specifier: 8.20.0 + version: 8.20.0 bun: specifier: 1.4.4 version: 1.4.4 diff --git a/resources/plugins/launch/bundled-plugins.json b/resources/plugins/launch/bundled-plugins.json index 3bc0d4e1d12..2e7bed4e6ad 100644 --- a/resources/plugins/launch/bundled-plugins.json +++ b/resources/plugins/launch/bundled-plugins.json @@ -1,10 +1,4 @@ { "version": 1, - "plugins": [ - { - "pluginKey": "stablyai.orca-navigation-shortcuts", - "path": "stablyai.orca-navigation-shortcuts", - "contentHash": "ce3a146bae9e121a18cb86a710973422be749a25da5a1c21a8911e2e98cc3a77" - } - ] + "plugins": [] } diff --git a/resources/plugins/launch/orca-marketplace.json b/resources/plugins/launch/orca-marketplace.json index 1088bdcd22f..8c34069a90b 100644 --- a/resources/plugins/launch/orca-marketplace.json +++ b/resources/plugins/launch/orca-marketplace.json @@ -2,16 +2,6 @@ "name": "Orca Official Plugins", "owner": "stablyai", "plugins": [ - { - "id": "stablyai.orca-portuguese", - "source": { - "kind": "git", - "url": "https://github.com/stablyai/orca-portuguese.git", - "ref": "v1.0.0" - }, - "description": "Brazilian Portuguese translations for common Orca navigation.", - "categories": ["languages", "official"] - }, { "id": "stablyai.orca-multipass-recipes", "source": { @@ -21,16 +11,6 @@ }, "description": "A reviewed starter lifecycle for disposable Multipass workspaces.", "categories": ["vm-recipes", "official"] - }, - { - "id": "stablyai.orca-navigation-shortcuts", - "source": { - "kind": "git", - "url": "https://github.com/stablyai/orca-navigation-shortcuts.git", - "ref": "v1.0.0" - }, - "description": "Command aliases and optional shortcuts for frequent Orca views.", - "categories": ["keybindings", "official"] } ] } diff --git a/resources/plugins/launch/stablyai.orca-navigation-shortcuts/orca-plugin.json b/resources/plugins/launch/stablyai.orca-navigation-shortcuts/orca-plugin.json deleted file mode 100644 index 6310d4e5476..00000000000 --- a/resources/plugins/launch/stablyai.orca-navigation-shortcuts/orca-plugin.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "manifestVersion": 1, - "id": "orca-navigation-shortcuts", - "publisher": "stablyai", - "name": "Orca Navigation Shortcuts", - "version": "1.0.0", - "description": "Command aliases and optional shortcuts for frequent Orca views.", - "repository": "https://github.com/stablyai/orca-navigation-shortcuts", - "engines": { "orca": ">=1.4.0" }, - "pluginApi": 1, - "contributes": { - "commands": [ - { "id": "open-tasks", "title": "Open Tasks", "context": "global", "action": "view.tasks" }, - { - "id": "toggle-search", - "title": "Toggle Search", - "context": "global", - "action": "sidebar.search.toggle" - }, - { - "id": "toggle-source-control", - "title": "Toggle Source Control", - "context": "global", - "action": "sidebar.sourceControl.toggle" - } - ], - "keybindings": [ - { "command": "open-tasks", "key": "Mod+Alt+T", "when": "global" }, - { "command": "toggle-search", "key": "Mod+Alt+F", "when": "global" }, - { "command": "toggle-source-control", "key": "Mod+Alt+G", "when": "global" } - ] - }, - "capabilities": [] -} diff --git a/resources/plugins/launch/stablyai.orca-portuguese/locales/pt-BR.json b/resources/plugins/launch/stablyai.orca-portuguese/locales/pt-BR.json deleted file mode 100644 index 27945d1be25..00000000000 --- a/resources/plugins/launch/stablyai.orca-portuguese/locales/pt-BR.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "settings": { - "appearance": { - "language": { - "title": "Idioma", - "description": "Escolha o idioma usado na interface do Orca.", - "system": "Sistema", - "english": "Inglês", - "chinese": "Chinês simplificado", - "korean": "Coreano", - "japanese": "Japonês", - "spanish": "Espanhol" - } - } - }, - "menu": { - "checkForUpdates": "Verificar atualizações...", - "settings": "Configurações", - "file": "Arquivo", - "exit": "Sair", - "edit": "Editar", - "appearance": "Aparência", - "view": "Visualizar", - "reload": "Recarregar", - "window": "Janela", - "help": "Ajuda", - "paste": "Colar" - }, - "tray": { - "openOrca": "Abrir o Orca", - "quit": "Encerrar", - "minimizeNotice": { - "body": "O Orca continua em execução na bandeja do sistema" - } - } -} diff --git a/resources/plugins/launch/stablyai.orca-portuguese/orca-plugin.json b/resources/plugins/launch/stablyai.orca-portuguese/orca-plugin.json deleted file mode 100644 index d32be4b38d9..00000000000 --- a/resources/plugins/launch/stablyai.orca-portuguese/orca-plugin.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "manifestVersion": 1, - "id": "orca-portuguese", - "publisher": "stablyai", - "name": "Português do Brasil", - "version": "1.0.0", - "description": "Brazilian Portuguese translations for common Orca navigation.", - "repository": "https://github.com/stablyai/orca-portuguese", - "engines": { "orca": ">=1.4.0" }, - "pluginApi": 1, - "contributes": { - "languagePacks": [{ "locale": "pt-BR", "path": "locales/pt-BR.json" }] - }, - "capabilities": [] -} diff --git a/src/main/i18n/main-i18n-lazy-locale.test.ts b/src/main/i18n/main-i18n-lazy-locale.test.ts index 544161e386d..70f263f2247 100644 --- a/src/main/i18n/main-i18n-lazy-locale.test.ts +++ b/src/main/i18n/main-i18n-lazy-locale.test.ts @@ -20,7 +20,7 @@ import { UI_LANGUAGE_SPANISH } from '../../shared/ui-language' import { mainI18n, setMainPluginLanguagePacks, setMainUiLanguage, translateMain } from './main-i18n' -import { pluginLanguageResourceId } from '../../shared/plugins/plugin-language-pack-artifact' +import { pluginLanguageResourceId } from '../../shared/plugins/artifacts/plugin-language-pack-artifact' describe('main-i18n lazy locale loading', () => { afterEach(() => vi.restoreAllMocks()) @@ -112,14 +112,15 @@ describe('main-i18n lazy locale loading', () => { }) it('loads a contributed catalog for native menus and dialogs', async () => { - const id = 'plugin:orca-samples.portuguese/pt-BR' as const + const id = 'plugin:samples.portuguese/pt-BR' as const setMainPluginLanguagePacks([ { id, resourceLanguage: pluginLanguageResourceId(id), - pluginKey: 'orca-samples.portuguese', + pluginKey: 'samples.portuguese', locale: 'pt-BR', - catalog: { menu: { file: 'Arquivo Orca' } } + catalog: { menu: { file: 'Arquivo Orca' } }, + coverage: 100 } ]) @@ -138,6 +139,7 @@ describe('main-i18n lazy locale loading', () => { id, resourceLanguage: pluginLanguageResourceId(id), pluginKey, + coverage: 100, locale: 'pt-BR', catalog: { menu: { file: pluginKey } } } @@ -160,6 +162,7 @@ describe('main-i18n lazy locale loading', () => { id, resourceLanguage: pluginLanguageResourceId(id), pluginKey: 'orca-samples.russian', + coverage: 100, locale: 'ru-RU', catalog: { terminals_one: '{{count}} one', diff --git a/src/main/index.ts b/src/main/index.ts index 75e19cffca9..7a7d599bd54 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,5 +1,5 @@ import { app, clipboard, dialog, type BrowserWindow } from 'electron' -import { parseSkillShareId } from '../shared/skill-share-link' +import { isRoutedOrcaDeepLink } from './startup/orca-deep-link-url' import { createMacAppActivationHandler } from './window/macos-app-activation' import { isBackgroundLaunch, isOrcadSidecarLaunch } from './window/foreground-activation-policy' import { createDesktopHandoff, desktopHandoffArguments } from './window/macos-desktop-handoff' @@ -38,6 +38,7 @@ import { wakeRememberedWslHosts } from './wsl/wsl-host-early-wake' import { wslHostsServing } from './wsl/wsl-host-gate' import { configureWslHostsGateFromDisk } from './wsl/wsl-host-gate-startup' import { ignoreInheritedWslHostsGateState } from '../shared/wsl-host-internal-gate' +import { registerOrcaProtocolClient } from './startup/orca-protocol-registration' function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow { return openMainWindowController(options) @@ -57,6 +58,9 @@ function requestDesktopActivation(argv: readonly string[] = []): void { state.skillShareDeepLinks.capture(argv, (shareId) => { state.mainWindow?.webContents.send('ui:openSkillShare', shareId) }) + state.pluginInstallLinks.capture(argv, (link) => { + state.mainWindow?.webContents.send('ui:openPluginInstallLink', link) + }) state.osOpenedDocuments.capture(argv, publishOsOpenedDocuments) // Duplicate serve launches must not activate a desktop on a headless server (#11935). if (!shouldActivateDesktopForSecondInstance(argv)) { @@ -123,7 +127,7 @@ if (preflightReady) { wakeRememberedWslHosts(app.getPath('userData')) } app.on('open-url', (event, url) => { - if (!parseSkillShareId(url)) { + if (!isRoutedOrcaDeepLink(url)) { return } event.preventDefault() @@ -140,7 +144,12 @@ if (preflightReady) { requestDesktopActivation() } }) + registerOrcaProtocolClient(app, { + platform: process.platform, + desktopLaunch: !state.isServeMode && !isBackgroundLaunch() && !isOrcadSidecarLaunch() + }) state.skillShareDeepLinks.capture(process.argv) + state.pluginInstallLinks.capture(process.argv) // The first renderer pulls on mount; nothing listens this early. state.osOpenedDocuments.capture(process.argv) registerMainProcessIpcHandlers() diff --git a/src/main/ipc/ephemeral-vm.test.ts b/src/main/ipc/ephemeral-vm.test.ts index 3faebeb0cca..3c566b83e25 100644 --- a/src/main/ipc/ephemeral-vm.test.ts +++ b/src/main/ipc/ephemeral-vm.test.ts @@ -225,11 +225,11 @@ describe('registerEphemeralVmHandlers', () => { ) const pluginService = pluginServiceWithRecipes([ { - pluginKey: 'orca-samples.recipes', + pluginKey: 'samples.recipes', recipe: { id: 'shared', name: 'Plugin Shared', create: 'plugin-shared' } }, { - pluginKey: 'orca-samples.recipes', + pluginKey: 'samples.recipes', recipe: { id: 'global', name: 'Plugin Global', create: 'plugin-global' } } ]) @@ -265,7 +265,7 @@ describe('registerEphemeralVmHandlers', () => { writeFileSync(destroyPath, "require('fs').writeFileSync('plugin-cleaned.txt', 'yes')") const registrations = [ { - pluginKey: 'orca-samples.recipes', + pluginKey: 'samples.recipes', recipe: { id: 'plugin-cloud', name: 'Plugin Cloud', @@ -316,7 +316,7 @@ describe('registerEphemeralVmHandlers', () => { }) const pluginService = pluginServiceWithRecipes([ { - pluginKey: 'orca-samples.recipes', + pluginKey: 'samples.recipes', recipe: { id: 'shared-id', name: 'Later Plugin Recipe', diff --git a/src/main/ipc/filesystem/filesystem-write-bytes.test.ts b/src/main/ipc/filesystem/filesystem-write-bytes.test.ts new file mode 100644 index 00000000000..cac8bd7f824 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-write-bytes.test.ts @@ -0,0 +1,90 @@ +import { createHash } from 'node:crypto' +import { mkdtemp, readdir, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { registerFilesystemWriteHandlers } from './filesystem-write-handlers' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +const handlers = vi.hoisted(() => new Map unknown>()) +vi.mock('electron', () => ({ + ipcMain: { + handle: (name: string, handler: (_event: unknown, args: unknown) => unknown) => + handlers.set(name, handler) + }, + shell: {} +})) +vi.mock('../local-file-access-resolution', () => ({ + resolveLocalWriteRequestPath: async (path: string) => path +})) +vi.mock('./filesystem-mutations', () => ({ registerFilesystemMutationHandlers: vi.fn() })) +vi.mock('../agent-session-attachment-upload-ipc', () => ({ + registerAgentSessionAttachmentUploadHandlers: vi.fn() +})) + +const sha256 = (bytes: Uint8Array): string => createHash('sha256').update(bytes).digest('hex') +const loaded = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0xff, 0x00]) +const edited = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0x80, 0xfe, 0x00, 0x01]) + +let root = '' +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-bytes-ipc-')) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization is stubbed, and no other context member is used by this handler. + registerFilesystemWriteHandlers({ store: {} } as FilesystemHandlerContext) +}) +afterEach(async () => { + await rm(root, { recursive: true, force: true }) + handlers.clear() +}) + +function writeBytes(filePath: string, bytes: Uint8Array, expectedSha256?: string) { + return handlers.get('fs:writeFileBytes')!(null, { + filePath, + bytes, + diskPrecondition: expectedSha256 ? { expectedSha256, allowMissing: true } : {} + }) +} + +it('replaces the bytes atomically when the file still holds what the editor loaded', async () => { + const filePath = join(root, 'book.xlsx') + await writeFile(filePath, loaded) + await expect(writeBytes(filePath, edited, sha256(loaded))).resolves.toEqual({ ok: true }) + expect(new Uint8Array(await readFile(filePath))).toEqual(edited) + expect(await readdir(root)).toEqual(['book.xlsx']) +}) + +it('refuses a stale baseline and leaves the newer disk bytes alone', async () => { + const filePath = join(root, 'book.xlsx') + await writeFile(filePath, new Uint8Array([1, 2, 3])) + await expect(writeBytes(filePath, edited, sha256(loaded))).resolves.toEqual({ + ok: false, + diskConflict: 'changed' + }) + expect(new Uint8Array(await readFile(filePath))).toEqual(new Uint8Array([1, 2, 3])) +}) + +it.skipIf(process.platform === 'win32')( + 'writes through a symlink to its target and keeps the file mode', + async () => { + const target = join(root, 'real.bin') + const link = join(root, 'link.bin') + await writeFile(target, loaded, { mode: 0o640 }) + await symlink(target, link) + await expect(writeBytes(link, edited, sha256(loaded))).resolves.toEqual({ ok: true }) + expect(new Uint8Array(await readFile(target))).toEqual(edited) + expect((await stat(target)).mode & 0o777).toBe(0o640) + } +) + +it('recreates a deleted file only when the save allows it', async () => { + const filePath = join(root, 'gone.bin') + await expect( + handlers.get('fs:writeFileBytes')!(null, { + filePath, + bytes: edited, + diskPrecondition: { expectedSha256: sha256(loaded) } + }) + ).resolves.toEqual({ ok: false, diskConflict: 'missing' }) + await expect(writeBytes(filePath, edited, sha256(loaded))).resolves.toEqual({ ok: true }) + expect(new Uint8Array(await readFile(filePath))).toEqual(edited) +}) diff --git a/src/main/ipc/filesystem/filesystem-write-handlers.ts b/src/main/ipc/filesystem/filesystem-write-handlers.ts index 765696b83f3..59982d4b041 100644 --- a/src/main/ipc/filesystem/filesystem-write-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-write-handlers.ts @@ -1,5 +1,5 @@ import { ipcMain, shell } from 'electron' -import { lstat, rm } from 'node:fs/promises' +import { lstat, rm, writeFile } from 'node:fs/promises' import type { SshMutationExpectation } from '../../../shared/ssh-types' import { assertSshMutationExpectation } from '../../ssh/connection/ssh-connection-generation' import { SshHostNotServedError } from '../../providers/ssh-host-not-served-error' @@ -18,6 +18,8 @@ import type { FileWriteResult } from '../../../shared/file-write-disk-precondition' import { writeLocalTextFileWithDiskPrecondition } from './local-disk-write-precondition' +import { replaceLocalFileBytesIfUnchanged } from './local-disk-bytes-replace' +import type { FileBytesWritePrecondition } from '../../../shared/file-bytes-write' import { windowsFsPath } from '../../wsl/wsl-unc-drive-fs-path' import { TRASH_DELETE_FAILED_MESSAGE } from '../../../shared/trash-delete-failure' @@ -60,6 +62,39 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex } ) + ipcMain.handle( + 'fs:writeFileBytes', + async ( + _event, + args: { + filePath: string + bytes: Uint8Array + connectionId?: string + access?: LocalFileAccess + diskPrecondition: FileBytesWritePrecondition + } & SshMutationExpectation + ): Promise => { + assertSshMutationExpectation( + args.connectionId, + args.expectedSshTargetId, + args.expectedSshConnectionGeneration, + args.expectedExecutionHostId + ) + if (args.connectionId) { + throw new SshHostNotServedError(args.connectionId) + } + if (!(args.bytes instanceof Uint8Array)) { + throw new Error('Missing file content') + } + const filePath = await resolveLocalWriteRequestPath(args.filePath, args.access, store) + return replaceLocalFileBytesIfUnchanged( + filePath, + (tempPath) => writeFile(tempPath, args.bytes), + args.diskPrecondition ?? {} + ) + } + ) + ipcMain.handle( 'fs:deletePath', async ( diff --git a/src/main/ipc/filesystem/local-disk-bytes-replace.ts b/src/main/ipc/filesystem/local-disk-bytes-replace.ts new file mode 100644 index 00000000000..371a74e8233 --- /dev/null +++ b/src/main/ipc/filesystem/local-disk-bytes-replace.ts @@ -0,0 +1,71 @@ +import { createHash, randomUUID } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { chmod, realpath, rm, stat } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' +import type { FileBytesWritePrecondition } from '../../../shared/file-bytes-write' +import { FILE_WRITE_OK, type FileWriteResult } from '../../../shared/file-write-disk-precondition' +import { renameFileWithWindowsRetryAsync } from '../../../shared/windows-retry-file-operations' +import { windowsFsPath } from '../../wsl/wsl-unc-drive-fs-path' +import { isENOENT } from './filesystem-path-containment' +import { runLocalEditorWrite } from './local-disk-write-precondition' +import { assertLocalWriteTargetIsRegularFile } from './local-regular-file-read' + +async function sha256OfFile(filePath: string): Promise { + const hash = createHash('sha256') + try { + for await (const chunk of createReadStream(filePath)) { + hash.update(chunk) + } + } catch (error) { + if (isENOENT(error)) { + return null + } + throw error + } + return hash.digest('hex') +} + +/** + * Replaces a file's bytes atomically (sibling temp file, then rename) unless it no longer holds + * the bytes the editor loaded. A symlink is followed, so its target is replaced, not the link. + */ +export async function replaceLocalFileBytesIfUnchanged( + requestedPath: string, + writeTemp: (tempPath: string) => Promise, + precondition: FileBytesWritePrecondition +): Promise { + const filePath = windowsFsPath(requestedPath) + return runLocalEditorWrite(filePath, async () => { + const target = await realpath(filePath).catch((error: unknown) => { + if (isENOENT(error)) { + return filePath + } + throw error + }) + if (precondition.expectedSha256 !== undefined) { + const current = await sha256OfFile(target) + if (current === null && precondition.allowMissing !== true) { + return { ok: false, diskConflict: 'missing' } + } + if (current !== null && current !== precondition.expectedSha256) { + return { ok: false, diskConflict: 'changed' } + } + } + await assertLocalWriteTargetIsRegularFile(target) + const mode = await stat(target).then( + (stats) => stats.mode & 0o7777, + () => null + ) + const temp = join(dirname(target), `.${basename(target)}.orca-save-${randomUUID()}`) + try { + await writeTemp(temp) + if (mode !== null) { + await chmod(temp, mode) + } + await renameFileWithWindowsRetryAsync(temp, target) + } finally { + await rm(temp, { force: true }).catch(() => undefined) + } + return FILE_WRITE_OK + }) +} diff --git a/src/main/ipc/filesystem/local-disk-write-precondition.ts b/src/main/ipc/filesystem/local-disk-write-precondition.ts index 246db0abb86..95c5b8f4888 100644 --- a/src/main/ipc/filesystem/local-disk-write-precondition.ts +++ b/src/main/ipc/filesystem/local-disk-write-precondition.ts @@ -178,6 +178,17 @@ export async function writeLocalTextFileWithDiskPrecondition( })) } +/** Runs one compare-and-write under the path and inode locks every editor save of the file shares. */ +export function runLocalEditorWrite( + filePath: string, + compareAndWrite: () => Promise +): Promise { + return runSerializedLocalFileWrite(filePath, compareAndWrite, () => ({ + ok: false, + diskConflict: 'changed' + })) +} + /** Every in-process writer that checks a file before replacing it shares this lock, so no two pass the check together. */ export async function runSerializedLocalFileWrite( filePath: string, diff --git a/src/main/ipc/plugin-marketplaces.test.ts b/src/main/ipc/plugin-marketplaces.test.ts index b1badba09c4..a43cb1bd1e4 100644 --- a/src/main/ipc/plugin-marketplaces.test.ts +++ b/src/main/ipc/plugin-marketplaces.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { PluginMarketplaceInstaller } from '../plugins/plugin-marketplace-installer' -import type { PluginMarketplaceService } from '../plugins/plugin-marketplace-service' +import { getDefaultSettings } from '../../shared/constants' +import type { PluginMarketplaceInstaller } from '../plugins/catalog/plugin-marketplace-installer' +import type { PluginMarketplaceService } from '../plugins/catalog/plugin-marketplace-service' import type { PluginService } from '../plugins/plugin-service' type IpcHandler = (event: unknown, args?: unknown) => unknown @@ -16,19 +17,21 @@ import { const SOURCE_ID = 'a'.repeat(32) const MARKETPLACE_COMMIT = 'b'.repeat(40) const PLUGIN_COMMIT = 'c'.repeat(40) -const PLUGIN_KEY = 'orca-samples.demo' +const PLUGIN_KEY = 'samples.demo' let handlers: Map function createServices(): PluginMarketplaceHandlerServices { return { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handlers read only these members. marketplace: { listSources: vi.fn().mockResolvedValue([{ id: SOURCE_ID }]), addSource: vi.fn().mockResolvedValue({ id: SOURCE_ID }), removeSource: vi.fn().mockResolvedValue(true), refreshSource: vi.fn().mockResolvedValue({ id: SOURCE_ID }), refreshAll: vi.fn().mockResolvedValue([{ id: SOURCE_ID }]), - listPlugins: vi.fn().mockResolvedValue([{ pluginKey: PLUGIN_KEY }]) + listPlugins: vi.fn().mockResolvedValue([{ pluginKey: PLUGIN_KEY }]), + seedOfficialSource: vi.fn().mockResolvedValue({ id: SOURCE_ID }) } as unknown as PluginMarketplaceService, installer: { preview: vi.fn().mockResolvedValue({ pluginKey: PLUGIN_KEY }), @@ -40,7 +43,14 @@ function createServices(): PluginMarketplaceHandlerServices { } function createPluginService(): PluginService { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handlers read only these members. return { + options: { + isPluginSystemEnabled: () => true, + getPluginConsents: () => ({}), + getDisabledPlugins: () => [] + }, + findValidPlugin: vi.fn(() => null), deactivatePlugin: vi.fn().mockResolvedValue(undefined), refresh: vi.fn().mockResolvedValue(undefined) } as unknown as PluginService @@ -64,7 +74,12 @@ beforeEach(() => { describe('plugin marketplace IPC authority', () => { it('validates every mutating or plugin-selecting request strictly', async () => { - registerPluginMarketplaceHandlers(createPluginService(), createServices()) + registerPluginMarketplaceHandlers( + createPluginService(), + createServices(), + () => getDefaultSettings('/tmp'), + vi.fn() + ) await expect( invoke('plugins:addMarketplace', { @@ -107,7 +122,12 @@ describe('plugin marketplace IPC authority', () => { it('dispatches source listing, add, removal, and refresh operations', async () => { const services = createServices() - registerPluginMarketplaceHandlers(createPluginService(), services) + registerPluginMarketplaceHandlers( + createPluginService(), + services, + () => getDefaultSettings('/tmp'), + vi.fn() + ) const source = { kind: 'git' as const, url: 'https://example.com/marketplace.git', @@ -122,6 +142,8 @@ describe('plugin marketplace IPC authority', () => { await invoke('plugins:listMarketplacePlugins') expect(services.marketplace.listSources).toHaveBeenCalledTimes(2) + // The startup seed is deferred, so opening the marketplace seeds the official catalog. + expect(services.marketplace.seedOfficialSource).toHaveBeenCalledTimes(2) expect(services.marketplace.addSource).toHaveBeenCalledWith(source) expect(services.marketplace.removeSource).toHaveBeenCalledWith(SOURCE_ID) expect(services.marketplace.refreshSource).toHaveBeenCalledWith(SOURCE_ID) @@ -132,7 +154,12 @@ describe('plugin marketplace IPC authority', () => { it('refreshes discovery only after a successful install', async () => { const services = createServices() const pluginService = createPluginService() - registerPluginMarketplaceHandlers(pluginService, services) + registerPluginMarketplaceHandlers( + pluginService, + services, + () => getDefaultSettings('/tmp'), + vi.fn() + ) const preview = { marketplaceSourceId: SOURCE_ID, marketplaceCommit: MARKETPLACE_COMMIT, @@ -153,7 +180,12 @@ describe('plugin marketplace IPC authority', () => { it('deactivates before rollback and refreshes discovery only on success', async () => { const services = createServices() const pluginService = createPluginService() - registerPluginMarketplaceHandlers(pluginService, services) + registerPluginMarketplaceHandlers( + pluginService, + services, + () => getDefaultSettings('/tmp'), + vi.fn() + ) await invoke('plugins:rollbackMarketplacePlugin', { pluginKey: PLUGIN_KEY }) @@ -169,4 +201,102 @@ describe('plugin marketplace IPC authority', () => { await invoke('plugins:rollbackMarketplacePlugin', { pluginKey: PLUGIN_KEY }) expect(pluginService.refresh).not.toHaveBeenCalled() }) + + it('turns on only content-only plugins, and only when asked, with main reading the manifest', async () => { + const services = createServices() + services.installer.install = vi + .fn() + .mockResolvedValue({ ok: true, pluginKey: PLUGIN_KEY, consentFingerprint: 'sha256-fp' }) + const execution = { value: 'data' } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only refresh and findValidPlugin. + const pluginService = { + ...createPluginService(), + findValidPlugin: vi.fn(() => ({ + pluginKey: PLUGIN_KEY, + consentFingerprint: 'sha256-fp', + manifest: { manifestVersion: 2, execution: execution.value } + })) + } as unknown as PluginService + const approve = vi.fn().mockResolvedValue(undefined) + registerPluginMarketplaceHandlers( + pluginService, + services, + () => getDefaultSettings('/tmp'), + approve + ) + const handler = handlers.get('plugins:installMarketplacePlugin') + const identity = { + marketplaceSourceId: SOURCE_ID, + marketplaceCommit: MARKETPLACE_COMMIT, + pluginKey: PLUGIN_KEY, + resolvedCommit: PLUGIN_COMMIT + } + const event = { sender: { id: 7 } } + + await expect(handler?.(event, identity)).resolves.not.toHaveProperty('needsReview') + expect(approve).not.toHaveBeenCalled() + await expect(handler?.(event, { ...identity, enableIfData: true })).resolves.toMatchObject({ + needsReview: false + }) + expect(approve).toHaveBeenCalledWith(PLUGIN_KEY, 'sha256-fp', 7) + + approve.mockClear() + await expect( + handler?.(event, { ...identity, enableIfData: true, expectedFingerprint: 'sha256-other' }) + ).resolves.toMatchObject({ needsReview: true }) + expect(approve).not.toHaveBeenCalled() + await expect( + handler?.(event, { ...identity, enableIfData: true, expectedFingerprint: 'sha256-fp' }) + ).resolves.toMatchObject({ needsReview: false }) + expect(approve).toHaveBeenCalledWith(PLUGIN_KEY, 'sha256-fp', 7) + + approve.mockClear() + execution.value = 'declarative' + await expect(handler?.(event, { ...identity, enableIfData: true })).resolves.toMatchObject({ + needsReview: true + }) + expect(approve).not.toHaveBeenCalled() + }) + + it('sends an update from a moved repository to review even when one-click was asked', async () => { + const services = createServices() + services.installer.install = vi.fn().mockResolvedValue({ + ok: true, + pluginKey: PLUGIN_KEY, + consentFingerprint: 'sha256-fp', + repositoryMoved: true + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only refresh and findValidPlugin. + const pluginService = { + ...createPluginService(), + findValidPlugin: vi.fn(() => ({ + pluginKey: PLUGIN_KEY, + consentFingerprint: 'sha256-fp', + manifest: { manifestVersion: 2, execution: 'data' } + })) + } as unknown as PluginService + const approve = vi.fn().mockResolvedValue(undefined) + registerPluginMarketplaceHandlers( + pluginService, + services, + () => getDefaultSettings('/tmp'), + approve + ) + const identity = { + marketplaceSourceId: SOURCE_ID, + marketplaceCommit: MARKETPLACE_COMMIT, + pluginKey: PLUGIN_KEY, + resolvedCommit: PLUGIN_COMMIT + } + const handler = handlers.get('plugins:installMarketplacePlugin') + for (const args of [ + identity, + { ...identity, enableIfData: true, expectedFingerprint: 'sha256-fp' } + ]) { + await expect(handler?.({ sender: { id: 7 } }, args)).resolves.toMatchObject({ + needsReview: true + }) + } + expect(approve).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/plugin-marketplaces.ts b/src/main/ipc/plugin-marketplaces.ts index 8e27ec24911..86ad257865a 100644 --- a/src/main/ipc/plugin-marketplaces.ts +++ b/src/main/ipc/plugin-marketplaces.ts @@ -1,18 +1,35 @@ +import { + installCatalogReleaseWithApproval, + readPreviewAccessGrowth +} from '../plugins/catalog/plugin-update-approval' import { ipcMain } from 'electron' import { z } from 'zod' -import { PLUGIN_COMMIT_PATTERN } from '../../shared/plugins/plugin-install-lockfile' -import { isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest' -import { pluginMarketplaceGitSourceSchema } from '../../shared/plugins/plugin-marketplace' -import type { PluginMarketplaceInstaller } from '../plugins/plugin-marketplace-installer' -import type { PluginMarketplaceService } from '../plugins/plugin-marketplace-service' -import { PLUGIN_MARKETPLACE_SOURCE_ID_PATTERN } from '../plugins/plugin-marketplace-store' +import { PLUGIN_COMMIT_PATTERN } from '../../shared/plugins/installation/plugin-install-lockfile' +import { isQualifiedPluginKey } from '../../shared/plugins/manifest/plugin-manifest' +import { pluginMarketplaceGitSourceSchema } from '../../shared/plugins/catalog/plugin-marketplace' +import type { PluginMarketplaceInstaller } from '../plugins/catalog/plugin-marketplace-installer' +import type { PluginMarketplaceService } from '../plugins/catalog/plugin-marketplace-service' +import { PLUGIN_MARKETPLACE_SOURCE_ID_PATTERN } from '../plugins/catalog/plugin-marketplace-store' import type { PluginService } from '../plugins/plugin-service' +import type { PluginAutoUpdater } from '../plugins/catalog/plugin-auto-updater' +import type { PluginAutoUpdateState } from '../../shared/plugins/catalog/plugin-update-policy' +import type { GlobalSettings } from '../../shared/global-settings-types' +import { pluginFileTypeSchema } from '../../shared/plugins/contributions/plugin-file-type' +import { findPluginFileTypeSuggestion } from '../plugins/catalog/plugin-file-type-suggestion' export type PluginMarketplaceHandlerServices = { marketplace: PluginMarketplaceService installer: PluginMarketplaceInstaller + autoUpdater?: Pick } +/** The consent write path (applyPluginConsent), bound to the store by the caller. */ +export type PluginApproveConsent = ( + pluginKey: string, + reviewedFingerprint: string, + originWebContentsId: number +) => Promise + const sourceIdSchema = z.string().regex(PLUGIN_MARKETPLACE_SOURCE_ID_PATTERN) const removeMarketplaceSchema = z.strictObject({ sourceId: sourceIdSchema }) const refreshMarketplaceSchema = z.strictObject({ sourceId: sourceIdSchema.optional() }) @@ -22,7 +39,11 @@ const marketplacePluginSchema = z.strictObject({ }) const installMarketplacePluginSchema = marketplacePluginSchema.extend({ marketplaceCommit: z.string().regex(PLUGIN_COMMIT_PATTERN), - resolvedCommit: z.string().regex(PLUGIN_COMMIT_PATTERN) + resolvedCommit: z.string().regex(PLUGIN_COMMIT_PATTERN), + /** Ask main to approve in the same step if the installed plugin is content only. */ + enableIfData: z.literal(true).optional(), + /** The preview's consentFingerprint: one-click approves only these exact bytes. */ + expectedFingerprint: z.string().min(1).max(256).optional() }) const installedPluginSchema = z.strictObject({ pluginKey: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key') @@ -30,9 +51,37 @@ const installedPluginSchema = z.strictObject({ export function registerPluginMarketplaceHandlers( pluginService: PluginService, - services: PluginMarketplaceHandlerServices + services: PluginMarketplaceHandlerServices, + getSettings: () => GlobalSettings, + approveConsent: PluginApproveConsent ): void { - ipcMain.handle('plugins:listMarketplaces', () => services.marketplace.listSources()) + // Why cached only: this runs on every file open, which must never wait on a catalog fetch. + ipcMain.handle( + 'plugins:suggestForFileType', + async (_event, fileType: unknown, purpose: unknown) => { + const parsed = pluginFileTypeSchema.safeParse(fileType) + if (!parsed.success) { + return null + } + return findPluginFileTypeSuggestion({ + fileType: parsed.data, + purpose: purpose === 'editor' ? 'editor' : 'open', + settings: getSettings(), + listings: await services.marketplace.listCachedPlugins(), + installed: pluginService.getDiscovered() + }) + } + ) + // Startup defers the official seed; a marketplace view must not wait for that timer. + const ensureOfficialSeed = (): void => { + if (getSettings().pluginSystemEnabled === true) { + void services.marketplace.seedOfficialSource().catch(() => undefined) + } + } + ipcMain.handle('plugins:listMarketplaces', () => { + ensureOfficialSeed() + return services.marketplace.listSources() + }) ipcMain.handle('plugins:addMarketplace', async (_event, args: unknown) => { const source = pluginMarketplaceGitSourceSchema.parse(args) return services.marketplace.addSource(source) @@ -48,21 +97,43 @@ export function registerPluginMarketplaceHandlers( ? [await services.marketplace.refreshSource(sourceId)] : services.marketplace.refreshAll() }) - ipcMain.handle('plugins:listMarketplacePlugins', () => services.marketplace.listPlugins()) + ipcMain.handle('plugins:listMarketplacePlugins', () => { + ensureOfficialSeed() + return services.marketplace.listPlugins() + }) ipcMain.handle('plugins:previewMarketplacePlugin', async (_event, args: unknown) => { const parsed = marketplacePluginSchema.parse(args) return services.installer.preview(parsed.marketplaceSourceId, parsed.pluginKey) }) - ipcMain.handle('plugins:installMarketplacePlugin', async (_event, args: unknown) => { - const result = await services.installer.install(installMarketplacePluginSchema.parse(args)) - if (result.ok) { - await pluginService.refresh() - } - return result + ipcMain.handle('plugins:installMarketplacePlugin', async (event, args: unknown) => { + const { enableIfData, expectedFingerprint, ...identity } = + installMarketplacePluginSchema.parse(args) + return installCatalogReleaseWithApproval({ + pluginKey: identity.pluginKey, + pluginService, + install: () => services.installer.install(identity), + approve: (pluginKey, reviewedFingerprint) => + approveConsent(pluginKey, reviewedFingerprint, event.sender.id), + ...(enableIfData ? { enableIfData: { expectedFingerprint } } : {}) + }) }) ipcMain.handle('plugins:previewMarketplaceUpdate', async (_event, args: unknown) => { const { pluginKey } = installedPluginSchema.parse(args) - return services.installer.previewInstalledUpdate(pluginKey) + const preview = await services.installer.previewInstalledUpdate(pluginKey) + const installed = pluginService.findValidPlugin(pluginKey) + if (!installed) { + return preview + } + return { ...preview, accessGrowth: await readPreviewAccessGrowth(installed, preview) } + }) + ipcMain.handle( + 'plugins:getAutoUpdateState', + (): PluginAutoUpdateState => + services.autoUpdater?.getState() ?? { updated: {}, needsReview: {} } + ) + ipcMain.handle('plugins:getRollbackVersion', (_event, args: unknown) => { + const { pluginKey } = installedPluginSchema.parse(args) + return services.installer.rollbackVersion(pluginKey) }) ipcMain.handle('plugins:rollbackMarketplacePlugin', async (_event, args: unknown) => { const { pluginKey } = installedPluginSchema.parse(args) diff --git a/src/main/ipc/plugins-panel-document-lifetime.test.ts b/src/main/ipc/plugins-panel-document-lifetime.test.ts index 8646cc6790a..e4da1913ffb 100644 --- a/src/main/ipc/plugins-panel-document-lifetime.test.ts +++ b/src/main/ipc/plugins-panel-document-lifetime.test.ts @@ -4,13 +4,13 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { z } from 'zod' -import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' -import { panelSessionTokenSchema } from '../../shared/plugins/plugin-panel-bridge' +import { pluginManifestSchema } from '../../shared/plugins/manifest/plugin-manifest' +import { panelSessionTokenSchema } from '../../shared/plugins/frames/plugin-frame-bridge' import type { Store } from '../persistence' import { PluginContentVerifier } from '../plugins/plugin-content-integrity' import type { ValidDiscoveredPlugin } from '../plugins/plugin-discovery' -import { PluginPanelController } from '../plugins/plugin-panel-controller' -import { PluginPanelSessions } from '../plugins/plugin-panel-sessions' +import { PluginPanelController } from '../plugins/panels/plugin-panel-controller' +import { PluginPanelSessions } from '../plugins/panels/plugin-panel-sessions' import type { PluginService } from '../plugins/plugin-service' const mocks = vi.hoisted(() => ({ diff --git a/src/main/ipc/plugins.test.ts b/src/main/ipc/plugins.test.ts index cdbc8082e6f..d25c568e029 100644 --- a/src/main/ipc/plugins.test.ts +++ b/src/main/ipc/plugins.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { PluginLockfile } from '../../shared/plugins/plugin-install-lockfile' +import type { PluginLockfile } from '../../shared/plugins/installation/plugin-install-lockfile' import type { PluginService } from '../plugins/plugin-service' import type { Store } from '../persistence' @@ -23,19 +23,19 @@ beforeEach(() => { describe('plugin consent IPC schema', () => { it('requires the fingerprint reviewed by the caller', () => { expect(() => - parsePluginConsentArgs({ pluginKey: 'orca-samples.demo', decision: 'approve' }) + parsePluginConsentArgs({ pluginKey: 'samples.demo', decision: 'approve' }) ).toThrow() }) it('accepts an explicit reviewed fingerprint', () => { expect( parsePluginConsentArgs({ - pluginKey: 'orca-samples.demo', + pluginKey: 'samples.demo', reviewedFingerprint: 'sha256-reviewed', decision: 'approve' }) ).toEqual({ - pluginKey: 'orca-samples.demo', + pluginKey: 'samples.demo', reviewedFingerprint: 'sha256-reviewed', decision: 'approve' }) diff --git a/src/main/ipc/plugins.ts b/src/main/ipc/plugins.ts index dd2fdb490a9..834526a7899 100644 --- a/src/main/ipc/plugins.ts +++ b/src/main/ipc/plugins.ts @@ -1,29 +1,28 @@ import { pluginThemeSettingsAfterRemoval } from '../../shared/plugin-theme-settings' -import { ipcMain } from 'electron' +import { ipcMain, type IpcMainInvokeEvent } from 'electron' import { z } from 'zod' import type { Store } from '../persistence' import type { OrcaRuntimeService } from '../runtime/orca-runtime' -import type { - PluginPanelActionOutcome, - PluginPanelEntry -} from '../../shared/plugins/plugin-panel-bridge' +import type { PluginFrameEntry } from '../../shared/plugins/frames/plugin-frame-bridge' +import type { PluginHostCallOutcome } from '../../shared/plugins/frames/plugin-protocol' import { getUserPluginsDir, getPluginsDataDir } from '../plugins/plugin-discovery' import { installPluginFromGit, installPluginFromLocalPath, readPluginLockfile, removeInstalledPlugin -} from '../plugins/plugin-install' +} from '../plugins/installation/plugin-install' import { applyPluginConsent, applyPluginEnablement } from '../plugins/plugin-enablement' +import { readApprovedPluginNewFileTemplate } from '../plugins/plugin-new-file-template' import type { PluginService } from '../plugins/plugin-service' -import { bindPluginPanelOwnerLifecycle } from '../plugins/plugin-panel-owner-lifecycle' -import { isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest' -import { pluginConsentRequestSchema } from '../../shared/plugins/plugin-consent-request' -import { normalizePluginIdList } from '../../shared/plugins/plugin-consent-state' +import { bindPluginPanelOwnerLifecycle } from '../plugins/panels/plugin-panel-owner-lifecycle' +import { isQualifiedPluginKey } from '../../shared/plugins/manifest/plugin-manifest' +import { pluginConsentRequestSchema } from '../../shared/plugins/approval/plugin-consent-request' +import { normalizePluginIdList } from '../../shared/plugins/approval/plugin-consent-state' import { isAllowedPluginGitUrl, type PluginLockfile -} from '../../shared/plugins/plugin-install-lockfile' +} from '../../shared/plugins/installation/plugin-install-lockfile' import { registerPluginMarketplaceHandlers, type PluginMarketplaceHandlerServices @@ -43,6 +42,16 @@ const readPanelEntryArgsSchema = z.object({ panelId: z.string().min(1) }) +const readCustomEditorEntryArgsSchema = z.object({ + pluginKey: z.string().min(1), + editorId: z.string().min(1) +}) + +const readNewFileTemplateArgsSchema = z.object({ + pluginKey: z.string().min(1), + newFileId: z.string().min(1) +}) + const invokeCommandArgsSchema = z.object({ pluginKey: z.string().min(1), commandId: z.string().min(1), @@ -69,7 +78,7 @@ const removeArgsSchema = z.object({ const logsArgsSchema = z.object({ pluginKey: z.string().min(1) }) // Preserve import compatibility; runtime RPC uses the Electron-free client-list module. -import { listPluginsForClients } from '../plugins/plugin-client-list' +import { listPluginsForClients, readInstalledPluginManifest } from '../plugins/plugin-client-list' export function canRemoveInstalledPlugin( pluginService: PluginService, @@ -108,18 +117,28 @@ export function registerPluginHandlers( // All handlers must await startup discovery to avoid returning a pre-discovery empty list. ipcMain.handle('plugins:list', async () => listPluginsForClients(pluginService)) - ipcMain.handle('plugins:listLanguagePacks', async () => { - await pluginService.whenReady() - return pluginService.contentPacks.languagePacks.list() - }) - ipcMain.handle('plugins:listThemes', async () => { - await pluginService.whenReady() - return pluginService.contentPacks.themes.list() - }) - ipcMain.handle('plugins:listTerminalThemes', async () => { - await pluginService.whenReady() - return pluginService.contentPacks.terminalThemes.list() + ipcMain.handle('plugins:getInstalledManifest', async (_event, args: unknown) => { + const { pluginKey } = logsArgsSchema.parse(args) + return readInstalledPluginManifest(pluginService, pluginKey) }) + const handleContentPacks = (channel: string, read: (arg: unknown) => unknown): void => { + ipcMain.handle(channel, async (_event, arg: unknown) => { + await pluginService.whenReady() + return read(arg) + }) + } + const { contentPacks } = pluginService + handleContentPacks('plugins:listLanguagePacks', () => contentPacks.languagePacks.list()) + handleContentPacks('plugins:listThemes', () => contentPacks.themes.list()) + handleContentPacks('plugins:listTerminalThemes', () => contentPacks.terminalThemes.list()) + handleContentPacks('plugins:listIconThemes', () => contentPacks.iconThemes.list()) + handleContentPacks('plugins:loadIconTheme', (id) => + typeof id === 'string' ? contentPacks.iconThemes.load(id) : null + ) + handleContentPacks('plugins:listSyntaxes', () => contentPacks.syntaxes.list()) + handleContentPacks('plugins:loadSyntax', (id) => + typeof id === 'string' ? contentPacks.syntaxes.load(id) : null + ) ipcMain.handle('plugins:consent', async (event, args: unknown) => { await pluginService.whenReady() const parsed = parsePluginConsentArgs(args) @@ -148,28 +167,43 @@ export function registerPluginHandlers( }) // Sandboxed iframe srcdoc needs CSP-wrapped HTML contents across IPC. - ipcMain.handle( - 'plugins:readPanelEntry', - async (event, args: unknown): Promise => { - const ownerKey = rendererPanelOwner(event.sender.id) - const ownerLease = bindPluginPanelOwnerLifecycle(event.sender, () => - pluginService.panels.revokeOwner(ownerKey) - ) - await pluginService.whenReady() - const parsed = readPanelEntryArgsSchema.parse(args) - return pluginService.panels.open( - ownerKey, - parsed.pluginKey, - parsed.panelId, - ownerLease.isCurrent - ) - } - ) + const openFrameEntry = async ( + sender: IpcMainInvokeEvent['sender'], + open: (ownerKey: string, isCurrent: () => boolean) => Promise + ): Promise => { + const ownerKey = rendererPanelOwner(sender.id) + const ownerLease = bindPluginPanelOwnerLifecycle(sender, () => + pluginService.panels.revokeOwner(ownerKey) + ) + await pluginService.whenReady() + // Why not revoke on a stale load: the owner key may already belong to a successor document. + return open(ownerKey, ownerLease.isCurrent) + } - // Renderer forwards panel actions; main enforces capabilities. + ipcMain.handle('plugins:readPanelEntry', (event, args: unknown) => { + const parsed = readPanelEntryArgsSchema.parse(args) + return openFrameEntry(event.sender, (ownerKey, isCurrent) => + pluginService.panels.open(ownerKey, parsed.pluginKey, parsed.panelId, isCurrent) + ) + }) + + ipcMain.handle('plugins:readCustomEditorEntry', (event, args: unknown) => { + const parsed = readCustomEditorEntryArgsSchema.parse(args) + return openFrameEntry(event.sender, (ownerKey, isCurrent) => + pluginService.panels.openCustomEditor(ownerKey, parsed.pluginKey, parsed.editorId, isCurrent) + ) + }) + + ipcMain.handle('plugins:readNewFileTemplate', async (_event, args: unknown) => { + const parsed = readNewFileTemplateArgsSchema.parse(args) + await pluginService.whenReady() + return readApprovedPluginNewFileTemplate(pluginService, parsed.pluginKey, parsed.newFileId) + }) + + // Renderer forwards frame host calls; main enforces capabilities and surfaces. ipcMain.handle( 'plugins:panelAction', - async (event, args: unknown): Promise => { + async (event, args: unknown): Promise => { await pluginService.whenReady() return pluginService.panels.execute(rendererPanelOwner(event.sender.id), args) } @@ -252,6 +286,19 @@ export function registerPluginHandlers( return listPluginsForClients(pluginService) }) if (marketplaceServices) { - registerPluginMarketplaceHandlers(pluginService, marketplaceServices) + registerPluginMarketplaceHandlers( + pluginService, + marketplaceServices, + () => store.getSettings(), + (pluginKey, reviewedFingerprint, originWebContentsId) => + applyPluginConsent({ + store, + pluginService, + pluginKey, + reviewedFingerprint, + decision: 'approve', + originWebContentsId + }) + ) } } diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts index 355cdd7fccb..41a6795b118 100644 --- a/src/main/ipc/settings.test.ts +++ b/src/main/ipc/settings.test.ts @@ -543,8 +543,8 @@ describe('registerSettingsHandlers', () => { ) => Promise await handler(settingsInvokeEvent, { - pluginConsents: { 'orca-samples.demo': 'sha256-forged' }, - disabledPlugins: ['orca-samples.demo'] + pluginConsents: { 'samples.demo': 'sha256-forged' }, + disabledPlugins: ['samples.demo'] }) expect(store.updateSettings).toHaveBeenCalledWith( diff --git a/src/main/keybindings/keybinding-file.test.ts b/src/main/keybindings/keybinding-file.test.ts index 93cb338519a..5c064622489 100644 --- a/src/main/keybindings/keybinding-file.test.ts +++ b/src/main/keybindings/keybinding-file.test.ts @@ -150,7 +150,7 @@ describe('keybinding-file', () => { filePath, JSON.stringify({ keybindings: { - 'plugin:orca-samples.tasks/open': 'Mod+Shift+T', + 'plugin:samples.tasks/open': 'Mod+Shift+T', 'plugin:tasks/open': 'Mod+Alt+T' } }), @@ -159,15 +159,15 @@ describe('keybinding-file', () => { const snapshot = readKeybindingFile(filePath, 'linux') expect(snapshot.overrides).toEqual({ - 'plugin:orca-samples.tasks/open': ['Mod+Shift+T'] + 'plugin:samples.tasks/open': ['Mod+Shift+T'] }) expect(snapshot.diagnostics).toMatchObject([ { severity: 'warning', actionId: 'plugin:tasks/open' } ]) - writeKeybindingOverride(filePath, 'linux', 'plugin:orca-samples.tasks/open', []) + writeKeybindingOverride(filePath, 'linux', 'plugin:samples.tasks/open', []) expect(readKeybindingFile(filePath, 'linux').overrides).toEqual({ - 'plugin:orca-samples.tasks/open': [] + 'plugin:samples.tasks/open': [] }) }) diff --git a/src/main/persistence/applying-settings/default-on-migration-guards.ts b/src/main/persistence/applying-settings/default-on-migration-guards.ts new file mode 100644 index 00000000000..59d4cc1b0d0 --- /dev/null +++ b/src/main/persistence/applying-settings/default-on-migration-guards.ts @@ -0,0 +1,20 @@ +import type { GlobalSettings } from '../../../shared/global-settings-types' + +// Each setting whose default flipped once, paired with its one-shot migration guard. +const DEFAULT_ON_MIGRATION_GUARDS = [ + ['terminalTuiScrollSensitivity', 'terminalTuiScrollSensitivityDefaultedToOne'], + ['autoRenameBranchFromWork', 'autoRenameBranchFromWorkDefaultedOn'], + ['pluginSystemEnabled', 'pluginSystemDefaultedOn'] +] as const satisfies readonly (readonly [keyof GlobalSettings, keyof GlobalSettings])[] + +/** Explicit writes (or attempts to clear a guard) keep the guard set so later choices stick. */ +export function stampDefaultOnMigrationGuards( + updates: Partial, + sanitizedUpdates: Partial +): void { + for (const [setting, guard] of DEFAULT_ON_MIGRATION_GUARDS) { + if (setting in updates || guard in updates) { + sanitizedUpdates[guard] = true + } + } +} diff --git a/src/main/persistence/applying-settings/persistence-plugin-system-default-on.test.ts b/src/main/persistence/applying-settings/persistence-plugin-system-default-on.test.ts new file mode 100644 index 00000000000..d0ffd3180d9 --- /dev/null +++ b/src/main/persistence/applying-settings/persistence-plugin-system-default-on.test.ts @@ -0,0 +1,70 @@ +import { closeTestStores, testState, createStore, writeDataFile } from '../persistence-test-harness' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { rmSync, mkdtempSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length) + } +})) +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) + +function writeSettings(settings: Record): void { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) +} + +describe('plugin system default-on migration', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) + }) + + afterEach(async () => { + await closeTestStores() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('turns plugins on for new profiles', async () => { + const store = await createStore() + expect(store.getSettings().pluginSystemEnabled).toBe(true) + expect(store.getSettings().pluginSystemDefaultedOn).toBe(true) + }) + + it('flips a saved pre-rollout false on once', async () => { + writeSettings({ pluginSystemEnabled: false }) + const store = await createStore() + expect(store.getSettings().pluginSystemEnabled).toBe(true) + expect(store.getSettings().pluginSystemDefaultedOn).toBe(true) + }) + + it('keeps a deliberate false saved after the migration', async () => { + writeSettings({ pluginSystemEnabled: false, pluginSystemDefaultedOn: true }) + const store = await createStore() + expect(store.getSettings().pluginSystemEnabled).toBe(false) + }) + + it('stamps the guard on explicit writes so they survive reload', async () => { + const store = await createStore() + const updated = store.updateSettings({ + pluginSystemEnabled: false, + pluginSystemDefaultedOn: false + }) + expect(updated.pluginSystemEnabled).toBe(false) + expect(updated.pluginSystemDefaultedOn).toBe(true) + }) +}) diff --git a/src/main/persistence/applying-settings/persistence-right-sidebar-tab.test.ts b/src/main/persistence/applying-settings/persistence-right-sidebar-tab.test.ts index 4cb794b84d6..d74e578d405 100644 --- a/src/main/persistence/applying-settings/persistence-right-sidebar-tab.test.ts +++ b/src/main/persistence/applying-settings/persistence-right-sidebar-tab.test.ts @@ -27,14 +27,14 @@ describe('normalizeRightSidebarTab', () => { }) it('preserves well-formed plugin panel tabs', () => { - expect(normalizeRightSidebarTab('plugin:orca-samples.my-plugin/dashboard')).toBe( - 'plugin:orca-samples.my-plugin/dashboard' + expect(normalizeRightSidebarTab('plugin:samples.my-plugin/dashboard')).toBe( + 'plugin:samples.my-plugin/dashboard' ) }) it('normalizes malformed plugin tabs to the default tab', () => { - expect(normalizeRightSidebarTab('plugin:orca-samples.my-plugin')).toBe('explorer') - expect(normalizeRightSidebarTab('plugin:orca-samples.my-plugin/panel/extra')).toBe('explorer') + expect(normalizeRightSidebarTab('plugin:samples.my-plugin')).toBe('explorer') + expect(normalizeRightSidebarTab('plugin:samples.my-plugin/panel/extra')).toBe('explorer') expect(normalizeRightSidebarTab('plugin:My_Plugin/Panel!')).toBe('explorer') }) diff --git a/src/main/persistence/applying-settings/settings-update.ts b/src/main/persistence/applying-settings/settings-update.ts index 5772cb15810..d9c8a752d51 100644 --- a/src/main/persistence/applying-settings/settings-update.ts +++ b/src/main/persistence/applying-settings/settings-update.ts @@ -1,5 +1,6 @@ import { publishSettingsUpdate } from './settings-update-effects' import { legacyChatPermissionWrite } from './legacy-chat-permission-write' +import { stampDefaultOnMigrationGuards } from './default-on-migration-guards' import { normalizeNativeChatAppearanceSettings } from '../../../shared/native-chat/native-chat-appearance-settings' import type { GlobalSettings } from '../../../shared/global-settings-types' import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection' @@ -10,7 +11,7 @@ import { sanitizeAgentLaunchProfileUpdates } from '../../../shared/tui-agent-lau import { normalizeTerminalQuickCommands } from '../../../shared/terminal-quick-commands' import { assertTerminalQuickCommandsStorable } from '../../../shared/terminal-quick-command-prompt-limit' import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes' -import { normalizePluginThemeSettings } from '../../../shared/plugin-theme-settings' +import { normalizePluginOwnedSettings } from '../../../shared/plugins/capabilities/plugin-owned-settings' import { normalizeTerminalDefaultShell } from '../../../shared/terminal-default-shell' import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cursor-style-settings' import { normalizeDesktopTerminalScrollbackRows } from '../../../shared/terminal-scrollback-policy' @@ -129,7 +130,7 @@ export function updateSettings( updates.terminalCustomThemes ) } - Object.assign(sanitizedUpdates, normalizePluginThemeSettings(updates)) + Object.assign(sanitizedUpdates, normalizePluginOwnedSettings(updates)) if ('terminalCursorStyle' in updates) { Object.assign( sanitizedUpdates, @@ -150,12 +151,6 @@ export function updateSettings( updates.terminalMinimumContrastRatio ) } - if ( - 'terminalTuiScrollSensitivity' in updates || - 'terminalTuiScrollSensitivityDefaultedToOne' in updates - ) { - sanitizedUpdates.terminalTuiScrollSensitivityDefaultedToOne = true - } if ('visibleTaskProviders' in updates || 'defaultTaskSource' in updates) { const taskProviderSettings = normalizeTaskProviderSettings({ visibleTaskProviders: @@ -173,9 +168,7 @@ export function updateSettings( sanitizedUpdates.visibleTaskProvidersDefaultedForJira = true } } - if ('autoRenameBranchFromWork' in updates || 'autoRenameBranchFromWorkDefaultedOn' in updates) { - sanitizedUpdates.autoRenameBranchFromWorkDefaultedOn = true - } + stampDefaultOnMigrationGuards(updates, sanitizedUpdates) if ('openInApplications' in updates) { sanitizedUpdates.openInApplications = normalizeOpenInApplications(updates.openInApplications) } diff --git a/src/main/persistence/applying-settings/terminal-settings-migrations.ts b/src/main/persistence/applying-settings/terminal-settings-migrations.ts index 06699874621..658c88f2a3a 100644 --- a/src/main/persistence/applying-settings/terminal-settings-migrations.ts +++ b/src/main/persistence/applying-settings/terminal-settings-migrations.ts @@ -77,6 +77,8 @@ type RetiredGlobalSettings = { opencodeGoApiKey?: unknown experimentalStructuredNativeChat?: unknown openAgentTabsInChatByDefault?: unknown + // Full-access plugins now need only their own approval. + pluginFullAccessEnabled?: unknown } export function stripRetiredGlobalSettings( @@ -92,6 +94,7 @@ export function stripRetiredGlobalSettings( opencodeGoApiKey: _legacyOpenCodeGoApiKey, experimentalStructuredNativeChat: _legacyStructuredNativeChat, openAgentTabsInChatByDefault: _legacyChatDefaultView, + pluginFullAccessEnabled: _retiredPluginFullAccessSwitch, ...rest } = (settings ?? {}) as Partial & RetiredGlobalSettings void _retiredChatPermissionCopies @@ -103,6 +106,7 @@ export function stripRetiredGlobalSettings( void _legacyOpenCodeGoApiKey void _legacyStructuredNativeChat void _legacyChatDefaultView + void _retiredPluginFullAccessSwitch return rest } diff --git a/src/main/persistence/applying-settings/ui-selection-normalization.ts b/src/main/persistence/applying-settings/ui-selection-normalization.ts index 9826e501fbc..26d72e3f71f 100644 --- a/src/main/persistence/applying-settings/ui-selection-normalization.ts +++ b/src/main/persistence/applying-settings/ui-selection-normalization.ts @@ -1,6 +1,6 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import { getDefaultUIState } from '../../../shared/constants' -import { isPluginPanelTabKey } from '../../../shared/plugins/plugin-manifest' +import { isPluginPanelTabKey } from '../../../shared/plugins/manifest/plugin-manifest' export function normalizeGroupBy(groupBy: unknown): PersistedState['ui']['groupBy'] { if ( diff --git a/src/main/persistence/loading-store/normalize-loaded-global-settings.ts b/src/main/persistence/loading-store/normalize-loaded-global-settings.ts index c039b6b2cfe..363dcdbc69e 100644 --- a/src/main/persistence/loading-store/normalize-loaded-global-settings.ts +++ b/src/main/persistence/loading-store/normalize-loaded-global-settings.ts @@ -6,7 +6,7 @@ import { normalizeOpenInApplications } from '../../../shared/open-in-application import { normalizeTerminalShortcutPolicy } from '../../../shared/keybindings' import { normalizeAppIconId } from '../../../shared/app-icon' import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes' -import { normalizePluginThemeSettings } from '../../../shared/plugin-theme-settings' +import { normalizePluginOwnedSettings } from '../../../shared/plugins/capabilities/plugin-owned-settings' import { normalizeTerminalDefaultShell } from '../../../shared/terminal-default-shell' import { projectSourceControlAiToLegacyCommitMessageAi } from '../../../shared/source-control-ai' import { normalizeNativeChatShellEnvironmentVariables } from '../../../shared/native-chat/native-chat-shell-environment' @@ -43,6 +43,7 @@ export function normalizeLoadedGlobalSettings( migratedPortugueseUiLanguage, migratedExperimentalActivity, migratedAutoRenameBranchFromWork, + migratedPluginSystemEnabled, migratedTerminalCursorStyle, migratedTerminalLineHeight, terminalRightClickToPasteDefaultedForPlatform, @@ -89,6 +90,7 @@ export function normalizeLoadedGlobalSettings( primarySelectionMiddleClickPasteDefaultedForTerminalDefaults: primarySelectionDefaultedForTerminalDefaults || stampPrimarySelectionTerminalDefaults, ...migratedAutoRenameBranchFromWork, + ...migratedPluginSystemEnabled, ...migratedTerminalCursorStyle, terminalLineHeight: migratedTerminalLineHeight, // False was always explicit; the old true default may be reset once. @@ -121,7 +123,7 @@ export function normalizeLoadedGlobalSettings( terminalDefaultShell: normalizeTerminalDefaultShell(parsed.settings?.terminalDefaultShell), terminalQuickCommands: normalizeTerminalQuickCommands(parsed.settings?.terminalQuickCommands), terminalCustomThemes: normalizeTerminalCustomThemes(parsed.settings?.terminalCustomThemes), - ...normalizePluginThemeSettings(parsed.settings ?? {}), + ...normalizePluginOwnedSettings(parsed.settings ?? {}), appIcon: normalizeAppIconId(parsed.settings?.appIcon), mobilePairingCustomAddress, mobilePairingCustomAddresses, diff --git a/src/main/persistence/loading-store/prepare-loaded-profile-settings.ts b/src/main/persistence/loading-store/prepare-loaded-profile-settings.ts index dcc69d549e4..e43a45b9bd4 100644 --- a/src/main/persistence/loading-store/prepare-loaded-profile-settings.ts +++ b/src/main/persistence/loading-store/prepare-loaded-profile-settings.ts @@ -4,6 +4,7 @@ import type { ProjectGroup } from '../../../shared/project-group-types' import { deriveGlobalWindowsRuntimeDefaultFromLegacySettings } from '../../../shared/project-execution-runtime' import { normalizeTaskProviderSettings } from '../../../shared/task-providers' import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../shared/auto-rename-branch-from-work-settings' +import { normalizePluginSystemEnabledDefaultOn } from '../../../shared/plugins/plugin-system-settings' import { addMobilePairingCustomAddress, normalizeMobilePairingCustomAddress, @@ -35,6 +36,7 @@ export type PreparedLoadedProfileSettings = { GlobalSettings, 'autoRenameBranchFromWork' | 'autoRenameBranchFromWorkDefaultedOn' > + migratedPluginSystemEnabled: ReturnType migratedTerminalCursorStyle: Pick< GlobalSettings, 'terminalCursorStyle' | 'terminalCursorStyleDefaultedToBlock' @@ -87,6 +89,10 @@ export function prepareLoadedProfileSettings( const migratedAutoRenameBranchFromWork = normalizeAutoRenameBranchFromWorkDefaultOn( parsed.settings ) + const migratedPluginSystemEnabled = normalizePluginSystemEnabledDefaultOn(parsed.settings) + if (parsed.settings?.pluginSystemDefaultedOn !== true) { + markNeedsSave() + } const migratedTerminalCursorStyle = normalizeTerminalCursorStyleDefault(parsed.settings) if ( parsed.settings?.terminalCursorStyle !== migratedTerminalCursorStyle.terminalCursorStyle || @@ -258,6 +264,7 @@ export function prepareLoadedProfileSettings( migratedPortugueseUiLanguage, migratedExperimentalActivity, migratedAutoRenameBranchFromWork, + migratedPluginSystemEnabled, migratedTerminalCursorStyle, migratedTerminalLineHeight, terminalRightClickToPasteDefaultedForPlatform, diff --git a/src/main/plugins/__mocks__/plugin-git-ssh-remote-fixture.ts b/src/main/plugins/__mocks__/plugin-git-ssh-remote-fixture.ts index 6e8602852ea..8664bbfed0f 100644 --- a/src/main/plugins/__mocks__/plugin-git-ssh-remote-fixture.ts +++ b/src/main/plugins/__mocks__/plugin-git-ssh-remote-fixture.ts @@ -11,7 +11,9 @@ export async function serveRepositoriesOverTestSsh( const shim = join(shimDirectory, 'git-ssh-shim.cjs') await writeFile( shim, - await readFile(join(import.meta.dirname, '..', 'plugin-private-marketplace-ssh-shim.cjs')) + await readFile( + join(import.meta.dirname, '..', 'catalog', 'plugin-private-marketplace-ssh-shim.cjs') + ) ) const node = process.execPath.replaceAll('\\', '/') vi.stubEnv('GIT_SSH_COMMAND', `"${node}" "${shim.replaceAll('\\', '/')}"`) diff --git a/src/main/plugins/__mocks__/plugin-uninstall-log-test-fixture.ts b/src/main/plugins/__mocks__/plugin-uninstall-log-test-fixture.ts index fb79dc003ec..58b3724c1c5 100644 --- a/src/main/plugins/__mocks__/plugin-uninstall-log-test-fixture.ts +++ b/src/main/plugins/__mocks__/plugin-uninstall-log-test-fixture.ts @@ -6,12 +6,12 @@ import type * as ChildProcess from 'node:child_process' import { PluginContentVerifier } from '../plugin-content-integrity' import { PluginService } from '../plugin-service' import { registerPluginHandlers } from '../../ipc/plugins' -import { installPluginFromLocalPath, installBundledPlugin } from '../plugin-install' -import { installStagedPluginTree } from '../plugin-install-staging' +import { installPluginFromLocalPath, installBundledPlugin } from '../installation/plugin-install' +import { installStagedPluginTree } from '../installation/plugin-install-staging' import { getUserPluginsDir } from '../plugin-discovery' -import { PluginWorkerController } from '../plugin-worker-controller' +import { PluginWorkerController } from '../workers/plugin-worker-controller' import { UninstallWorkerPort } from '../plugin-uninstall-worker-fixture' -import type { PluginManifest } from '../../../shared/plugins/plugin-manifest' +import type { PluginManifest } from '../../../shared/plugins/manifest/plugin-manifest' const ports = vi.hoisted(() => ({ handlers: new Map unknown>(), @@ -113,7 +113,7 @@ export async function createUninstallFixture() { pluginApi: 1, main: 'worker.js', contributes: { commands: [{ id: 'run', title: 'Run' }], ...contributes }, - capabilities: contributes.events ? [{ kind: 'events:subscribe' }] : [] + capabilities: contributes.events ? [{ kind: 'workspace:read' }, { kind: 'agent:read' }] : [] } await writeFile(join(sourcePath, 'orca-plugin.json'), JSON.stringify(manifest)) await writeFile(join(sourcePath, 'worker.js'), 'export default function () {}') @@ -125,7 +125,15 @@ export async function createUninstallFixture() { pluginsDir: getUserPluginsDir(root), stagingDir: sourcePath, hostVersion: '1.4.0', - source: { kind: 'git', url: 'https://github.com/stablyai/orca-plugins.git', ref: 'main' }, + source: { + kind: 'marketplace', + marketplace: { + url: 'https://github.com/stablyai/orca-plugins.git', + ref: 'main', + resolvedCommit: '2'.repeat(40) + }, + plugin: { url: `https://github.com/stablyai/${id}.git`, ref: 'main' } + }, resolvedCommit: '1'.repeat(40) }) : await installPluginFromLocalPath({ diff --git a/src/main/plugins/catalog/plugin-auto-updater.test.ts b/src/main/plugins/catalog/plugin-auto-updater.test.ts new file mode 100644 index 00000000000..a56f928d702 --- /dev/null +++ b/src/main/plugins/catalog/plugin-auto-updater.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it, vi } from 'vitest' +import { fingerprintPluginConsent } from '../../../shared/plugins/approval/plugin-consent-fingerprint' +import { + pluginManifestSchema, + type PluginManifest +} from '../../../shared/plugins/manifest/plugin-manifest' +import { + isPluginAutoUpdateOn, + normalizePluginUpdatePolicySettings, + type PluginAutoUpdateState +} from '../../../shared/plugins/catalog/plugin-update-policy' +import type { ValidDiscoveredPlugin } from '../plugin-discovery' +import { PLUGIN_AUTO_UPDATE_HOLD_BACK_MS, PluginAutoUpdater } from './plugin-auto-updater' + +const KEY = 'samples.demo' +const REPO = 'https://example.com/samples/demo.git' +const HOUR = 60 * 60 * 1000 +const COMMIT = 'c'.repeat(40) + +function release(version: string) { + return { + version, + commit: COMMIT, + contentHash: 'sha-new', + execution: 'trusted-worker' as const, + minimumOrca: '1.0.0' + } +} + +function manifest(version: string, overrides: Partial = {}): PluginManifest { + return pluginManifestSchema.parse({ + manifestVersion: 1, + id: 'demo', + publisher: 'samples', + name: 'Demo', + version, + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + main: 'worker.js', + capabilities: [{ kind: 'notifications:show' }], + ...overrides + }) +} + +function discovered(m: PluginManifest, contentHash: string, isDev = false): ValidDiscoveredPlugin { + return { + pluginKey: KEY, + rootDir: `/plugins/${KEY}/${contentHash}`, + manifest: m, + consentFingerprint: fingerprintPluginConsent(m, contentHash), + contentHash, + isDev + } +} + +function setup( + options: { + next?: PluginManifest + officialCatalog?: boolean + repository?: string + approved?: boolean + disabled?: boolean + dev?: boolean + enabled?: boolean + blocked?: boolean + pinned?: boolean + previewCommit?: string + refreshMs?: number + killListRefresh?: () => Promise + } = {} +) { + let now = 1_000_000 + // Why first pass only: later refreshes hit warm caches and finish sooner. + let refreshMs = options.refreshMs ?? 0 + const previous = discovered(manifest('1.0.0'), 'sha-old', options.dev) + const nextManifest = options.next ?? manifest('1.1.0') + const next = discovered(nextManifest, 'sha-new') + let current = previous + const consents: Record = + options.approved === false ? {} : { [KEY]: previous.consentFingerprint } + const firstSeen = new Map() + const install = vi.fn(async () => ({ + ok: true as const, + pluginKey: KEY, + version: nextManifest.version, + contentHash: 'sha-new', + consentFingerprint: next.consentFingerprint, + resolvedCommit: null + })) + const changes: { state: PluginAutoUpdateState; updated: Record }[] = [] + const updater = new PluginAutoUpdater({ + pluginService: { + options: { + isPluginSystemEnabled: () => true, + getPluginConsents: () => consents, + getDisabledPlugins: () => (options.disabled ? [KEY] : []) + }, + getDiscovered: () => [current], + findValidPlugin: () => current, + refresh: async () => { + current = next + } + }, + installer: { + findInstalledUpdateListing: vi.fn(async () => ({ + listing: { + marketplaceSourceId: 'a'.repeat(32), + marketplaceName: 'Samples', + marketplaceOwner: 'samples', + marketplaceCommit: 'b'.repeat(40), + pluginKey: KEY, + source: { kind: 'git' as const, url: REPO, ref: 'main' }, + categories: [], + official: false, + bundled: false, + version: nextManifest.version, + ...(options.pinned === false ? {} : { release: release(nextManifest.version) }), + ...(options.blocked ? { blockedByKillList: { reason: 'malware' } } : {}) + }, + officialCatalog: options.officialCatalog === true + })), + previewInstalledUpdate: vi.fn(async () => ({ + marketplaceSourceId: 'a'.repeat(32), + marketplaceName: 'Samples', + marketplaceOwner: 'samples', + marketplaceCommit: 'b'.repeat(40), + pluginKey: KEY, + source: { kind: 'git' as const, url: options.repository ?? REPO, ref: 'main' }, + ...(options.repository ? { repositoryMoved: true as const } : {}), + resolvedCommit: options.previewCommit ?? COMMIT, + contentHash: 'sha-new', + consentFingerprint: next.consentFingerprint, + manifest: nextManifest, + official: false, + bundled: false + })), + install + }, + firstSeen: { + firstSeen: async (pluginKey, release, at) => { + const known = firstSeen.get(pluginKey) + if (known?.release === release) { + return known.at + } + firstSeen.set(pluginKey, { release, at }) + return at + } + }, + isEnabled: () => options.enabled !== false, + refreshCatalogs: async () => { + now += refreshMs + refreshMs = 0 + }, + refreshKillList: options.killListRefresh ?? (async () => undefined), + approve: async (pluginKey, fingerprint) => { + consents[pluginKey] = fingerprint + }, + onChanged: (state, updated) => changes.push({ state, updated }), + now: () => now + }) + return { + updater, + install, + consents, + next, + changes, + advance: (ms: number) => { + now += ms + } + } +} + +describe('plugin auto-update', () => { + it('holds a release back until it has been listed for 12 hours, then installs it in place', async () => { + const { updater, install, consents, next, changes, advance } = setup() + await updater.run() + expect(install).not.toHaveBeenCalled() + advance(PLUGIN_AUTO_UPDATE_HOLD_BACK_MS - HOUR) + await updater.run() + expect(install).not.toHaveBeenCalled() + advance(HOUR) + await updater.run() + expect(install).toHaveBeenCalledTimes(1) + expect(install).toHaveBeenCalledWith(expect.anything(), { requireInstalled: true }) + expect(consents[KEY]).toBe(next.consentFingerprint) + expect(updater.getState()).toEqual({ updated: { [KEY]: '1.1.0' }, needsReview: {} }) + expect(changes.at(-1)?.updated).toEqual({ [KEY]: '1.1.0' }) + }) + + it('installs on the pass one hold later even when the first catalog refresh was slow', async () => { + const { updater, install, advance } = setup({ refreshMs: 60_000 }) + await updater.run() + advance(PLUGIN_AUTO_UPDATE_HOLD_BACK_MS - 60_000) + await updater.run() + expect(install).toHaveBeenCalledTimes(1) + }) + + it('refreshes the kill list before installing and installs nothing when that fails', async () => { + const order: string[] = [] + const ok = setup({ + officialCatalog: true, + killListRefresh: async () => order.push('kill-list') + }) + ok.install.mockImplementation(async () => { + order.push('install') + throw new Error('stop') + }) + await ok.updater.run() + expect(order).toEqual(['kill-list', 'install']) + const offline = setup({ + officialCatalog: true, + killListRefresh: async () => { + throw new Error('offline') + } + }) + await offline.updater.run() + expect(offline.install).not.toHaveBeenCalled() + }) + + it('installs at once from an official catalog and logs it for hosts without a UI', async () => { + const info = vi.spyOn(console, 'info').mockImplementation(() => undefined) + const { updater, install } = setup({ officialCatalog: true }) + await updater.run() + expect(install).toHaveBeenCalledTimes(1) + expect(info).toHaveBeenCalledWith(`[plugins] auto-updated ${KEY} to 1.1.0`) + info.mockRestore() + }) + + it('leaves an update that asks for more access uninstalled and marked for review', async () => { + const { updater, install, consents } = setup({ + officialCatalog: true, + next: manifest('1.1.0', { + capabilities: [{ kind: 'notifications:show' }, { kind: 'terminal:send' }] + }) + }) + const before = consents[KEY] + await updater.run() + expect(install).not.toHaveBeenCalled() + expect(consents[KEY]).toBe(before) + expect(updater.getState().needsReview).toEqual({ [KEY]: '1.1.0' }) + }) + + it('marks an update from a moved repository for review', async () => { + const { updater, install } = setup({ + officialCatalog: true, + repository: 'https://example.com/someone-else/demo.git' + }) + await updater.run() + expect(install).not.toHaveBeenCalled() + expect(updater.getState().needsReview).toEqual({ [KEY]: '1.1.0' }) + }) + + it('skips disabled, unapproved, development, blocked plugins and the manual policy', async () => { + for (const options of [ + { disabled: true }, + { approved: false }, + { dev: true }, + { blocked: true }, + { enabled: false } + ]) { + const { updater, install } = setup({ officialCatalog: true, ...options }) + await updater.run() + expect(install, JSON.stringify(options)).not.toHaveBeenCalled() + } + }) + + it('never auto-installs an unpinned listing, whose bytes are a moving branch head', async () => { + const { updater, install, advance } = setup({ pinned: false }) + await updater.run() + advance(PLUGIN_AUTO_UPDATE_HOLD_BACK_MS) + await updater.run() + expect(install).not.toHaveBeenCalled() + }) + + it('ignores a release re-pinned after a commit that only touched a sibling plugin', async () => { + // Why: the plugin's directory, so its version and hash, did not change; only `commit` did. + const { updater, install } = setup({ officialCatalog: true, next: manifest('1.0.0') }) + await updater.run() + expect(install).not.toHaveBeenCalled() + expect(updater.getState()).toEqual({ updated: {}, needsReview: {} }) + }) + + it('installs only the release that was held, not one listed since', async () => { + const { updater, install } = setup({ officialCatalog: true, previewCommit: 'd'.repeat(40) }) + await updater.run() + expect(install).not.toHaveBeenCalled() + }) + + it('keeps the policy a bounded union with auto as the default and unknown values manual', () => { + expect(normalizePluginUpdatePolicySettings({})).toEqual({}) + expect(isPluginAutoUpdateOn({})).toBe(true) + expect( + isPluginAutoUpdateOn( + normalizePluginUpdatePolicySettings({ pluginUpdatePolicy: 'official-only' }) + ) + ).toBe(false) + expect(normalizePluginUpdatePolicySettings({ pluginUpdatePolicy: 'manual' })).toEqual({ + pluginUpdatePolicy: 'manual' + }) + expect(normalizePluginUpdatePolicySettings({ pluginUpdatePolicy: 'official-only' })).toEqual({ + pluginUpdatePolicy: 'manual' + }) + }) +}) diff --git a/src/main/plugins/catalog/plugin-auto-updater.ts b/src/main/plugins/catalog/plugin-auto-updater.ts new file mode 100644 index 00000000000..e518a559073 --- /dev/null +++ b/src/main/plugins/catalog/plugin-auto-updater.ts @@ -0,0 +1,196 @@ +import { compareAppVersions } from '../../../shared/app-version' +import { pluginUpdateGrowsAccess } from '../../../shared/plugins/approval/plugin-update-access-growth' +import type { PluginAutoUpdateState } from '../../../shared/plugins/catalog/plugin-update-policy' +import { + isInvalidDiscoveredPlugin, + type DiscoveredPlugin, + type ValidDiscoveredPlugin +} from '../plugin-discovery' +import { + installCatalogReleaseWithApproval, + readApprovedPredecessor, + readPreviewAccessGrowth, + type PluginCatalogInstallOutcome +} from './plugin-update-approval' +import type { PluginPolicySource } from '../plugin-activation-policy' +import type { PluginMarketplaceInstaller } from './plugin-marketplace-installer' +import type { PluginReleaseFirstSeenStore } from './plugin-release-first-seen-store' + +// Why half the hold: a release first seen one pass ago is old enough by the second pass, not the third. +export const PLUGIN_AUTO_UPDATE_INTERVAL_MS = 6 * 60 * 60 * 1000 +/** Why: a release must stay listed this long before it installs unasked, so takedowns can land. */ +export const PLUGIN_AUTO_UPDATE_HOLD_BACK_MS = 12 * 60 * 60 * 1000 + +type AutoUpdatePluginService = { + readonly options: PluginPolicySource + getDiscovered: () => readonly DiscoveredPlugin[] + findValidPlugin: (pluginKey: string) => ValidDiscoveredPlugin | null + refresh: () => Promise +} + +type AutoUpdateInstaller = Pick< + PluginMarketplaceInstaller, + 'findInstalledUpdateListing' | 'previewInstalledUpdate' | 'install' +> + +/** + * Installs catalog updates for enabled plugins when they ask for nothing new, like an editor's + * extension auto-update. An update that grows access, or comes from a moved repository, is left + * uninstalled and marked for review; the running version keeps running. + */ +export class PluginAutoUpdater { + private readonly options: { + pluginService: AutoUpdatePluginService + installer: AutoUpdateInstaller + firstSeen: Pick + isEnabled: () => boolean + refreshCatalogs: () => Promise + refreshKillList: () => Promise + approve: (pluginKey: string, reviewedFingerprint: string) => Promise + onChanged: (state: PluginAutoUpdateState, updatedNow: PluginAutoUpdateState['updated']) => void + now?: () => number + } + private state: PluginAutoUpdateState = { updated: {}, needsReview: {} } + private running: Promise | null = null + private timer: ReturnType | null = null + + constructor(options: PluginAutoUpdater['options']) { + this.options = options + } + + getState(): PluginAutoUpdateState { + return this.state + } + + /** One pass; concurrent callers share it. */ + run(): Promise { + this.running ??= this.performRun().finally(() => { + this.running = null + }) + return this.running + } + + syncPeriodicRun(enabled: boolean, intervalMs = PLUGIN_AUTO_UPDATE_INTERVAL_MS): void { + if (!enabled) { + this.dispose() + return + } + if (this.timer) { + return + } + this.timer = setInterval(() => { + void this.run().catch((error) => console.warn('[plugins] auto-update failed:', error)) + }, intervalMs) + this.timer.unref?.() + } + + dispose(): void { + if (this.timer) { + clearInterval(this.timer) + this.timer = null + } + } + + private async performRun(): Promise { + if (!this.options.isEnabled()) { + return + } + // Why before the refresh: a pass that starts exactly one hold later must see the hold as over. + const startedAt = (this.options.now ?? Date.now)() + await this.options + .refreshCatalogs() + .catch((error) => console.warn('[plugins] auto-update kept cached catalogs:', error)) + // Why once, lazily: the kill list can be hours old, so take a fresh copy only when about to install. + let killListFresh: Promise | null = null + const refreshKillList = (): Promise => + (killListFresh ??= this.options.refreshKillList().then(() => undefined)) + const updatedNow: PluginAutoUpdateState['updated'] = {} + const needsReview: PluginAutoUpdateState['needsReview'] = {} + for (const plugin of this.options.pluginService.getDiscovered()) { + if (isInvalidDiscoveredPlugin(plugin) || !this.options.isEnabled()) { + continue + } + try { + const verdict = await this.updateOne(plugin, startedAt, refreshKillList) + // Why logged: a headless or remote host has no toast, so the log is the only record. + if (verdict?.kind === 'updated') { + updatedNow[plugin.pluginKey] = verdict.version + console.info(`[plugins] auto-updated ${plugin.pluginKey} to ${verdict.version}`) + } else if (verdict?.kind === 'needs-review') { + needsReview[plugin.pluginKey] = verdict.version + console.info(`[plugins] update ${verdict.version} of ${plugin.pluginKey} needs review`) + } + } catch (error) { + console.warn(`[plugins] auto-update skipped ${plugin.pluginKey}:`, error) + } + } + const updated = { ...this.state.updated, ...updatedNow } + for (const key of Object.keys(needsReview)) { + delete updated[key] + } + this.state = { updated, needsReview } + this.options.onChanged(this.state, updatedNow) + } + + private async updateOne( + plugin: ValidDiscoveredPlugin, + now: number, + refreshKillList: () => Promise + ): Promise<{ kind: 'updated' | 'needs-review'; version: string } | null> { + const { pluginService, installer } = this.options + // Why approved only: a disabled or never-approved plugin is the user's to update. + if (!readApprovedPredecessor(pluginService.options, plugin)) { + return null + } + const found = await installer.findInstalledUpdateListing(plugin.pluginKey) + // Why pinned only: an unpinned listing installs a branch head, which can move after the hold. + const release = found?.listing.release + if ( + !found || + !release || + found.listing.blockedByKillList || + compareAppVersions(release.version, plugin.manifest.version) <= 0 + ) { + return null + } + if (!found.officialCatalog) { + const firstSeenAt = await this.options.firstSeen.firstSeen( + plugin.pluginKey, + `${release.version}@${release.commit}`, + now + ) + if (now - firstSeenAt < PLUGIN_AUTO_UPDATE_HOLD_BACK_MS) { + return null + } + } + const preview = await installer.previewInstalledUpdate(plugin.pluginKey) + // Why: a catalog refresh since the hold check can list another release; install only the held one. + if ( + preview.resolvedCommit !== release.commit || + preview.contentHash !== release.contentHash || + preview.contentHash === plugin.contentHash + ) { + return null + } + if ( + preview.repositoryMoved || + pluginUpdateGrowsAccess(await readPreviewAccessGrowth(plugin, preview)) + ) { + return { kind: 'needs-review', version: preview.manifest.version } + } + // Why fail closed: without a current kill list a takedown could not stop this install. + await refreshKillList() + const outcome: PluginCatalogInstallOutcome = await installCatalogReleaseWithApproval({ + pluginKey: plugin.pluginKey, + pluginService, + install: () => installer.install(preview, { requireInstalled: true }), + approve: this.options.approve + }) + if (!outcome.ok) { + throw new Error(outcome.error) + } + return outcome.needsReview + ? { kind: 'needs-review', version: outcome.version } + : { kind: 'updated', version: outcome.version } + } +} diff --git a/src/main/plugins/catalog/plugin-catalog-image-extraction.ts b/src/main/plugins/catalog/plugin-catalog-image-extraction.ts new file mode 100644 index 00000000000..9fd11023d9a --- /dev/null +++ b/src/main/plugins/catalog/plugin-catalog-image-extraction.ts @@ -0,0 +1,87 @@ +import type { PluginCatalogRelease } from '../../../shared/plugins/catalog/plugin-catalog-release' +import { readPluginImageHeader } from '../../../shared/plugins/images/plugin-image-format' +import { + PLUGIN_IMAGE_RULES, + pluginImageRuleError, + type PluginCatalogImages, + type PluginImageKind +} from '../../../shared/plugins/images/plugin-image-fields' +import type { PluginMarketplace } from '../../../shared/plugins/catalog/plugin-marketplace' +import { sha256Hex } from '../plugin-image-cache' + +// Why a catalog-wide cap: a large catalog must not turn one refresh into an unbounded download. +const CATALOG_IMAGES_MAX_BYTES = 64 * 1024 * 1024 + +type CatalogImage = NonNullable + +export type CatalogBlobReader = (path: string, maxBytes: number) => Promise + +/** Reads every image a catalog references from the catalog repository at its commit, keeps the + * ones whose bytes match their hash, size and format, and drops the rest from the listing. */ +export async function extractCatalogImages( + marketplace: PluginMarketplace, + readBlob: CatalogBlobReader +): Promise<{ marketplace: PluginMarketplace; images: Buffer[] }> { + const verified = new Map() + const rejected = new Set() + let budget = CATALOG_IMAGES_MAX_BYTES + const check = async (image: CatalogImage, kind: PluginImageKind): Promise => { + if (verified.has(image.sha256)) { + return true + } + if (rejected.has(image.sha256)) { + return false + } + const maxBytes = Math.min(PLUGIN_IMAGE_RULES[kind].maxBytes, budget) + const bytes = maxBytes > 0 ? await readBlob(image.path, maxBytes).catch(() => null) : null + const info = bytes ? readPluginImageHeader(bytes) : 'missing' + const ok = + bytes !== null && + typeof info !== 'string' && + sha256Hex(bytes) === image.sha256 && + info.width === image.width && + info.height === image.height && + pluginImageRuleError(kind, info, bytes.byteLength) === null + if (ok && bytes) { + budget -= bytes.byteLength + verified.set(image.sha256, bytes) + } else { + rejected.add(image.sha256) + } + return ok + } + const keep = async (images: PluginCatalogImages): Promise => { + const icon = images.icon && (await check(images.icon, 'icon')) ? images.icon : undefined + const cover = images.cover && (await check(images.cover, 'cover')) ? images.cover : undefined + const screenshots: CatalogImage[] = [] + for (const shot of images.screenshots ?? []) { + if (await check(shot, 'screenshot')) { + screenshots.push(shot) + } + } + return { + ...(icon ? { icon } : {}), + ...(cover ? { cover } : {}), + ...(screenshots.length ? { screenshots } : {}) + } + } + const plugins: PluginMarketplace['plugins'] = [] + for (const entry of marketplace.plugins) { + if (!entry.releases) { + plugins.push(entry) + continue + } + const releases: PluginCatalogRelease[] = [] + for (const release of entry.releases) { + if (!release.images) { + releases.push(release) + continue + } + const { images, ...rest } = release + const kept = await keep(images) + releases.push(Object.keys(kept).length ? { ...rest, images: kept } : rest) + } + plugins.push({ ...entry, releases }) + } + return { marketplace: { ...marketplace, plugins }, images: [...verified.values()] } +} diff --git a/src/main/plugins/catalog/plugin-catalog-monorepo-images.test.ts b/src/main/plugins/catalog/plugin-catalog-monorepo-images.test.ts new file mode 100644 index 00000000000..c244a329792 --- /dev/null +++ b/src/main/plugins/catalog/plugin-catalog-monorepo-images.test.ts @@ -0,0 +1,91 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import type { PluginMarketplace } from '../../../shared/plugins/catalog/plugin-marketplace' +import { extractCatalogImages } from './plugin-catalog-image-extraction' +import { fillMonorepoReleaseImages } from './plugin-catalog-monorepo-images' + +const CATALOG_URL = 'https://github.com/stablyai/orca-plugins.git' +const be32 = (n: number) => [n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255] +function png(width: number, height: number): Buffer { + const ihdr = [...Buffer.from('IHDR'), ...be32(width), ...be32(height), 8, 6, 0, 0, 0] + return Buffer.from([ + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, + 0, + 0, + 0, + 13, + ...ihdr, + 0, + 0, + 0, + 0 + ]) +} +const sha = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex') + +function catalog(url: string, images?: Record): PluginMarketplace { + return { + name: 'Official', + owner: 'stablyai', + plugins: [ + { + id: 'stablyai.orca-midnight-theme', + source: { kind: 'git', url, ref: 'main', path: 'plugins/midnight-theme' }, + categories: ['themes'], + releases: [ + { + version: '2.1.0', + commit: 'c'.repeat(40), + contentHash: 'f'.repeat(64), + execution: 'data', + minimumOrca: '1.4.245', + ...(images ? { images } : {}) + } + ] + } + ] + } +} + +const cover = png(1600, 1000) +const manifest = { + name: 'Orca Midnight', + cover: { path: 'images/cover.png', alt: 'Midnight on the editor' }, + screenshots: [{ path: 'images/missing.png', alt: 'Gone' }] +} +const files: Record = { + 'plugins/midnight-theme/orca-plugin.json': Buffer.from(JSON.stringify(manifest)), + 'plugins/midnight-theme/images/cover.png': cover +} +const read = async (path: string): Promise => files[path] ?? null + +describe('fillMonorepoReleaseImages', () => { + it('lists the manifest cover for a same-repository release published without images', async () => { + const filled = await fillMonorepoReleaseImages(catalog(CATALOG_URL), CATALOG_URL, read) + const extracted = await extractCatalogImages(filled, read) + expect(extracted.marketplace.plugins[0]?.releases?.[0]?.images).toEqual({ + cover: { + path: 'plugins/midnight-theme/images/cover.png', + alt: 'Midnight on the editor', + sha256: sha(cover), + width: 1600, + height: 1000 + } + }) + expect(extracted.images).toEqual([cover]) + }) + + it('leaves plugins hosted elsewhere and releases that declare images untouched', async () => { + const elsewhere = catalog('https://github.com/acme/midnight.git') + expect(await fillMonorepoReleaseImages(elsewhere, CATALOG_URL, read)).toEqual(elsewhere) + const declared = catalog(CATALOG_URL, {}) + expect(await fillMonorepoReleaseImages(declared, CATALOG_URL, read)).toEqual(declared) + }) +}) diff --git a/src/main/plugins/catalog/plugin-catalog-monorepo-images.ts b/src/main/plugins/catalog/plugin-catalog-monorepo-images.ts new file mode 100644 index 00000000000..0e84f465733 --- /dev/null +++ b/src/main/plugins/catalog/plugin-catalog-monorepo-images.ts @@ -0,0 +1,47 @@ +import { + isSameGitRepository, + type PluginMarketplace +} from '../../../shared/plugins/catalog/plugin-marketplace' +import { catalogImagesFromManifest } from '../../../shared/plugins/images/plugin-manifest-catalog-images' +import { sha256Hex } from '../plugin-image-cache' +import type { CatalogBlobReader } from './plugin-catalog-image-extraction' + +const MANIFEST_MAX_BYTES = 256 * 1024 + +/** + * A catalog that keeps its plugins in its own repository already holds their images, so a newest + * release listed without `images` borrows the manifest's from the catalog commit. Extraction then + * checks those bytes like any declared image. + */ +export async function fillMonorepoReleaseImages( + marketplace: PluginMarketplace, + catalogUrl: string, + readBlob: CatalogBlobReader +): Promise { + const plugins: PluginMarketplace['plugins'] = [] + for (const entry of marketplace.plugins) { + const [newest, ...older] = entry.releases ?? [] + const prefix = entry.source.path + if (!newest || newest.images || !prefix || !isSameGitRepository(entry.source.url, catalogUrl)) { + plugins.push(entry) + continue + } + const manifestBytes = await readBlob(`${prefix}/orca-plugin.json`, MANIFEST_MAX_BYTES).catch( + () => null + ) + let manifest: unknown = null + try { + manifest = manifestBytes ? JSON.parse(manifestBytes.toString('utf8')) : null + } catch { + manifest = null + } + const images = await catalogImagesFromManifest({ + manifest, + prefix, + read: readBlob, + sha256: (bytes) => sha256Hex(Buffer.from(bytes)) + }) + plugins.push(images ? { ...entry, releases: [{ ...newest, images }, ...older] } : entry) + } + return { ...marketplace, plugins } +} diff --git a/src/main/plugins/catalog/plugin-file-type-suggestion.test.ts b/src/main/plugins/catalog/plugin-file-type-suggestion.test.ts new file mode 100644 index 00000000000..72d47949b71 --- /dev/null +++ b/src/main/plugins/catalog/plugin-file-type-suggestion.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest' +import { fingerprintPluginConsent } from '../../../shared/plugins/approval/plugin-consent-fingerprint' +import { pluginManifestSchema } from '../../../shared/plugins/manifest/plugin-manifest' +import type { ValidDiscoveredPlugin } from '../plugin-discovery' +import { findPluginFileTypeSuggestion } from './plugin-file-type-suggestion' +import type { PluginMarketplaceListing } from './plugin-marketplace-projection' + +function listing( + pluginKey: string, + fileTypes: string[], + extra: Partial = {} +) { + return { + marketplaceSourceId: 'official', + marketplaceName: 'Orca', + marketplaceOwner: 'stablyai', + marketplaceCommit: 'c'.repeat(40), + pluginKey, + source: { kind: 'git', url: `https://example.invalid/${pluginKey}.git`, ref: 'v1' }, + categories: [], + official: true, + bundled: false, + release: { + version: '1.0.0', + commit: 'c'.repeat(40), + contentHash: 'f'.repeat(64), + execution: 'data', + minimumOrca: '1.4.0', + types: ['syntax'], + fileTypes + }, + ...extra + } satisfies PluginMarketplaceListing +} + +function installed(pluginKey: string, extensions: string[]): ValidDiscoveredPlugin { + const [publisher = '', id = ''] = pluginKey.split('.') + const manifest = pluginManifestSchema.parse({ + manifestVersion: 1, + id, + publisher, + name: id, + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + contributes: { + syntaxes: extensions.length + ? [{ id: 'lang', title: 'Lang', scopeName: 'source.lang', path: 'g.json', extensions }] + : [] + } + }) + return { + pluginKey, + rootDir: '/tmp/none', + manifest, + consentFingerprint: fingerprintPluginConsent(manifest), + contentHash: null, + isDev: false + } +} + +const on = { pluginSystemEnabled: true } +const listings = [ + listing('community.typst', ['typ'], { official: false, marketplaceSourceId: 'community' }), + listing('samples.blocked', ['typ'], { blockedByKillList: { reason: 'revoked' } }), + listing('samples.first', ['typ'], { + release: { ...listing('x', []).release, fileTypes: ['typ'], name: 'Typst' } + }), + listing('samples.second', ['typ', 'gleam']) +] + +describe('findPluginFileTypeSuggestion', () => { + it('picks the first official, unblocked catalog plugin and carries its release name', () => { + expect( + findPluginFileTypeSuggestion({ fileType: 'typ', settings: on, listings, installed: [] }) + ).toEqual({ + marketplaceSourceId: 'official', + pluginKey: 'samples.first', + fileType: 'typ', + name: 'Typst' + }) + }) + + it('suggests nothing when off, dismissed, or the plugin system is off', () => { + for (const settings of [ + { pluginSystemEnabled: false }, + { ...on, pluginFileTypeSuggestions: false }, + { ...on, pluginDismissedFileTypes: ['typ'] } + ]) { + expect( + findPluginFileTypeSuggestion({ fileType: 'typ', settings, listings, installed: [] }) + ).toBeNull() + } + }) + + it('suggests nothing an installed plugin already handles, and skips installed listings', () => { + expect( + findPluginFileTypeSuggestion({ + fileType: 'typ', + settings: on, + listings, + installed: [installed('local.typst', ['.TYP'])] + }) + ).toBeNull() + expect( + findPluginFileTypeSuggestion({ + fileType: 'typ', + settings: on, + listings, + installed: [installed('samples.first', [])] + })?.pluginKey + ).toBe('samples.second') + }) +}) + +describe('editor suggestions for files Orca already opens as text', () => { + const excalidraw = (pluginKey = 'stablyai.excalidraw') => + listing(pluginKey, ['excalidraw'], { + release: { + version: '1.0.0', + commit: 'c'.repeat(40), + contentHash: 'f'.repeat(64), + execution: 'sandboxed-ui', + minimumOrca: '1.4.0', + fileTypes: ['excalidraw'], + editorFileTypes: ['excalidraw'], + contributes: [ + { kind: 'customEditors', name: 'Excalidraw', detail: '.excalidraw, .excalidraw.json' } + ] + } + }) + const settings = { pluginSystemEnabled: true } + + const suggest = (fileType: string, listing: PluginMarketplaceListing, extra = {}) => + findPluginFileTypeSuggestion({ + fileType, + purpose: 'editor', + settings: { ...settings, ...extra }, + listings: [listing], + installed: [] + }) + + it('suggests a catalog custom editor for the type until dismissed', () => { + expect(suggest('excalidraw', excalidraw())).toMatchObject({ + pluginKey: 'stablyai.excalidraw', + fileType: 'excalidraw' + }) + expect( + suggest('excalidraw', excalidraw(), { pluginDismissedFileTypes: ['excalidraw'] }) + ).toBeNull() + }) + + it('never suggests a syntax plugin for a file that is already highlighted', () => { + expect(suggest('gleam', listing('stablyai.gleam', ['gleam']))).toBeNull() + }) + + it('trusts only verified, structured file types, never the display summary', () => { + const misstated = excalidraw() + misstated.release.fileTypes = [] + const summaryOnly = excalidraw() + delete summaryOnly.release.editorFileTypes + expect(suggest('excalidraw', misstated)).toBeNull() + expect(suggest('excalidraw', summaryOnly)).toBeNull() + }) + + it('suggests the same editor for a binary file Orca cannot open', () => { + const sheet = listing('stablyai.spreadsheet', ['xlsx'], { + release: { + version: '1.0.0', + commit: 'c'.repeat(40), + contentHash: 'f'.repeat(64), + execution: 'sandboxed-ui', + minimumOrca: '1.4.0', + fileTypes: ['xlsx'], + contributes: [{ kind: 'customEditors', name: 'Spreadsheet', detail: '.xlsx' }] + } + }) + expect( + findPluginFileTypeSuggestion({ fileType: 'xlsx', settings, listings: [sheet], installed: [] }) + ).toMatchObject({ pluginKey: 'stablyai.spreadsheet' }) + }) +}) diff --git a/src/main/plugins/catalog/plugin-file-type-suggestion.ts b/src/main/plugins/catalog/plugin-file-type-suggestion.ts new file mode 100644 index 00000000000..7d7a8db7962 --- /dev/null +++ b/src/main/plugins/catalog/plugin-file-type-suggestion.ts @@ -0,0 +1,70 @@ +import { + pluginEditorFileTypes, + pluginHandledFileTypes, + type PluginFileTypeSuggestionPurpose, + type PluginFileTypeSuggestionSettings +} from '../../../shared/plugins/contributions/plugin-file-type' +import { isInvalidDiscoveredPlugin, type DiscoveredPlugin } from '../plugin-discovery' +import type { PluginMarketplaceListing } from './plugin-marketplace-projection' + +export type PluginFileTypeSuggestion = { + marketplaceSourceId: string + pluginKey: string + fileType: string + /** The release's verified plugin name, when the catalog gives one. */ + name?: string +} + +/** + * The first official catalog plugin, in catalog order, that says it opens this file type. A + * user-added marketplace never produces an unsolicited suggestion. Nothing when + * suggestions are off or dismissed for the type, or when any installed plugin (even a disabled + * one) already handles it, so a user never gets pointed at what they already have. With the + * `editor` purpose, for a file Orca already opens as text, only a custom editor counts. + */ +export function findPluginFileTypeSuggestion(input: { + fileType: string + purpose?: PluginFileTypeSuggestionPurpose + settings: PluginFileTypeSuggestionSettings & { pluginSystemEnabled?: boolean } + listings: readonly PluginMarketplaceListing[] + installed: readonly DiscoveredPlugin[] +}): PluginFileTypeSuggestion | null { + const { fileType, settings } = input + const editorOnly = input.purpose === 'editor' + if ( + settings.pluginSystemEnabled !== true || + settings.pluginFileTypeSuggestions === false || + settings.pluginDismissedFileTypes?.includes(fileType) + ) { + return null + } + const installedKeys = new Set() + for (const plugin of input.installed) { + if (isInvalidDiscoveredPlugin(plugin)) { + continue + } + installedKeys.add(plugin.pluginKey) + const handled = editorOnly + ? pluginEditorFileTypes(plugin.manifest.contributes) + : pluginHandledFileTypes(plugin.manifest.contributes) + if (handled.includes(fileType)) { + return null + } + } + const listing = input.listings.find( + (candidate) => + candidate.official && + !candidate.blockedByKillList && + !installedKeys.has(candidate.pluginKey) && + candidate.release?.fileTypes?.includes(fileType) && + (!editorOnly || candidate.release?.editorFileTypes?.includes(fileType)) + ) + return listing + ? { + marketplaceSourceId: listing.marketplaceSourceId, + pluginKey: listing.pluginKey, + fileType, + ...(listing.release?.name ? { name: listing.release.name } : {}) + } + : null +} diff --git a/src/main/plugins/plugin-kill-list-content-revocation.test.ts b/src/main/plugins/catalog/plugin-kill-list-content-revocation.test.ts similarity index 82% rename from src/main/plugins/plugin-kill-list-content-revocation.test.ts rename to src/main/plugins/catalog/plugin-kill-list-content-revocation.test.ts index 765842347f8..07d834c3a03 100644 --- a/src/main/plugins/plugin-kill-list-content-revocation.test.ts +++ b/src/main/plugins/catalog/plugin-kill-list-content-revocation.test.ts @@ -2,23 +2,27 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' -import { pluginManifestSchema, type PluginManifest } from '../../shared/plugins/plugin-manifest' -import { getApprovedPluginVmRecipes } from './plugin-approved-vm-recipes' -import { PluginService } from './plugin-service' -import { hashPluginTree } from './plugin-content-hash' +import { fingerprintPluginConsent } from '../../../shared/plugins/approval/plugin-consent-fingerprint' +import { devFolderConsentIdentity } from '../plugin-discovery' +import { + pluginManifestSchema, + type PluginManifest +} from '../../../shared/plugins/manifest/plugin-manifest' +import { getApprovedPluginVmRecipes } from '../plugin-approved-vm-recipes' +import { PluginService } from '../plugin-service' +import { hashPluginTree } from '../plugin-content-hash' // VM recipe create strings execute through a shell; revocation must remove declarative content too. const roots: string[] = [] const services: PluginService[] = [] -const pluginKey = 'orca-samples.recipes' +const pluginKey = 'samples.recipes' function contentManifest(): PluginManifest { return pluginManifestSchema.parse({ manifestVersion: 1, id: 'recipes', - publisher: 'orca-samples', + publisher: 'samples', name: 'Recipes', version: '1.0.0', engines: { orca: '>=1.0.0' }, @@ -56,13 +60,14 @@ async function createService(root: string, isKilled: () => boolean): Promise true, getDisabledPlugins: () => [], getPluginConsents: () => ({ - [pluginKey]: fingerprintPluginConsent(contentManifest(), content.hash) + [pluginKey]: fingerprintPluginConsent(contentManifest(), identity) }), getDevPluginPaths: () => [root], getPluginKillListEntry: (key) => diff --git a/src/main/plugins/plugin-kill-list-service.test.ts b/src/main/plugins/catalog/plugin-kill-list-service.test.ts similarity index 72% rename from src/main/plugins/plugin-kill-list-service.test.ts rename to src/main/plugins/catalog/plugin-kill-list-service.test.ts index abd420231d2..124c9bcdf93 100644 --- a/src/main/plugins/plugin-kill-list-service.test.ts +++ b/src/main/plugins/catalog/plugin-kill-list-service.test.ts @@ -2,8 +2,12 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { PluginKillList } from '../../shared/plugins/plugin-kill-list' -import { fetchPluginKillList, PluginKillListService } from './plugin-kill-list-service' +import type { PluginKillList } from '../../../shared/plugins/catalog/plugin-kill-list' +import { + fetchPluginKillList, + PLUGIN_KILL_LIST_RETRY_MS, + PluginKillListService +} from './plugin-kill-list-service' import type { PluginKillListStore } from './plugin-kill-list-store' const roots: string[] = [] @@ -134,6 +138,57 @@ describe('PluginKillListService', () => { await expect(service.refresh()).rejects.toThrow('older snapshot') expect(service.snapshot()?.generatedAt).toBe('2026-07-12T20:00:00Z') }) + + it('refreshes periodically while plugins are on so long sessions see revocations', async () => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) + let revoked = false + const service = new PluginKillListService({ + pluginsDataDir: await tempRoot(), + fetcher: async () => + revoked + ? killList('2026-07-13T20:00:00Z') + : { version: 1, generatedAt: '2026-07-12T20:00:00Z', plugins: [] } + }) + await service.refresh() + const changed = vi.fn() + service.onChanged(changed) + service.syncPeriodicRefresh(true, 1000) + service.syncPeriodicRefresh(true, 1000) + revoked = true + + vi.advanceTimersByTime(1000) + await vi.waitFor(() => expect(changed).toHaveBeenCalledTimes(1)) + expect(service.reason('community.unsafe')).toBe('Malware advisory') + + service.syncPeriodicRefresh(false) + vi.advanceTimersByTime(5000) + await Promise.resolve() + expect(changed).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + }) +}) + +describe('PluginKillListService retry', () => { + it('retries 15 minutes after a failed refresh instead of waiting a full interval', async () => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'setTimeout', 'clearTimeout'] }) + let online = false + const service = new PluginKillListService({ + pluginsDataDir: await tempRoot(), + fetcher: async () => { + if (!online) { + throw new Error('offline') + } + return killList() + } + }) + service.syncPeriodicRefresh(true) + await expect(service.refresh()).rejects.toThrow('offline') + online = true + await vi.advanceTimersByTimeAsync(PLUGIN_KILL_LIST_RETRY_MS) + await vi.waitFor(() => expect(service.reason('community.unsafe')).toBe('Malware advisory')) + service.dispose() + expect(vi.getTimerCount()).toBe(0) + }) }) describe('fetchPluginKillList', () => { @@ -146,6 +201,7 @@ describe('fetchPluginKillList', () => { ) await expect(fetchPluginKillList(fetcher)).resolves.toEqual(killList()) + expect(fetcher.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal) }) it('rejects non-success responses', async () => { diff --git a/src/main/plugins/plugin-kill-list-service.ts b/src/main/plugins/catalog/plugin-kill-list-service.ts similarity index 67% rename from src/main/plugins/plugin-kill-list-service.ts rename to src/main/plugins/catalog/plugin-kill-list-service.ts index 1148b6ba1f3..e393ee30062 100644 --- a/src/main/plugins/plugin-kill-list-service.ts +++ b/src/main/plugins/catalog/plugin-kill-list-service.ts @@ -4,11 +4,18 @@ import { pluginKillListSchema, type PluginKillList, type PluginKillListEntry -} from '../../shared/plugins/plugin-kill-list' +} from '../../../shared/plugins/catalog/plugin-kill-list' import { PluginKillListStore } from './plugin-kill-list-store' export const PLUGIN_KILL_LIST_URL = 'https://onorca.dev/plugins/kill-list.json' const PLUGIN_KILL_LIST_DOWNLOAD_LIMIT = 4 * 1024 * 1024 +// Why: revocations must reach sessions that stay open for days, not only fresh launches. +export const PLUGIN_KILL_LIST_REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000 +// Why: a failed refresh must not leave revocations hours stale until the next interval. +export const PLUGIN_KILL_LIST_RETRY_MS = 15 * 60 * 1000 +const PLUGIN_KILL_LIST_RETRY_CAP_MS = 2 * 60 * 60 * 1000 +// Why: refreshes are chained, so one stalled request would block every later one. +const PLUGIN_KILL_LIST_FETCH_TIMEOUT_MS = 30_000 type PluginKillListFetcher = () => Promise @@ -18,11 +25,10 @@ export class PluginKillListService { private readonly listeners = new Set<() => void>() private currentList: PluginKillList | null = null private loadPromise: Promise | null = null - private refreshChain: Promise = Promise.resolve({ - version: 1, - generatedAt: '1970-01-01T00:00:00Z', - plugins: [] - }) + private periodicTimer: ReturnType | null = null + private retryTimer: ReturnType | null = null + private failures = 0 + private refreshChain: Promise = Promise.resolve(emptyKillList()) constructor(options: { pluginsDataDir: string @@ -69,9 +75,61 @@ export class PluginKillListService { .catch(() => this.currentList ?? emptyKillList()) .then(() => this.performRefresh()) this.refreshChain = refresh + refresh.then( + () => { + this.failures = 0 + }, + () => this.scheduleRetry() + ) return refresh } + private scheduleRetry(): void { + if (!this.periodicTimer || this.retryTimer) { + return + } + this.failures += 1 + const delay = Math.min( + PLUGIN_KILL_LIST_RETRY_MS * 2 ** (this.failures - 1), + PLUGIN_KILL_LIST_RETRY_CAP_MS + ) + this.retryTimer = setTimeout(() => { + this.retryTimer = null + this.refreshInBackground() + }, delay) + this.retryTimer.unref?.() + } + + /** Keeps a background refresh running while plugins are on; stops it when they are off. */ + syncPeriodicRefresh(enabled: boolean, intervalMs = PLUGIN_KILL_LIST_REFRESH_INTERVAL_MS): void { + if (!enabled) { + this.dispose() + return + } + if (this.periodicTimer) { + return + } + this.periodicTimer = setInterval(() => this.refreshInBackground(), intervalMs) + this.periodicTimer.unref?.() + } + + private refreshInBackground(): void { + void this.refresh().catch((error) => + console.warn('[plugins] failed to refresh plugin safety list; using cached state:', error) + ) + } + + dispose(): void { + if (this.periodicTimer) { + clearInterval(this.periodicTimer) + this.periodicTimer = null + } + if (this.retryTimer) { + clearTimeout(this.retryTimer) + this.retryTimer = null + } + } + private async performRefresh(): Promise { await this.initialize() const fetched = pluginKillListSchema.parse(await this.fetcher()) @@ -97,7 +155,10 @@ export async function fetchPluginKillList( fetcher: typeof fetch = fetch, url = PLUGIN_KILL_LIST_URL ): Promise { - const response = await fetcher(url, { cache: 'no-store' }) + const response = await fetcher(url, { + cache: 'no-store', + signal: AbortSignal.timeout(PLUGIN_KILL_LIST_FETCH_TIMEOUT_MS) + }) if (!response.ok) { throw new Error(`plugin kill-list request failed with HTTP ${response.status}`) } diff --git a/src/main/plugins/plugin-kill-list-store.ts b/src/main/plugins/catalog/plugin-kill-list-store.ts similarity index 88% rename from src/main/plugins/plugin-kill-list-store.ts rename to src/main/plugins/catalog/plugin-kill-list-store.ts index 5dca14190fd..bf962580ee6 100644 --- a/src/main/plugins/plugin-kill-list-store.ts +++ b/src/main/plugins/catalog/plugin-kill-list-store.ts @@ -1,8 +1,11 @@ import { createReadStream } from 'node:fs' import { mkdir } from 'node:fs/promises' import { dirname, join } from 'node:path' -import { pluginKillListSchema, type PluginKillList } from '../../shared/plugins/plugin-kill-list' -import { writePluginFileAtomically } from './plugin-atomic-file-write' +import { + pluginKillListSchema, + type PluginKillList +} from '../../../shared/plugins/catalog/plugin-kill-list' +import { writePluginFileAtomically } from '../installation/plugin-atomic-file-write' const PLUGIN_KILL_LIST_MAX_BYTES = 4 * 1024 * 1024 diff --git a/src/main/plugins/plugin-marketplace-error-message.ts b/src/main/plugins/catalog/plugin-marketplace-error-message.ts similarity index 100% rename from src/main/plugins/plugin-marketplace-error-message.ts rename to src/main/plugins/catalog/plugin-marketplace-error-message.ts diff --git a/src/main/plugins/plugin-marketplace-fetch.integration.test.ts b/src/main/plugins/catalog/plugin-marketplace-fetch.integration.test.ts similarity index 77% rename from src/main/plugins/plugin-marketplace-fetch.integration.test.ts rename to src/main/plugins/catalog/plugin-marketplace-fetch.integration.test.ts index c8fc5b4ad19..6368431d937 100644 --- a/src/main/plugins/plugin-marketplace-fetch.integration.test.ts +++ b/src/main/plugins/catalog/plugin-marketplace-fetch.integration.test.ts @@ -2,8 +2,8 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' -import { gitExecFileAsync } from '../git/runner' -import { serveRepositoriesOverTestSsh } from './__mocks__/plugin-git-ssh-remote-fixture' +import { gitExecFileAsync } from '../../git/runner' +import { serveRepositoriesOverTestSsh } from '../__mocks__/plugin-git-ssh-remote-fixture' import { fetchPluginMarketplace } from './plugin-marketplace-fetch' const roots: string[] = [] @@ -12,7 +12,7 @@ afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) }) -it('reads only the catalog index, prefers the v2 file, and ignores plugin size limits', async () => { +it('reads only the catalog index, prefers orca-catalog.json, and ignores plugin size limits', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-marketplace-fetch-')) roots.push(root) const repository = join(root, 'catalog') @@ -30,8 +30,13 @@ it('reads only the catalog index, prefers the v2 file, and ignores plugin size l await writeFile(join(repository, 'packs', `${index}.txt`), String(index)) } await writeFile(join(repository, 'orca-marketplace.json'), catalog('Old', [entry])) - await writeFile(join(repository, 'orca-marketplace.v2.json'), catalog('New', [])) + await writeFile( + join(repository, 'orca-catalog.json'), + JSON.stringify({ catalogVersion: 2, name: 'New', owner: 'samples', plugins: [] }) + ) await git(['init', '--quiet']) + // Git's detached auto-maintenance after commit can still be writing .git during teardown. + await git(['config', 'maintenance.auto', 'false']) await git(['add', '--all']) await git(['-c', 'user.name=t', '-c', 'user.email=t@example.invalid', 'commit', '-qm', 'c']) await git(['tag', 'catalog']) diff --git a/src/main/plugins/plugin-marketplace-fetch.test.ts b/src/main/plugins/catalog/plugin-marketplace-fetch.test.ts similarity index 51% rename from src/main/plugins/plugin-marketplace-fetch.test.ts rename to src/main/plugins/catalog/plugin-marketplace-fetch.test.ts index b966c3a8222..11a85b7bf76 100644 --- a/src/main/plugins/plugin-marketplace-fetch.test.ts +++ b/src/main/plugins/catalog/plugin-marketplace-fetch.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it } from 'vitest' -import { - PLUGIN_MARKETPLACE_FILENAME, - PLUGIN_MARKETPLACE_V2_FILENAME -} from '../../shared/plugins/plugin-marketplace' +import { PLUGIN_CATALOG_FILENAME } from '../../../shared/plugins/catalog/plugin-catalog' +import { PLUGIN_MARKETPLACE_FILENAME } from '../../../shared/plugins/catalog/plugin-marketplace' import { parsePluginMarketplaceIndex } from './plugin-marketplace-fetch' const entry = { @@ -24,31 +22,35 @@ function parse(filename: string, catalog: unknown) { } describe('parsePluginMarketplaceIndex', () => { - it('keeps valid v2 entries next to ones this client cannot install', () => { - const marketplace = parse(PLUGIN_MARKETPLACE_V2_FILENAME, { + it('keeps valid catalog entries next to ones this client cannot install', () => { + const marketplace = parse(PLUGIN_CATALOG_FILENAME, { + catalogVersion: 2, name: 'New', owner: 'samples', futureHeader: true, plugins: [ - { ...entry, id: 'samples.theme', release: { ...release, types: ['icon-theme'] } }, - { ...entry, id: 'samples.broken', release: { ...release, commit: 'nope' } }, - { ...entry, release: { ...release, futureField: 1 }, futureField: 1 } + { ...entry, id: 'samples.theme', releases: [{ ...release, types: ['file-viewer'] }] }, + { ...entry, id: 'samples.broken', releases: [{ ...release, commit: 'nope' }] }, + { ...entry, releases: [{ ...release, futureField: 1 }], futureField: 1 } ] }) - expect(marketplace).toEqual({ name: 'New', owner: 'samples', plugins: [{ ...entry, release }] }) + expect(marketplace).toMatchObject({ + name: 'New', + owner: 'samples', + plugins: [{ ...entry, releases: [release] }] + }) + expect(marketplace.plugins).toHaveLength(1) }) - it('rejects duplicate ids even when one copy is unreadable', () => { + it('refuses a newer catalog format with the fix', () => { expect(() => - parse(PLUGIN_MARKETPLACE_V2_FILENAME, { + parse(PLUGIN_CATALOG_FILENAME, { + catalogVersion: 3, name: 'New', owner: 'samples', - plugins: [ - { ...entry, release }, - { ...entry, release: { ...release, commit: 'nope' } } - ] + plugins: [] }) - ).toThrow('duplicate plugin id') + ).toThrow('orca-catalog.json uses catalog format 3; update Orca to browse this catalog') }) it('keeps release entries out of the frozen v1 file', () => { diff --git a/src/main/plugins/catalog/plugin-marketplace-fetch.ts b/src/main/plugins/catalog/plugin-marketplace-fetch.ts new file mode 100644 index 00000000000..22930f64d45 --- /dev/null +++ b/src/main/plugins/catalog/plugin-marketplace-fetch.ts @@ -0,0 +1,141 @@ +import { tmpdir } from 'node:os' +import { + PLUGIN_MARKETPLACE_FILENAME, + pluginMarketplaceSchema, + type PluginMarketplace +} from '../../../shared/plugins/catalog/plugin-marketplace' +import { + PLUGIN_CATALOG_FILENAME, + pluginCatalogSchema, + pluginCatalogVersionError +} from '../../../shared/plugins/catalog/plugin-catalog' +import { formatPluginSchemaIssues } from '../../../shared/plugins/manifest/plugin-schema-issues' +import { gitExecFileAsyncBuffer } from '../../git/runner' +import { extractCatalogImages } from './plugin-catalog-image-extraction' +import { fillMonorepoReleaseImages } from './plugin-catalog-monorepo-images' +import { readPluginGitCommit } from '../installation/plugin-git-repository' +import type { PluginMarketplaceRegisteredSource } from './plugin-marketplace-store' + +const MARKETPLACE_INDEX_MAX_BYTES = 16 * 1024 * 1024 +// Why its own cap: a catalog repository may hold far more than one plugin, and only its index is read. +const MARKETPLACE_DOWNLOAD_MAX_BYTES = 512 * 1024 * 1024 +const MARKETPLACE_GIT_TIMEOUT_MS = 120_000 + +export type PluginMarketplaceFetchResult = { + marketplaceCommit: string + marketplace: PluginMarketplace + /** Catalog images whose bytes matched their declared hash, size and format. */ + images?: Buffer[] +} + +/** System Git retains private-repo SSH agent and credential-helper support. */ +export async function fetchPluginMarketplace( + source: PluginMarketplaceRegisteredSource +): Promise { + const { commit, value } = await readPluginGitCommit( + { + url: source.source.url, + ref: source.source.ref, + workingDirectory: tmpdir(), + maxDownloadBytes: MARKETPLACE_DOWNLOAD_MAX_BYTES + }, + (repository, commit) => readMarketplaceAtCommit(repository, commit, source.source.url) + ) + return { marketplaceCommit: commit, marketplace: value.marketplace, images: value.images } +} + +/** Reads only the catalog index blobs, so the rest of the repository is never written out. + * Prefers orca-catalog.json; repositories that only publish the frozen v1 file still load. */ +async function readMarketplaceAtCommit( + repository: string, + commit: string, + catalogUrl: string +): Promise<{ marketplace: PluginMarketplace; images: Buffer[] }> { + const options = { cwd: repository, timeout: MARKETPLACE_GIT_TIMEOUT_MS } + const listing = await gitExecFileAsyncBuffer( + ['ls-tree', '-l', '-z', commit, '--', PLUGIN_CATALOG_FILENAME, PLUGIN_MARKETPLACE_FILENAME], + { ...options, maxBuffer: 64 * 1024 } + ) + const blobs = new Map() + for (const record of listing.stdout.toString('utf8').split('\0')) { + const match = /^100(?:644|755) blob ([0-9a-f]{40}) +([0-9]+)\t(.+)$/s.exec(record) + if (match) { + blobs.set(match[3]!, { object: match[1]!, size: Number(match[2]) }) + } + } + const filename = blobs.has(PLUGIN_CATALOG_FILENAME) + ? PLUGIN_CATALOG_FILENAME + : PLUGIN_MARKETPLACE_FILENAME + const blob = blobs.get(filename) + if (!blob) { + throw new Error(`${PLUGIN_MARKETPLACE_FILENAME} not found`) + } + if (blob.size > MARKETPLACE_INDEX_MAX_BYTES) { + throw new Error(`${filename} exceeds its size limit`) + } + const bytes = await gitExecFileAsyncBuffer(['cat-file', 'blob', blob.object], { + ...options, + maxBuffer: MARKETPLACE_INDEX_MAX_BYTES + }) + const readBlob = (path: string, maxBytes: number): Promise => + readCatalogBlob(repository, commit, path, maxBytes) + const marketplace = await fillMonorepoReleaseImages( + parsePluginMarketplaceIndex(filename, bytes.stdout), + catalogUrl, + readBlob + ) + return extractCatalogImages(marketplace, readBlob) +} + +/** One file from the catalog commit, refused before reading when it is larger than allowed. */ +async function readCatalogBlob( + repository: string, + commit: string, + path: string, + maxBytes: number +): Promise { + const options = { cwd: repository, timeout: MARKETPLACE_GIT_TIMEOUT_MS } + const listing = await gitExecFileAsyncBuffer(['ls-tree', '-l', '-z', commit, '--', path], { + ...options, + maxBuffer: 4 * 1024 + }) + const match = /^100(?:644|755) blob ([0-9a-f]{40}) +([0-9]+)\t/.exec( + listing.stdout.toString('utf8') + ) + if (!match?.[1] || Number(match[2]) > maxBytes) { + return null + } + const blob = await gitExecFileAsyncBuffer(['cat-file', 'blob', match[1]], { + ...options, + maxBuffer: maxBytes + }) + return blob.stdout +} + +export function parsePluginMarketplaceIndex(filename: string, bytes: Buffer): PluginMarketplace { + let raw: unknown + try { + raw = JSON.parse(bytes.toString('utf8')) + } catch (error) { + throw new Error( + `invalid ${filename}: ${error instanceof Error ? error.message : String(error)}` + ) + } + if (filename === PLUGIN_CATALOG_FILENAME) { + const tooNew = pluginCatalogVersionError(raw) + if (tooNew) { + throw new Error(`${filename} ${tooNew}`) + } + const parsed = pluginCatalogSchema.safeParse(raw) + if (!parsed.success) { + throw new Error(`invalid ${filename}: ${formatPluginSchemaIssues(parsed.error.issues, raw)}`) + } + const { name, owner, plugins } = parsed.data + return { name, owner, plugins } + } + const parsed = pluginMarketplaceSchema.safeParse(raw) + if (!parsed.success) { + throw new Error(`invalid ${filename}: ${formatPluginSchemaIssues(parsed.error.issues, raw)}`) + } + return parsed.data +} diff --git a/src/main/plugins/catalog/plugin-marketplace-installer.test.ts b/src/main/plugins/catalog/plugin-marketplace-installer.test.ts new file mode 100644 index 00000000000..e6445c5b3ce --- /dev/null +++ b/src/main/plugins/catalog/plugin-marketplace-installer.test.ts @@ -0,0 +1,470 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { + PLUGIN_SCHEMA_NEWEST_SINCE, + pluginHostVersion +} from '../../../shared/plugins/installation/plugin-host-version' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PluginCatalogRelease } from '../../../shared/plugins/catalog/plugin-catalog-release' +import type { PluginMarketplace } from '../../../shared/plugins/catalog/plugin-marketplace' +import { readPluginLockfile, removeInstalledPlugin } from '../installation/plugin-install' +import { PluginImageCache } from '../plugin-image-cache' +import { PluginMarketplaceInstaller } from './plugin-marketplace-installer' +import { PluginMarketplaceService } from './plugin-marketplace-service' + +const git = vi.hoisted( + (): { + checkout: ReturnType + version: string + publisher: string + id: string + commit: string + payload: string + v2: boolean + release: PluginCatalogRelease | undefined + pluginUrl: string + marketplaceCommit: string + worker: boolean + icon: boolean + /** Directories of one repository listed as separate plugins named after them. */ + paths: string[] | null + } => ({ + checkout: vi.fn(), + version: '1.0.0', + publisher: 'community', + id: 'notes', + commit: 'a'.repeat(40), + payload: 'first', + v2: false, + release: undefined, + pluginUrl: 'https://github.com/community/notes.git', + marketplaceCommit: 'f'.repeat(40), + worker: false, + icon: false, + paths: null + }) +) + +vi.mock('../installation/plugin-git-repository', () => ({ + checkoutPluginGitSource: git.checkout +})) + +const roots: string[] = [] + +async function tempRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-marketplace-installer-')) + roots.push(root) + return root +} + +function marketplace(): PluginMarketplace { + if (git.paths) { + return { + name: 'Community', + owner: 'community', + plugins: git.paths.map((path) => ({ + id: `community.${path.split('/').at(-1)}`, + source: { kind: 'git', url: git.pluginUrl, ref: 'stable', path }, + categories: [] + })) + } + } + return { + name: 'Community', + owner: 'community', + plugins: [ + { + id: 'community.notes', + source: { + kind: 'git', + url: git.pluginUrl, + ref: 'stable' + }, + categories: ['productivity'], + ...(git.release ? { releases: [git.release] } : {}) + } + ] + } +} + +async function writeCurrentPlugin(destination: string, path?: string): Promise { + await mkdir(destination, { recursive: true }) + await writeFile( + join(destination, 'orca-plugin.json'), + JSON.stringify({ + ...(git.v2 + ? { + manifestVersion: 2, + execution: 'declarative', + engines: { orca: `>=${PLUGIN_SCHEMA_NEWEST_SINCE}` }, + contributes: { commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }] } + } + : { manifestVersion: 1, engines: { orca: '>=1.0.0' }, pluginApi: 1 }), + id: path?.split('/').at(-1) ?? git.id, + publisher: git.publisher, + name: 'Notes', + version: git.version, + capabilities: [], + ...(git.worker ? { main: 'worker.js' } : {}), + ...(git.icon ? { icon: 'icon.png' } : {}) + }) + ) + if (git.icon) { + await writeFile(join(destination, 'icon.png'), squarePng(128)) + } + await writeFile(join(destination, 'payload.txt'), git.payload) + if (git.worker) { + await writeFile(join(destination, 'worker.js'), '') + } +} + +function squarePng(side: number): Buffer { + const be32 = (n: number) => [n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255] + const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] + const ihdr = [0, 0, 0, 13, ...Buffer.from('IHDR'), ...be32(side), ...be32(side), 8, 6, 0, 0, 0] + return Buffer.from([...signature, ...ihdr, 0, 0, 0, 0]) +} + +async function setup(imageCache?: PluginImageCache): Promise<{ + root: string + marketplace: PluginMarketplaceService + installer: PluginMarketplaceInstaller + sourceId: string +}> { + const root = await tempRoot() + const service = new PluginMarketplaceService({ + pluginsDataDir: join(root, 'plugins-data'), + fetcher: async () => ({ marketplaceCommit: git.marketplaceCommit, marketplace: marketplace() }) + }) + const added = await service.addSource({ + kind: 'git', + url: 'https://github.com/community/plugins.git', + ref: 'main' + }) + return { + root, + marketplace: service, + installer: new PluginMarketplaceInstaller({ + marketplace: service, + userDataPath: root, + hostVersion: pluginHostVersion('1.4.0'), + ...(imageCache ? { imageCache } : {}) + }), + sourceId: added.id + } +} + +beforeEach(() => { + git.version = '1.0.0' + git.publisher = 'community' + git.id = 'notes' + git.commit = 'a'.repeat(40) + git.payload = 'first' + git.v2 = false + git.release = undefined + git.pluginUrl = 'https://github.com/community/notes.git' + git.marketplaceCommit = 'f'.repeat(40) + git.worker = false + git.icon = false + git.paths = null + git.checkout.mockReset() + git.checkout.mockImplementation( + async ({ destination, path }: { destination: string; path?: string }) => { + await writeCurrentPlugin(destination, path) + return git.commit + } + ) +}) + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('PluginMarketplaceInstaller', () => { + it('previews exact validated bytes and records marketplace provenance on install', async () => { + const { root, installer, sourceId } = await setup() + + const preview = await installer.preview(sourceId, 'community.notes') + expect(preview).toMatchObject({ + pluginKey: 'community.notes', + resolvedCommit: 'a'.repeat(40), + marketplaceCommit: 'f'.repeat(40), + manifest: { version: '1.0.0' } + }) + const result = await installer.install(preview) + + if (!result.ok) { + throw new Error(result.error) + } + expect(result).toMatchObject({ ok: true, resolvedCommit: 'a'.repeat(40) }) + const lock = await readPluginLockfile(join(root, 'plugins')) + expect(lock.plugins['community.notes']?.source).toEqual({ + kind: 'marketplace', + marketplace: { + url: 'https://github.com/community/plugins.git', + ref: 'main', + resolvedCommit: 'f'.repeat(40) + }, + plugin: { + url: 'https://github.com/community/notes.git', + ref: 'stable' + } + }) + }) + + it('previews without the image when the image cache cannot save it', async () => { + git.v2 = true + git.icon = true + const cache = new PluginImageCache(join(await tempRoot(), 'images')) + vi.spyOn(cache, 'put').mockRejectedValue( + Object.assign(new Error('rename failed'), { code: 'ENOENT' }) + ) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { installer, sourceId } = await setup(cache) + + const preview = await installer.preview(sourceId, 'community.notes') + expect(preview.manifest.icon).toBe('icon.png') + expect(preview.images?.icon).toBeUndefined() + expect(warn).toHaveBeenCalled() + warn.mockRestore() + }) + + it('requires a fresh review when the plugin ref moves after preview', async () => { + const { root, installer, sourceId } = await setup() + const preview = await installer.preview(sourceId, 'community.notes') + git.commit = 'b'.repeat(40) + git.version = '2.0.0' + + await expect(installer.install(preview)).resolves.toEqual({ + ok: false, + error: 'plugin source changed after preview; review the update again' + }) + await expect(readFile(join(root, 'plugins', 'plugins.lock.json'))).rejects.toMatchObject({ + code: 'ENOENT' + }) + }) + + it('installs only the exact bytes a catalog release pins', async () => { + git.v2 = true + const unpinnedSetup = await setup() + const unpinned = await unpinnedSetup.installer.preview( + unpinnedSetup.sourceId, + 'community.notes' + ) + git.release = { + version: '1.0.0', + commit: git.commit, + contentHash: unpinned.contentHash, + execution: 'declarative', + minimumOrca: PLUGIN_SCHEMA_NEWEST_SINCE, + types: ['instructions'] + } + const { installer, sourceId } = await setup() + const preview = await installer.preview(sourceId, 'community.notes') + // The pinned commit is fetched directly, so the listed branch is display text only. + expect(git.checkout).toHaveBeenLastCalledWith(expect.objectContaining({ ref: git.commit })) + await expect(installer.install(preview)).resolves.toMatchObject({ ok: true }) + expect(git.checkout).toHaveBeenLastCalledWith(expect.objectContaining({ ref: git.commit })) + git.payload = 'tampered at the same commit' + + await expect(installer.install(preview)).resolves.toEqual({ + ok: false, + error: 'plugin bytes do not match the catalog release' + }) + await expect(installer.preview(sourceId, 'community.notes')).rejects.toThrow( + 'plugin bytes do not match the catalog release' + ) + }) + + it('lists a release for a newer Orca but refuses it before downloading', async () => { + git.release = { + version: '1.0.0', + commit: git.commit, + contentHash: 'c'.repeat(64), + execution: 'declarative', + minimumOrca: '9.0.0', + types: ['instructions'] + } + const { marketplace, installer, sourceId } = await setup() + await expect(marketplace.findPlugin(sourceId, 'community.notes')).resolves.toBeTruthy() + + await expect(installer.preview(sourceId, 'community.notes')).rejects.toThrow( + `requires Orca >=9.0.0 (this is ${PLUGIN_SCHEMA_NEWEST_SINCE})` + ) + await expect( + installer.install({ + marketplaceSourceId: sourceId, + marketplaceCommit: 'f'.repeat(40), + pluginKey: 'community.notes', + resolvedCommit: git.commit + }) + ).resolves.toEqual({ + ok: false, + error: `requires Orca >=9.0.0 (this is ${PLUGIN_SCHEMA_NEWEST_SINCE})` + }) + expect(git.checkout).not.toHaveBeenCalled() + }) + + it('installs a full-access plugin with no global switch; approval stays per plugin', async () => { + git.worker = true + const { installer, sourceId, root } = await setup() + const preview = await installer.preview(sourceId, 'community.notes') + await expect(installer.install(preview)).resolves.toMatchObject({ ok: true }) + expect(Object.keys((await readPluginLockfile(join(root, 'plugins'))).plugins)).toEqual([ + 'community.notes' + ]) + }) + + it('rejects a source whose manifest identity differs from its listing', async () => { + const { installer, sourceId } = await setup() + git.publisher = 'attacker' + + await expect(installer.preview(sourceId, 'community.notes')).rejects.toThrow( + 'attacker.notes does not match marketplace listing community.notes' + ) + }) + + it('updates from recorded marketplace provenance and rolls back one immutable version', async () => { + const { root, installer, sourceId } = await setup() + const firstPreview = await installer.preview(sourceId, 'community.notes') + const firstInstall = await installer.install(firstPreview) + expect(firstInstall.ok).toBe(true) + if (!firstInstall.ok) { + return + } + + git.commit = 'b'.repeat(40) + git.version = '2.0.0' + git.payload = 'second' + const updatePreview = await installer.previewInstalledUpdate('community.notes') + expect(updatePreview).toMatchObject({ resolvedCommit: 'b'.repeat(40) }) + await expect(installer.install(updatePreview)).resolves.toMatchObject({ + ok: true, + version: '2.0.0' + }) + await expect(installer.install(updatePreview)).resolves.not.toHaveProperty('repositoryMoved') + + await expect(installer.rollbackVersion('community.notes')).resolves.toBe('1.0.0') + await expect(installer.rollbackVersion('not a key')).resolves.toBeNull() + await expect(installer.rollback('community.notes')).resolves.toMatchObject({ + ok: true, + version: '1.0.0', + contentHash: firstInstall.contentHash + }) + const lock = await readPluginLockfile(join(root, 'plugins')) + expect(lock.plugins['community.notes']).toMatchObject({ + version: '1.0.0', + resolvedCommit: 'a'.repeat(40) + }) + }) + + it('keeps an install pinned to its catalog when a second catalog lists the same id', async () => { + const { root, marketplace, installer, sourceId } = await setup() + await installer.install(await installer.preview(sourceId, 'community.notes')) + const mirror = await marketplace.addSource({ + kind: 'git', + url: 'https://gitlab.com/mirror/group/plugins.git', + ref: 'main' + }) + git.commit = 'b'.repeat(40) + git.version = '2.0.0' + + const switched = await installer.install(await installer.preview(mirror.id, 'community.notes')) + + expect(switched).toMatchObject({ ok: false, error: expect.stringContaining('remove it first') }) + const lock = await readPluginLockfile(join(root, 'plugins')) + expect(lock.plugins['community.notes']).toMatchObject({ + version: '1.0.0', + source: { marketplace: { url: 'https://github.com/community/plugins.git' } } + }) + await expect(installer.previewInstalledUpdate('community.notes')).resolves.toMatchObject({ + marketplaceSourceId: sourceId + }) + }) + + it('lets a Remove that starts during an automatic update win', async () => { + const { root, installer, sourceId } = await setup() + await installer.install(await installer.preview(sourceId, 'community.notes')) + git.commit = 'b'.repeat(40) + git.payload = 'second' + const update = await installer.previewInstalledUpdate('community.notes') + const pluginsDir = join(root, 'plugins') + const [, result] = await Promise.all([ + removeInstalledPlugin({ + pluginsDir, + pluginsDataDir: join(root, 'plugins-data'), + pluginKey: 'community.notes' + }), + installer.install(update, { requireInstalled: true }) + ]) + expect(result).toMatchObject({ ok: false, error: expect.stringContaining('removed') }) + expect((await readPluginLockfile(pluginsDir)).plugins).toEqual({}) + }) + + it('flags an update whose catalog moved the plugin to a different repository', async () => { + const { marketplace: service, installer, sourceId } = await setup() + await installer.install(await installer.preview(sourceId, 'community.notes')) + + git.pluginUrl = 'git@github.com:Community/Notes' + git.marketplaceCommit = 'e'.repeat(40) + await service.refreshSource(sourceId) + git.payload = 'respelled' + const respelledPreview = await installer.previewInstalledUpdate('community.notes') + expect(respelledPreview).not.toHaveProperty('repositoryMoved') + const respelled = await installer.install(respelledPreview) + expect(respelled).toMatchObject({ ok: true }) + expect(respelled).not.toHaveProperty('repositoryMoved') + + git.pluginUrl = 'https://github.com/someone-else/notes.git' + git.marketplaceCommit = 'd'.repeat(40) + await service.refreshSource(sourceId) + git.payload = 'moved' + const movedPreview = await installer.previewInstalledUpdate('community.notes') + expect(movedPreview).toMatchObject({ repositoryMoved: true }) + await expect(installer.install(movedPreview)).resolves.toMatchObject({ + ok: true, + repositoryMoved: true + }) + }) + + it('installs two plugins from one repository side by side, each from its own directory', async () => { + git.paths = ['plugins/notes', 'plugins/tasks'] + const { root, installer, sourceId } = await setup() + for (const key of ['community.notes', 'community.tasks']) { + await expect( + installer.install(await installer.preview(sourceId, key)) + ).resolves.toMatchObject({ ok: true, pluginKey: key }) + } + expect(git.checkout.mock.calls.map(([input]) => input.path)).toEqual([ + 'plugins/notes', + 'plugins/notes', + 'plugins/tasks', + 'plugins/tasks' + ]) + const lock = await readPluginLockfile(join(root, 'plugins')) + expect(lock.plugins['community.notes']?.source).toMatchObject({ + plugin: { url: git.pluginUrl, ref: 'stable', path: 'plugins/notes' } + }) + expect(lock.plugins['community.tasks']?.source).toMatchObject({ + plugin: { path: 'plugins/tasks' } + }) + }) + + it('treats a changed directory in the same repository as a moved plugin', async () => { + git.paths = ['plugins/notes'] + const { marketplace: service, installer, sourceId } = await setup() + await installer.install(await installer.preview(sourceId, 'community.notes')) + + git.paths = ['archive/notes'] + git.marketplaceCommit = 'e'.repeat(40) + await service.refreshSource(sourceId) + git.payload = 'moved' + const movedPreview = await installer.previewInstalledUpdate('community.notes') + expect(movedPreview).toMatchObject({ repositoryMoved: true }) + await expect(installer.install(movedPreview)).resolves.toMatchObject({ + ok: true, + repositoryMoved: true + }) + }) +}) diff --git a/src/main/plugins/catalog/plugin-marketplace-installer.ts b/src/main/plugins/catalog/plugin-marketplace-installer.ts new file mode 100644 index 00000000000..32db4f4831b --- /dev/null +++ b/src/main/plugins/catalog/plugin-marketplace-installer.ts @@ -0,0 +1,305 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { PluginManifest } from '../../../shared/plugins/manifest/plugin-manifest' +import { PLUGIN_IMAGE_RULES } from '../../../shared/plugins/images/plugin-image-fields' +import { + pluginShaImageUrl, + type PluginHostImages +} from '../../../shared/plugins/images/plugin-image-url' +import { readContainedPluginArtifactBytes } from '../plugin-artifact-validation' +import { declaredPluginImages } from '../plugin-image-artifact-validation' +import type { PluginImageCache } from '../plugin-image-cache' +import { pluginManifestHostImages } from '../plugin-manifest-images' +import { readPluginVmRecipeIdentities } from '../plugin-vm-recipe-identities' +import type { PluginVmRecipeIdentities } from '../../../shared/plugins/approval/plugin-update-access-growth' +import { pluginCatalogReleaseCompatibilityError } from '../../../shared/plugins/manifest/plugin-manifest-compatibility' +import { isQualifiedPluginKey } from '../../../shared/plugins/manifest/plugin-manifest' +import { getUserPluginsDir } from '../plugin-discovery' +import { readPluginRollbackVersion } from '../installation/plugin-rollback-candidate' +import { checkoutPluginGitSource } from '../installation/plugin-git-repository' +import { + installPluginFromMarketplace, + readPluginLockfile, + rollbackInstalledPlugin, + type PluginInstallResult +} from '../installation/plugin-install' +import { inspectPluginInstallTree } from '../installation/plugin-install-staging' +import type { + PluginMarketplaceListing, + PluginMarketplaceService +} from './plugin-marketplace-service' +import { marketplaceSourceId } from './plugin-marketplace-store' +import { isSamePluginSource } from '../../../shared/plugins/catalog/plugin-marketplace' +import type { PluginInstallSource } from '../../../shared/plugins/installation/plugin-install-lockfile' + +export type PluginMarketplaceInstallPreview = { + marketplaceSourceId: string + marketplaceName: string + marketplaceOwner: string + marketplaceCommit: string + pluginKey: string + source: PluginMarketplaceListing['source'] + resolvedCommit: string + contentHash: string + consentFingerprint: string + manifest: PluginManifest + official: boolean + bundled: boolean + blockedByKillList?: { reason: string; advisoryUrl?: string } + /** The manifest's images, verified at preview and cached by hash. */ + images?: PluginHostImages + /** What each recipe file can run, read before the staging tree is deleted; null if unreadable. */ + vmRecipeIdentities?: PluginVmRecipeIdentities + /** Update previews only: the catalog now points at a different repository than the install. */ + repositoryMoved?: true +} + +/** `repositoryMoved`: this replaced an install that came from a different repository. */ +export type PluginMarketplaceInstallResult = PluginInstallResult & { repositoryMoved?: true } + +type MarketplaceInstallSource = Extract + +export type PluginMarketplacePreviewIdentity = Pick< + PluginMarketplaceInstallPreview, + 'marketplaceSourceId' | 'marketplaceCommit' | 'pluginKey' | 'resolvedCommit' +> + +export class PluginMarketplaceInstaller { + private readonly marketplace: PluginMarketplaceService + private readonly userDataPath: string + private readonly hostVersion: string + private readonly blockedPluginReason: (pluginKey: string) => string | null + private readonly imageCache: PluginImageCache | null + + constructor(options: { + marketplace: PluginMarketplaceService + userDataPath: string + hostVersion: string + blockedPluginReason?: (pluginKey: string) => string | null + imageCache?: PluginImageCache + }) { + this.imageCache = options.imageCache ?? null + this.marketplace = options.marketplace + this.userDataPath = options.userDataPath + this.hostVersion = options.hostVersion + this.blockedPluginReason = options.blockedPluginReason ?? (() => null) + } + + async preview( + marketplaceSourceId: string, + pluginKey: string + ): Promise { + const listing = await this.requireListing(marketplaceSourceId, pluginKey) + const incompatible = this.releaseCompatibilityError(listing) + if (incompatible) { + throw new Error(incompatible) + } + const stagingDirectory = await mkdtemp(join(tmpdir(), 'orca-plugin-marketplace-preview-')) + try { + const resolvedCommit = await checkoutPluginGitSource({ + url: listing.source.url, + // Why: a pinned release names its commit; the listed ref is only what the author calls it. + ref: listing.release?.commit ?? listing.source.ref, + ...(listing.source.path ? { path: listing.source.path } : {}), + destination: stagingDirectory, + workingDirectory: tmpdir() + }) + const inspection = await inspectPluginInstallTree({ + rootDir: stagingDirectory, + hostVersion: this.hostVersion, + expectedPluginKey: pluginKey, + ...(listing.release ? { release: { record: listing.release, resolvedCommit } } : {}) + }) + if (!inspection.ok) { + throw new Error(inspection.error) + } + const images = await this.cachePreviewImages(stagingDirectory, inspection.manifest) + const vmRecipeIdentities = + inspection.manifest.contributes.vmRecipes.length > 0 + ? await readPluginVmRecipeIdentities(stagingDirectory, inspection.manifest) + : undefined + return { + marketplaceSourceId, + marketplaceName: listing.marketplaceName, + marketplaceOwner: listing.marketplaceOwner, + marketplaceCommit: listing.marketplaceCommit, + pluginKey, + source: listing.source, + resolvedCommit, + contentHash: inspection.contentHash, + consentFingerprint: inspection.consentFingerprint, + manifest: inspection.manifest, + official: listing.official, + bundled: listing.bundled, + ...(listing.blockedByKillList ? { blockedByKillList: listing.blockedByKillList } : {}), + ...(images ? { images } : {}), + ...(vmRecipeIdentities !== undefined ? { vmRecipeIdentities } : {}) + } + } finally { + await rm(stagingDirectory, { recursive: true, force: true }) + } + } + + async install( + preview: PluginMarketplacePreviewIdentity, + options: { requireInstalled?: boolean } = {} + ): Promise { + const listing = await this.requireListing(preview.marketplaceSourceId, preview.pluginKey) + const blockedReason = + listing.blockedByKillList?.reason ?? this.blockedPluginReason(preview.pluginKey) + if (blockedReason) { + return { ok: false, error: `plugin is blocked by Orca's safety list: ${blockedReason}` } + } + const incompatible = this.releaseCompatibilityError(listing) + if (incompatible) { + return { ok: false, error: incompatible } + } + if (listing.marketplaceCommit !== preview.marketplaceCommit) { + return { ok: false, error: 'marketplace changed after preview; review the plugin again' } + } + const sourceState = (await this.marketplace.listSources()).find( + (source) => source.id === preview.marketplaceSourceId + ) + if (!sourceState) { + return { ok: false, error: 'marketplace source is no longer configured' } + } + // Why read first: the install rewrites the lock entry this comparison needs. + const installed = await this.readMarketplaceInstall(preview.pluginKey) + const repositoryMoved = + installed !== null && !isSamePluginSource(installed.plugin, listing.source) + const result = await installPluginFromMarketplace({ + pluginsDir: getUserPluginsDir(this.userDataPath), + hostVersion: this.hostVersion, + expectedPluginKey: preview.pluginKey, + expectedResolvedCommit: preview.resolvedCommit, + marketplace: { + url: sourceState.source.url, + ref: sourceState.source.ref, + resolvedCommit: preview.marketplaceCommit + }, + plugin: { + url: listing.source.url, + ref: listing.source.ref, + ...(listing.source.path ? { path: listing.source.path } : {}) + }, + ...(listing.release ? { release: listing.release } : {}), + blockedPluginReason: this.blockedPluginReason, + ...(options.requireInstalled ? { requireInstalled: true } : {}) + }) + return result.ok && repositoryMoved ? { ...result, repositoryMoved: true } : result + } + + /** The catalog an installed plugin came from, or null when it was not a marketplace install. */ + private async readMarketplaceInstall( + pluginKey: string + ): Promise<{ sourceId: string; plugin: MarketplaceInstallSource['plugin'] } | null> { + const entry = (await readPluginLockfile(getUserPluginsDir(this.userDataPath))).plugins[ + pluginKey + ] + if (entry?.source.kind !== 'marketplace') { + return null + } + const { marketplace, plugin } = entry.source + return { + sourceId: marketplaceSourceId({ kind: 'git', url: marketplace.url, ref: marketplace.ref }), + plugin + } + } + + async previewInstalledUpdate(pluginKey: string): Promise { + const installed = await this.readMarketplaceInstall(pluginKey) + if (!installed) { + throw new Error(`plugin ${pluginKey} was not installed from a marketplace`) + } + const preview = await this.preview(installed.sourceId, pluginKey) + return isSamePluginSource(installed.plugin, preview.source) + ? preview + : { ...preview, repositoryMoved: true } + } + + /** The own-catalog listing an installed plugin would update from, read from caches only. */ + async findInstalledUpdateListing(pluginKey: string): Promise<{ + listing: PluginMarketplaceListing + officialCatalog: boolean + } | null> { + const installed = await this.readMarketplaceInstall(pluginKey) + const listing = installed + ? await this.marketplace.findPlugin(installed.sourceId, pluginKey) + : null + if (!installed || !listing) { + return null + } + const source = (await this.marketplace.listSources()).find( + ({ id }) => id === installed.sourceId + ) + return { listing, officialCatalog: source?.official === true } + } + + /** What `rollback` would restore, read only when the UI asks. */ + async rollbackVersion(pluginKey: string): Promise { + return isQualifiedPluginKey(pluginKey) + ? readPluginRollbackVersion(join(getUserPluginsDir(this.userDataPath), pluginKey)) + : null + } + + async rollback(pluginKey: string): Promise { + return rollbackInstalledPlugin({ + pluginsDir: getUserPluginsDir(this.userDataPath), + pluginKey, + hostVersion: this.hostVersion, + blockedPluginReason: this.blockedPluginReason + }) + } + + /** Why cache now: the staging tree is deleted after preview, and images were already checked by + * the install inspection, so their hashes are the only stable names. */ + private async cachePreviewImages( + rootDir: string, + manifest: PluginManifest + ): Promise { + const cache = this.imageCache + if (!cache) { + return undefined + } + const hashes = new Map() + for (const image of declaredPluginImages(manifest)) { + const bytes = await readContainedPluginArtifactBytes( + rootDir, + image.path, + PLUGIN_IMAGE_RULES[image.kind].maxBytes + ).catch(() => null) + // A cache failure only costs the image; it must never fail the preview or update. + const sha256 = bytes + ? await cache.put(bytes).catch((error: unknown) => { + console.warn(`[plugins] could not cache image ${image.path}:`, error) + return null + }) + : null + if (sha256) { + hashes.set(image.path, sha256) + } + } + return pluginManifestHostImages(manifest, (_slot, path) => { + const sha256 = hashes.get(path) + return sha256 ? pluginShaImageUrl(sha256) : null + }) + } + + private releaseCompatibilityError(listing: PluginMarketplaceListing): string | null { + return listing.release + ? pluginCatalogReleaseCompatibilityError(listing.release, this.hostVersion) + : null + } + + private async requireListing( + marketplaceSourceId: string, + pluginKey: string + ): Promise { + const listing = await this.marketplace.findPlugin(marketplaceSourceId, pluginKey) + if (!listing) { + throw new Error(`plugin ${pluginKey} is not listed by marketplace ${marketplaceSourceId}`) + } + return listing + } +} diff --git a/src/main/plugins/catalog/plugin-marketplace-projection.test.ts b/src/main/plugins/catalog/plugin-marketplace-projection.test.ts new file mode 100644 index 00000000000..2fce3e034eb --- /dev/null +++ b/src/main/plugins/catalog/plugin-marketplace-projection.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { pluginMarketplaceListingSchema } from '../../../shared/plugins/catalog/plugin-marketplace' +import { pluginMarketplaceListing } from './plugin-marketplace-projection' + +describe('pluginMarketplaceListing trust', () => { + const listing = (catalogUrl: string, pluginKey: string) => { + const source = { kind: 'git' as const, url: catalogUrl, ref: 'main' } + const marketplace = pluginMarketplaceListingSchema.parse({ + name: 'Orca Plugins', + owner: 'stablyai', + plugins: [ + { + id: pluginKey, + source: { kind: 'git', url: 'https://github.com/stablyai/orca-plugins.git', ref: 'v1' } + } + ] + }) + return pluginMarketplaceListing({ + source: { id: 'a'.repeat(32), source, addedAt: 0 }, + snapshot: { + schemaVersion: 2, + sourceId: 'a'.repeat(32), + source, + marketplaceCommit: 'b'.repeat(40), + fetchedAt: 0, + marketplace + }, + entry: marketplace.plugins[0]!, + hostVersion: null, + blocked: null + }) + } + + it('marks official what the official catalog lists, whatever the id', () => { + const official = 'https://github.com/stablyai/orca-plugins.git' + expect(listing(official, 'stablyai.spreadsheet').official).toBe(true) + expect(listing(official, 'stablyai.orca-gleam').official).toBe(true) + expect( + listing('https://github.com/acme/orca-plugins.git', 'stablyai.orca-gleam').official + ).toBe(false) + }) +}) diff --git a/src/main/plugins/catalog/plugin-marketplace-projection.ts b/src/main/plugins/catalog/plugin-marketplace-projection.ts new file mode 100644 index 00000000000..a93bfaf3ec6 --- /dev/null +++ b/src/main/plugins/catalog/plugin-marketplace-projection.ts @@ -0,0 +1,163 @@ +import { + OFFICIAL_MARKETPLACE_OWNER, + isOfficialMarketplaceGitSource, + isOfficialOrganizationGitSource, + type PluginMarketplaceEntry, + type PluginMarketplaceGitSource +} from '../../../shared/plugins/catalog/plugin-marketplace' +import type { PluginKillListEntry } from '../../../shared/plugins/catalog/plugin-kill-list' +import type { + PluginMarketplaceCachedSnapshot, + PluginMarketplaceRegisteredSource +} from './plugin-marketplace-store' +import { selectPluginCatalogRelease } from '../../../shared/plugins/catalog/plugin-catalog' +import { + pluginShaImageUrl, + type PluginHostImages +} from '../../../shared/plugins/images/plugin-image-url' +import type { PluginCatalogRelease } from '../../../shared/plugins/catalog/plugin-catalog-release' + +export type PluginMarketplaceSourceState = { + id: string + source: PluginMarketplaceGitSource + addedAt: number + marketplace: { + name: string + owner: string + resolvedCommit: string + fetchedAt: number + } | null + stale: boolean + official: boolean + error?: string +} + +export type PluginMarketplaceListing = { + marketplaceSourceId: string + marketplaceName: string + marketplaceOwner: string + marketplaceCommit: string + pluginKey: string + source: PluginMarketplaceEntry['source'] + description?: string + categories: string[] + official: boolean + bundled: boolean + blockedByKillList?: { reason: string; advisoryUrl?: string } + /** The release offered to this host: the newest it can install, else the one needing the least + * newer Orca. Absent for v1 catalogs. */ + release?: PluginCatalogRelease + /** Every release the catalog lists for this plugin, newest first. */ + releases?: PluginCatalogRelease[] + /** The offered release's images, served by main from verified bytes. */ + images?: PluginHostImages +} & PluginCatalogListingDetails + +/** Display fields a release carries, flattened so list rows need not know release records. */ +export type PluginCatalogListingDetails = { + name?: string + version?: string + license?: string + repository?: string + homepage?: string + updatedAt?: string + changelog?: string +} + +const DETAIL_KEYS = [ + 'name', + 'license', + 'repository', + 'homepage', + 'updatedAt', + 'changelog' +] as const satisfies readonly (keyof PluginCatalogListingDetails & keyof PluginCatalogRelease)[] + +/** Catalog images as the renderer loads them: hash-addressed, verified, never hotlinked. */ +export function pluginCatalogListingImages(release: PluginCatalogRelease | undefined): { + images?: PluginHostImages +} { + const images = release?.images + if (!images) { + return {} + } + const host = (image: { sha256: string; alt: string; width: number; height: number }) => ({ + url: pluginShaImageUrl(image.sha256), + alt: image.alt, + width: image.width, + height: image.height + }) + return { + images: { + ...(images.icon ? { icon: host(images.icon) } : {}), + ...(images.cover ? { cover: host(images.cover) } : {}), + ...(images.screenshots?.length ? { screenshots: images.screenshots.map(host) } : {}) + } + } +} + +/** The release to offer this host; without a host version, the newest. */ +export function offeredCatalogRelease( + releases: readonly PluginCatalogRelease[] | undefined, + hostVersion: string | null +): PluginCatalogRelease | undefined { + if (!releases) { + return undefined + } + return hostVersion ? selectPluginCatalogRelease(releases, hostVersion) : releases[0] +} + +export function pluginCatalogListingDetails( + release: PluginCatalogRelease | undefined +): PluginCatalogListingDetails { + if (!release) { + return {} + } + const details: PluginCatalogListingDetails = { version: release.version } + for (const key of DETAIL_KEYS) { + const value = release[key] + if (value !== undefined) { + details[key] = value + } + } + return details +} + +/** One catalog entry as clients see it: trust is derived here, never taken from the catalog. */ +export function pluginMarketplaceListing(input: { + source: PluginMarketplaceRegisteredSource + snapshot: PluginMarketplaceCachedSnapshot + entry: PluginMarketplaceEntry + hostVersion: string | null + blocked: PluginKillListEntry | null +}): PluginMarketplaceListing { + const { source, snapshot, entry, blocked } = input + const release = offeredCatalogRelease(entry.releases, input.hostVersion) + const official = + isOfficialMarketplaceGitSource(source.source.url) && + snapshot.marketplace.owner.toLowerCase() === OFFICIAL_MARKETPLACE_OWNER && + isOfficialOrganizationGitSource(entry.source.url) + return { + marketplaceSourceId: source.id, + marketplaceName: snapshot.marketplace.name, + marketplaceOwner: snapshot.marketplace.owner, + marketplaceCommit: snapshot.marketplaceCommit, + pluginKey: entry.id, + source: entry.source, + ...(entry.description ? { description: entry.description } : {}), + categories: entry.categories, + official, + bundled: false, + ...(release ? { release, releases: entry.releases } : {}), + ...pluginCatalogListingDetails(release), + ...pluginCatalogListingImages(release), + ...(blocked + ? { + blockedByKillList: { + reason: blocked.reason, + ...(blocked.advisoryUrl ? { advisoryUrl: blocked.advisoryUrl } : {}) + } + } + : {}) + } +} diff --git a/src/main/plugins/plugin-marketplace-provenance.ts b/src/main/plugins/catalog/plugin-marketplace-provenance.ts similarity index 57% rename from src/main/plugins/plugin-marketplace-provenance.ts rename to src/main/plugins/catalog/plugin-marketplace-provenance.ts index fbef2ce69f8..7fb8d7ee745 100644 --- a/src/main/plugins/plugin-marketplace-provenance.ts +++ b/src/main/plugins/catalog/plugin-marketplace-provenance.ts @@ -3,7 +3,7 @@ import { isOfficialMarketplaceGitSource, isOfficialOrganizationGitSource, isReservedPluginIdentity -} from '../../shared/plugins/plugin-marketplace' +} from '../../../shared/plugins/catalog/plugin-marketplace' import type { PluginMarketplaceFetchResult } from './plugin-marketplace-fetch' import type { PluginMarketplaceRegisteredSource } from './plugin-marketplace-store' @@ -11,14 +11,20 @@ export function validateMarketplaceProvenance( source: PluginMarketplaceRegisteredSource, fetched: PluginMarketplaceFetchResult ): void { - if ( - isOfficialMarketplaceGitSource(source.source.url) && - fetched.marketplace.owner.toLowerCase() !== OFFICIAL_MARKETPLACE_OWNER - ) { + const official = isOfficialMarketplaceGitSource(source.source.url) + if (official && fetched.marketplace.owner.toLowerCase() !== OFFICIAL_MARKETPLACE_OWNER) { throw new Error('official marketplace metadata has an unexpected owner') } for (const entry of fetched.marketplace.plugins) { - if (isReservedPluginIdentity(entry.id) && !isOfficialOrganizationGitSource(entry.source.url)) { + if (!isReservedPluginIdentity(entry.id)) { + continue + } + if (!official) { + throw new Error( + `${entry.id} uses a name reserved for official Orca plugins; only Orca's official catalog may list it` + ) + } + if (!isOfficialOrganizationGitSource(entry.source.url)) { throw new Error( `reserved plugin identity ${entry.id} must resolve to the stablyai organization` ) diff --git a/src/main/plugins/plugin-marketplace-service.test.ts b/src/main/plugins/catalog/plugin-marketplace-service.test.ts similarity index 93% rename from src/main/plugins/plugin-marketplace-service.test.ts rename to src/main/plugins/catalog/plugin-marketplace-service.test.ts index 138cc4afa03..ce681270ac1 100644 --- a/src/main/plugins/plugin-marketplace-service.test.ts +++ b/src/main/plugins/catalog/plugin-marketplace-service.test.ts @@ -5,8 +5,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { PluginMarketplace, PluginMarketplaceGitSource -} from '../../shared/plugins/plugin-marketplace' -import { OFFICIAL_MARKETPLACE_GIT_SOURCE } from '../../shared/plugins/plugin-marketplace' +} from '../../../shared/plugins/catalog/plugin-marketplace' +import { OFFICIAL_MARKETPLACE_GIT_SOURCE } from '../../../shared/plugins/catalog/plugin-marketplace' import type { PluginMarketplaceFetchResult } from './plugin-marketplace-fetch' import { PluginMarketplaceService } from './plugin-marketplace-service' import { @@ -142,11 +142,23 @@ describe('PluginMarketplaceService', () => { }) await expect(service.addSource(source())).rejects.toThrow( - 'reserved plugin identity community.orca-secrets' + "community.orca-secrets uses a name reserved for official Orca plugins; only Orca's official catalog may list it" ) await expect(service.listSources()).resolves.toEqual([]) }) + it('refuses a third-party catalog that relists an official plugin', async () => { + const service = new PluginMarketplaceService({ + pluginsDataDir: await tempRoot(), + fetcher: async () => + fetched( + marketplace('Mirror', 'stablyai.orca-notes', 'https://github.com/stablyai/orca-notes.git') + ) + }) + + await expect(service.addSource(source())).rejects.toThrow('reserved for official Orca plugins') + }) + it('derives the Official badge only from the canonical marketplace and source organization', async () => { const officialMarketplace: PluginMarketplace = { name: 'Orca Plugins', @@ -183,7 +195,7 @@ describe('PluginMarketplaceService', () => { { id: 'community.midnight', source: { kind: 'git', url: 'https://github.com/community/midnight.git', ref: 'v1' }, - categories: ['icon-themes', 'official'] + categories: ['skills', 'official'] }, { id: 'community.recipes', @@ -199,7 +211,7 @@ describe('PluginMarketplaceService', () => { const added = await service.addSource(source()) - // The icon-theme pack is filtered out; only the supported recipe listing remains. + // The skills pack is filtered out; only the supported recipe listing remains. await expect(service.listPlugins()).resolves.toEqual([ expect.objectContaining({ pluginKey: 'community.recipes' }) ]) diff --git a/src/main/plugins/plugin-marketplace-service.ts b/src/main/plugins/catalog/plugin-marketplace-service.ts similarity index 83% rename from src/main/plugins/plugin-marketplace-service.ts rename to src/main/plugins/catalog/plugin-marketplace-service.ts index a638e512090..6971b5ca06c 100644 --- a/src/main/plugins/plugin-marketplace-service.ts +++ b/src/main/plugins/catalog/plugin-marketplace-service.ts @@ -1,14 +1,11 @@ import { - OFFICIAL_MARKETPLACE_OWNER, OFFICIAL_MARKETPLACE_GIT_SOURCE, isOfficialMarketplaceGitSource, - isOfficialOrganizationGitSource, - isOfficialPluginIdentity, isMarketplaceListingSupported, pluginMarketplaceGitSourceSchema, type PluginMarketplaceEntry, type PluginMarketplaceGitSource -} from '../../shared/plugins/plugin-marketplace' +} from '../../../shared/plugins/catalog/plugin-marketplace' import { fetchPluginMarketplace, type PluginMarketplaceFetchResult @@ -19,12 +16,14 @@ import { type PluginMarketplaceCachedSnapshot, type PluginMarketplaceRegisteredSource } from './plugin-marketplace-store' -import type { PluginKillListEntry } from '../../shared/plugins/plugin-kill-list' +import type { PluginKillListEntry } from '../../../shared/plugins/catalog/plugin-kill-list' +import type { PluginImageCache } from '../plugin-image-cache' import { validateMarketplaceProvenance } from './plugin-marketplace-provenance' import { pluginMarketplaceErrorMessage } from './plugin-marketplace-error-message' -import type { - PluginMarketplaceListing, - PluginMarketplaceSourceState +import { + pluginMarketplaceListing, + type PluginMarketplaceListing, + type PluginMarketplaceSourceState } from './plugin-marketplace-projection' export type { PluginMarketplaceListing, @@ -39,6 +38,8 @@ export class PluginMarketplaceService { private readonly store: PluginMarketplaceStore private readonly fetcher: MarketplaceFetcher private readonly getKillListEntry: (pluginKey: string) => PluginKillListEntry | null + private readonly hostVersion: string | null + private readonly imageCache: PluginImageCache | null private readonly refreshChains = new Map>() private readonly sourceErrors = new Map() private officialSeedPromise: Promise | null = null @@ -49,7 +50,12 @@ export class PluginMarketplaceService { fetcher?: MarketplaceFetcher store?: PluginMarketplaceStore getKillListEntry?: (pluginKey: string) => PluginKillListEntry | null + /** Picks which of an entry's releases to offer; without it the newest is offered. */ + hostVersion?: string + imageCache?: PluginImageCache }) { + this.hostVersion = options.hostVersion ?? null + this.imageCache = options.imageCache ?? null this.store = options.store ?? new PluginMarketplaceStore(options.pluginsDataDir) this.fetcher = options.fetcher ?? fetchPluginMarketplace this.getKillListEntry = options.getKillListEntry ?? (() => null) @@ -150,19 +156,23 @@ export class PluginMarketplaceService { async listPlugins(): Promise { await this.waitForOfficialSeed() + return (await this.listCachedPlugins()).sort((left, right) => + `${left.pluginKey}\0${left.marketplaceSourceId}`.localeCompare( + `${right.pluginKey}\0${right.marketplaceSourceId}` + ) + ) + } + + /** Listings from the cached catalogs in catalog order, without waiting on any fetch. */ + async listCachedPlugins(): Promise { const states = await this.listSnapshots() - return states - .flatMap(({ source, snapshot }) => - snapshot.marketplace.plugins - // Exclude unsupported contribution kinds before users reach install. - .filter((entry) => isMarketplaceListingSupported(entry.categories)) - .map((entry) => this.listingFromEntry(source, snapshot, entry)) - ) - .sort((left, right) => - `${left.pluginKey}\0${left.marketplaceSourceId}`.localeCompare( - `${right.pluginKey}\0${right.marketplaceSourceId}` - ) - ) + return states.flatMap(({ source, snapshot }) => + snapshot.marketplace.plugins + // Why: hide packs whose contribution kind this build no longer + // supports (themes/icons/skills) so users never reach a dead install. + .filter((entry) => isMarketplaceListingSupported(entry.categories)) + .map((entry) => this.listingFromEntry(source, snapshot, entry)) + ) } async findPlugin( @@ -232,6 +242,17 @@ export class PluginMarketplaceService { ): Promise { const fetched = await this.fetcher(source) validateMarketplaceProvenance(source, fetched) + const cache = this.imageCache + if (cache) { + await Promise.all( + (fetched.images ?? []).map((image) => + cache.put(image).catch((error: unknown) => { + console.warn('[plugins] could not cache marketplace image:', error) + return null + }) + ) + ) + } return fetched } @@ -260,33 +281,13 @@ export class PluginMarketplaceService { snapshot: PluginMarketplaceCachedSnapshot, entry: PluginMarketplaceEntry ): PluginMarketplaceListing { - const official = - isOfficialMarketplaceGitSource(source.source.url) && - snapshot.marketplace.owner.toLowerCase() === OFFICIAL_MARKETPLACE_OWNER && - isOfficialPluginIdentity(entry.id) && - isOfficialOrganizationGitSource(entry.source.url) - const blocked = this.getKillListEntry(entry.id) - return { - marketplaceSourceId: source.id, - marketplaceName: snapshot.marketplace.name, - marketplaceOwner: snapshot.marketplace.owner, - marketplaceCommit: snapshot.marketplaceCommit, - pluginKey: entry.id, - source: entry.source, - ...(entry.description ? { description: entry.description } : {}), - categories: entry.categories, - official, - bundled: false, - ...(entry.release ? { release: entry.release } : {}), - ...(blocked - ? { - blockedByKillList: { - reason: blocked.reason, - ...(blocked.advisoryUrl ? { advisoryUrl: blocked.advisoryUrl } : {}) - } - } - : {}) - } + return pluginMarketplaceListing({ + source, + snapshot, + entry, + hostVersion: this.hostVersion, + blocked: this.getKillListEntry(entry.id) + }) } private stateFromSnapshot( diff --git a/src/main/plugins/plugin-marketplace-store.test.ts b/src/main/plugins/catalog/plugin-marketplace-store.test.ts similarity index 91% rename from src/main/plugins/plugin-marketplace-store.test.ts rename to src/main/plugins/catalog/plugin-marketplace-store.test.ts index a52a6b0d84e..c2dcae0abe2 100644 --- a/src/main/plugins/plugin-marketplace-store.test.ts +++ b/src/main/plugins/catalog/plugin-marketplace-store.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promis import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import type { PluginMarketplaceGitSource } from '../../shared/plugins/plugin-marketplace' +import type { PluginMarketplaceGitSource } from '../../../shared/plugins/catalog/plugin-marketplace' import { marketplaceSourceId, PluginMarketplaceStore } from './plugin-marketplace-store' const roots: string[] = [] @@ -111,14 +111,16 @@ describe('PluginMarketplaceStore', () => { id: 'community.notes', source: { kind: 'git', url: 'https://github.com/community/notes.git', ref: 'v1' }, categories: [], - release: { - version: '1.0.0', - commit: 'c'.repeat(40), - contentHash: 'f'.repeat(64), - execution: 'data', - minimumOrca: '1.4.0', - types: ['interface-language'] - } + releases: [ + { + version: '1.0.0', + commit: 'c'.repeat(40), + contentHash: 'f'.repeat(64), + execution: 'data', + minimumOrca: '1.4.0', + types: ['interface-language'] + } + ] } ] } diff --git a/src/main/plugins/plugin-marketplace-store.ts b/src/main/plugins/catalog/plugin-marketplace-store.ts similarity index 87% rename from src/main/plugins/plugin-marketplace-store.ts rename to src/main/plugins/catalog/plugin-marketplace-store.ts index aabf13c8fe0..6ee642b7d90 100644 --- a/src/main/plugins/plugin-marketplace-store.ts +++ b/src/main/plugins/catalog/plugin-marketplace-store.ts @@ -3,15 +3,15 @@ import { createReadStream } from 'node:fs' import { mkdir, rm } from 'node:fs/promises' import { dirname, join } from 'node:path' import { z } from 'zod' -import { PLUGIN_COMMIT_PATTERN } from '../../shared/plugins/plugin-install-lockfile' +import { PLUGIN_COMMIT_PATTERN } from '../../../shared/plugins/installation/plugin-install-lockfile' import { pluginMarketplaceGitSourceSchema, pluginMarketplaceListingSchema, pluginMarketplaceSchema, type PluginMarketplace, type PluginMarketplaceGitSource -} from '../../shared/plugins/plugin-marketplace' -import { writePluginFileAtomically } from './plugin-atomic-file-write' +} from '../../../shared/plugins/catalog/plugin-marketplace' +import { writePluginFileAtomically } from '../installation/plugin-atomic-file-write' export const PLUGIN_MARKETPLACE_SOURCE_LIMIT = 64 export const PLUGIN_MARKETPLACE_SOURCE_ID_PATTERN = /^[0-9a-f]{32}$/ @@ -66,6 +66,7 @@ export class PluginMarketplaceStore { private readonly snapshotDirectory: string private sources: PluginMarketplaceRegisteredSource[] | null = null private writeChain: Promise = Promise.resolve() + private readonly snapshots = new Map>() constructor(pluginsDataDir: string) { const root = join(pluginsDataDir, 'marketplaces') @@ -109,6 +110,7 @@ export class PluginMarketplaceStore { return next }) if (removed) { + this.snapshots.delete(parsedId) await rm(this.snapshotPath(parsedId), { force: true }) await rm(this.legacySnapshotPath(parsedId), { force: true }) } @@ -130,16 +132,31 @@ export class PluginMarketplaceStore { marketplace: input.marketplace }) await writeAtomicJson(this.snapshotPath(input.source.id), snapshot) + this.snapshots.set(input.source.id, Promise.resolve(snapshot)) return snapshot } + /** Parsed once per process; every write goes through this store, which keeps it current. */ async readSnapshot(sourceId: string): Promise { const parsedId = sourceIdSchema.parse(sourceId) + let snapshot = this.snapshots.get(parsedId) + if (!snapshot) { + snapshot = this.readSnapshotFromDisk(parsedId) + this.snapshots.set(parsedId, snapshot) + // Why: a failed read must be retried, not cached. + void snapshot.catch(() => this.snapshots.delete(parsedId)) + } + return snapshot + } + + private async readSnapshotFromDisk( + sourceId: string + ): Promise { return ( - (await this.readSnapshotFile(this.snapshotPath(parsedId), parsedId, cachedSnapshotSchema)) ?? + (await this.readSnapshotFile(this.snapshotPath(sourceId), sourceId, cachedSnapshotSchema)) ?? (await this.readSnapshotFile( - this.legacySnapshotPath(parsedId), - parsedId, + this.legacySnapshotPath(sourceId), + sourceId, legacySnapshotSchema )) ) diff --git a/src/main/plugins/catalog/plugin-one-click-enable.test.ts b/src/main/plugins/catalog/plugin-one-click-enable.test.ts new file mode 100644 index 00000000000..f786f62e1bf --- /dev/null +++ b/src/main/plugins/catalog/plugin-one-click-enable.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from 'vitest' +import { pluginManifestSchema } from '../../../shared/plugins/manifest/plugin-manifest' +import { PLUGIN_MANIFEST_V2_SINCE } from '../../../shared/plugins/manifest/plugin-schema-field-docs' +import type { ValidDiscoveredPlugin } from '../plugin-discovery' +import { enableIfDataPlugin } from './plugin-one-click-enable' + +function installed(execution: 'data' | 'declarative', contributes: Record) { + const plugin: ValidDiscoveredPlugin = { + pluginKey: 'acme.demo', + rootDir: '/plugins/acme.demo/hash', + manifest: pluginManifestSchema.parse({ + manifestVersion: 2, + execution, + id: 'demo', + publisher: 'acme', + name: 'Demo', + version: '1.0.0', + engines: { orca: `>=${PLUGIN_MANIFEST_V2_SINCE}` }, + contributes + }), + consentFingerprint: 'sha256-installed', + contentHash: 'a'.repeat(64), + isDev: false + } + return plugin +} + +const theme = { themes: [{ id: 'dusk', title: 'Dusk', path: 'dusk.json' }] } +const command = { commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }] } + +describe('enableIfDataPlugin', () => { + it('approves a content-only plugin with the installed fingerprint', async () => { + const approve = vi.fn(async () => undefined) + await expect( + enableIfDataPlugin({ + pluginKey: 'acme.demo', + installedFingerprint: 'sha256-installed', + findValidPlugin: () => installed('data', theme), + approve + }) + ).resolves.toEqual({ needsReview: false }) + expect(approve).toHaveBeenCalledWith('acme.demo', 'sha256-installed') + }) + + it.each([ + ['a plugin that adds instructions', installed('declarative', command), 'sha256-installed'], + ['bytes other than the ones just installed', installed('data', theme), 'sha256-other'], + ['a plugin that failed discovery', null, 'sha256-installed'], + ['a development override', { ...installed('data', theme), isDev: true }, 'sha256-installed'] + ])('leaves %s for review', async (_label, plugin, installedFingerprint) => { + const approve = vi.fn(async () => undefined) + await expect( + enableIfDataPlugin({ + pluginKey: 'acme.demo', + installedFingerprint, + findValidPlugin: () => plugin, + approve + }) + ).resolves.toEqual({ needsReview: true }) + expect(approve).not.toHaveBeenCalled() + }) + + it('approves only the fingerprint the user saw at preview', async () => { + const approve = vi.fn(async () => undefined) + const run = (expectedFingerprint: string) => + enableIfDataPlugin({ + pluginKey: 'acme.demo', + installedFingerprint: 'sha256-installed', + expectedFingerprint, + findValidPlugin: () => installed('data', theme), + approve + }) + await expect(run('sha256-installed')).resolves.toEqual({ needsReview: false }) + expect(approve).toHaveBeenCalledOnce() + approve.mockClear() + await expect(run('sha256-previewed-earlier')).resolves.toEqual({ needsReview: true }) + expect(approve).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/plugins/catalog/plugin-one-click-enable.ts b/src/main/plugins/catalog/plugin-one-click-enable.ts new file mode 100644 index 00000000000..f9b2732a93c --- /dev/null +++ b/src/main/plugins/catalog/plugin-one-click-enable.ts @@ -0,0 +1,32 @@ +import { pluginExecutionProfile } from '../../../shared/plugins/contributions/plugin-execution-profile' +import type { ValidDiscoveredPlugin } from '../plugin-discovery' + +/** + * One-click install for content-only plugins. Why main decides: the click counts as consent only + * for plugins that cannot run anything, and that must be read from the installed, verified + * manifest, never from what the renderer claims. Anything else stays pending for review. + */ +export async function enableIfDataPlugin(input: { + pluginKey: string + /** Fingerprint of the bytes just installed; approval binds to exactly these. */ + installedFingerprint: string + /** The fingerprint the user was shown at preview; when given, nothing else is approved. */ + expectedFingerprint?: string + findValidPlugin: (pluginKey: string) => ValidDiscoveredPlugin | null + approve: (pluginKey: string, reviewedFingerprint: string) => Promise +}): Promise<{ needsReview: boolean }> { + const plugin = input.findValidPlugin(input.pluginKey) + if ( + !plugin || + // Why: a development override shadows the install, so it is not the bytes just reviewed. + plugin.isDev || + plugin.consentFingerprint !== input.installedFingerprint || + (input.expectedFingerprint !== undefined && + input.expectedFingerprint !== input.installedFingerprint) || + pluginExecutionProfile(plugin.manifest) !== 'data' + ) { + return { needsReview: true } + } + await input.approve(input.pluginKey, plugin.consentFingerprint) + return { needsReview: false } +} diff --git a/src/main/plugins/plugin-private-marketplace-ssh-shim.cjs b/src/main/plugins/catalog/plugin-private-marketplace-ssh-shim.cjs similarity index 100% rename from src/main/plugins/plugin-private-marketplace-ssh-shim.cjs rename to src/main/plugins/catalog/plugin-private-marketplace-ssh-shim.cjs diff --git a/src/main/plugins/plugin-private-marketplace.integration.test.ts b/src/main/plugins/catalog/plugin-private-marketplace.integration.test.ts similarity index 95% rename from src/main/plugins/plugin-private-marketplace.integration.test.ts rename to src/main/plugins/catalog/plugin-private-marketplace.integration.test.ts index 7c37025487c..d5be67248cd 100644 --- a/src/main/plugins/plugin-private-marketplace.integration.test.ts +++ b/src/main/plugins/catalog/plugin-private-marketplace.integration.test.ts @@ -4,9 +4,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' import { afterEach, describe, expect, it } from 'vitest' -import type { PluginMarketplaceGitSource } from '../../shared/plugins/plugin-marketplace' -import { getUserPluginsDir } from './plugin-discovery' -import { readPluginLockfile } from './plugin-install' +import type { PluginMarketplaceGitSource } from '../../../shared/plugins/catalog/plugin-marketplace' +import { getUserPluginsDir } from '../plugin-discovery' +import { readPluginLockfile } from '../installation/plugin-install' import { PluginMarketplaceInstaller } from './plugin-marketplace-installer' import { PluginMarketplaceService } from './plugin-marketplace-service' diff --git a/src/main/plugins/catalog/plugin-release-first-seen-store.test.ts b/src/main/plugins/catalog/plugin-release-first-seen-store.test.ts new file mode 100644 index 00000000000..cdc8444ecae --- /dev/null +++ b/src/main/plugins/catalog/plugin-release-first-seen-store.test.ts @@ -0,0 +1,29 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { PluginReleaseFirstSeenStore } from './plugin-release-first-seen-store' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('plugin release first-seen store', () => { + it('keeps the first sighting across restarts and restarts the clock for a new release', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-plugin-first-seen-')) + roots.push(root) + expect(await new PluginReleaseFirstSeenStore(root).firstSeen('a.b', '1.1.0@c', 100)).toBe(100) + const reopened = new PluginReleaseFirstSeenStore(root) + expect(await reopened.firstSeen('a.b', '1.1.0@c', 500)).toBe(100) + expect(await reopened.firstSeen('a.b', '1.2.0@d', 900)).toBe(900) + }) + + it('treats a damaged file as nothing seen yet', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-plugin-first-seen-')) + roots.push(root) + await writeFile(join(root, 'plugin-release-first-seen.json'), '{not json') + expect(await new PluginReleaseFirstSeenStore(root).firstSeen('a.b', '1.0.0@c', 7)).toBe(7) + }) +}) diff --git a/src/main/plugins/catalog/plugin-release-first-seen-store.ts b/src/main/plugins/catalog/plugin-release-first-seen-store.ts new file mode 100644 index 00000000000..ab1e8c132bf --- /dev/null +++ b/src/main/plugins/catalog/plugin-release-first-seen-store.ts @@ -0,0 +1,53 @@ +import { mkdir, readFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { z } from 'zod' +import { writePluginFileAtomically } from '../installation/plugin-atomic-file-write' + +const firstSeenSchema = z.record( + z.string().max(256), + z.object({ release: z.string().max(512), firstSeenAt: z.number().finite().nonnegative() }) +) + +/** + * When this Orca first saw each plugin's newest catalog release. Why local: catalogs carry no + * listed-at time, and the auto-update hold-back must survive restarts so takedowns get time to land. + */ +export class PluginReleaseFirstSeenStore { + private readonly filePath: string + private cache: z.infer | null = null + + constructor(pluginsDataDir: string) { + this.filePath = join(pluginsDataDir, 'plugin-release-first-seen.json') + } + + /** The first time `release` was seen for this plugin, recording `now` when it is new. */ + async firstSeen(pluginKey: string, release: string, now: number): Promise { + const records = await this.read() + const known = records[pluginKey] + if (known?.release === release) { + return known.firstSeenAt + } + records[pluginKey] = { release, firstSeenAt: now } + await mkdir(dirname(this.filePath), { recursive: true }) + await writePluginFileAtomically(this.filePath, `${JSON.stringify(records, null, 2)}\n`) + return now + } + + private async read(): Promise> { + if (!this.cache) { + const raw = await readFile(this.filePath, 'utf8').catch(() => null) + const parsed = raw ? firstSeenSchema.safeParse(safeJson(raw)) : null + // Why reset on damage: a lost record only restarts that release's hold-back. + this.cache = parsed?.success ? parsed.data : {} + } + return this.cache + } +} + +function safeJson(raw: string): unknown { + try { + return JSON.parse(raw) + } catch { + return null + } +} diff --git a/src/main/plugins/catalog/plugin-update-approval.test.ts b/src/main/plugins/catalog/plugin-update-approval.test.ts new file mode 100644 index 00000000000..65d55e79534 --- /dev/null +++ b/src/main/plugins/catalog/plugin-update-approval.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it, vi } from 'vitest' +import { fingerprintPluginConsent } from '../../../shared/plugins/approval/plugin-consent-fingerprint' +import { + pluginManifestSchema, + type PluginManifest +} from '../../../shared/plugins/manifest/plugin-manifest' +import type { ValidDiscoveredPlugin } from '../plugin-discovery' +import { installCatalogReleaseWithApproval } from './plugin-update-approval' + +const KEY = 'samples.demo' + +type ManifestOverrides = Omit, 'contributes'> & { + contributes?: Partial +} + +function manifest(overrides: ManifestOverrides = {}): PluginManifest { + return pluginManifestSchema.parse({ + manifestVersion: 1, + id: 'demo', + publisher: 'samples', + name: 'Demo', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + capabilities: [], + ...overrides + }) +} + +function discovered(m: PluginManifest, contentHash: string, isDev = false): ValidDiscoveredPlugin { + return { + pluginKey: KEY, + rootDir: `/plugins/${KEY}/${contentHash}`, + manifest: m, + consentFingerprint: fingerprintPluginConsent(m, contentHash), + contentHash, + isDev + } +} + +const SAMPLES = { + data: () => manifest({ contributes: { languagePacks: [{ locale: 'fr', path: 'fr.json' }] } }), + declarative: () => + manifest({ + capabilities: [], + contributes: { + commands: [{ id: 'demo.open', title: 'Open', action: 'sidebar.left.toggle' }], + keybindings: [{ command: 'demo.open', key: 'CmdOrCtrl+Shift+9' }] + } + }), + 'sandboxed-ui': () => + manifest({ contributes: { panels: [{ id: 'dash', title: 'Dash', entry: 'panel.html' }] } }), + 'trusted-worker': () => + manifest({ main: 'worker.js', capabilities: [{ kind: 'notifications:show' }] }) +} satisfies Record PluginManifest> + +/** Runs an update from `before` (approved unless `approved: false`) to `after`. */ +async function runUpdate(options: { + before: PluginManifest + after: PluginManifest + approved?: boolean + repositoryMoved?: boolean + dev?: boolean + disabledDuringInstall?: boolean +}) { + const previous = discovered(options.before, 'sha-old', options.dev) + const next = discovered(options.after, 'sha-new') + let current = previous + const consents: Record = {} + const disabled: string[] = [] + if (options.approved !== false) { + consents[KEY] = previous.consentFingerprint + } + const approve = vi.fn(async (pluginKey: string, fingerprint: string) => { + consents[pluginKey] = fingerprint + }) + const outcome = await installCatalogReleaseWithApproval({ + pluginKey: KEY, + pluginService: { + options: { + isPluginSystemEnabled: () => true, + getPluginConsents: () => consents, + getDisabledPlugins: () => disabled + }, + findValidPlugin: () => current, + refresh: async () => { + current = next + } + }, + install: async () => { + if (options.disabledDuringInstall) { + disabled.push(KEY) + } + return installed() + }, + approve + }) + return { outcome, approve, consents, next } + + function installed() { + return { + ok: true as const, + pluginKey: KEY, + version: '1.1.0', + contentHash: 'sha-new', + resolvedCommit: null, + consentFingerprint: next.consentFingerprint, + ...(options.repositoryMoved ? { repositoryMoved: true as const } : {}) + } + } +} + +describe('catalog update approval', () => { + it('does not re-enable a plugin the user disabled while its update installed', async () => { + const before = SAMPLES['trusted-worker']() + const after = { ...SAMPLES['trusted-worker'](), version: '1.1.0' } + const { outcome, approve } = await runUpdate({ before, after, disabledDuringInstall: true }) + expect(outcome).toMatchObject({ ok: true, needsReview: true }) + expect(approve).not.toHaveBeenCalled() + }) + + for (const [type, sample] of Object.entries(SAMPLES)) { + it(`keeps a ${type} plugin approved when an update changes only its content`, async () => { + const before = sample() + const after = { ...sample(), version: '1.1.0' } + const { outcome, consents, next } = await runUpdate({ before, after }) + expect(outcome).toMatchObject({ ok: true, needsReview: false }) + expect(consents[KEY]).toBe(next.consentFingerprint) + }) + + it(`asks again when a ${type} update adds a capability`, async () => { + const before = sample() + const after = { + ...sample(), + version: '1.1.0', + main: 'worker.js', + capabilities: [...before.capabilities, { kind: 'terminal:send' as const }] + } + const { outcome, approve } = await runUpdate({ before, after }) + expect(outcome).toMatchObject({ ok: true, needsReview: true }) + expect(approve).not.toHaveBeenCalled() + }) + } + + it('asks again when the execution profile rises', async () => { + const { outcome, approve } = await runUpdate({ + before: SAMPLES.data(), + after: SAMPLES['sandboxed-ui']() + }) + expect(outcome).toMatchObject({ needsReview: true }) + expect(approve).not.toHaveBeenCalled() + }) + + it('asks again for a new global shortcut, command, panel or event', async () => { + const declarative = SAMPLES.declarative() + const newShortcut = manifest({ + contributes: { + ...declarative.contributes, + keybindings: [{ command: 'demo.open', key: 'CmdOrCtrl+Shift+8' }] + } + }) + const panel = SAMPLES['sandboxed-ui']() + const newPanel = manifest({ + contributes: { + panels: [...panel.contributes.panels, { id: 'more', title: 'More', entry: 'more.html' }] + } + }) + const worker = SAMPLES['trusted-worker']() + const newEvent = manifest({ + main: 'worker.js', + capabilities: [{ kind: 'notifications:show' }, { kind: 'workspace:read' }], + contributes: { events: [{ on: 'workspace.created' }] } + }) + const workerWithRead = manifest({ + main: 'worker.js', + capabilities: [{ kind: 'notifications:show' }, { kind: 'workspace:read' }] + }) + for (const [before, after] of [ + [declarative, newShortcut], + [panel, newPanel], + [workerWithRead, newEvent] + ] as const) { + const { outcome } = await runUpdate({ before, after }) + expect(outcome).toMatchObject({ needsReview: true }) + } + // Removing access never re-asks. + const { outcome } = await runUpdate({ before: workerWithRead, after: worker }) + expect(outcome).toMatchObject({ needsReview: false }) + }) + + it('always asks again for a moved repository, a development override or no prior approval', async () => { + const before = SAMPLES['trusted-worker']() + const after = { ...before, version: '1.1.0' } + for (const options of [{ repositoryMoved: true }, { dev: true }, { approved: false }]) { + const { outcome, approve } = await runUpdate({ before, after, ...options }) + expect(approve).not.toHaveBeenCalled() + expect(outcome.ok).toBe(true) + if (options.repositoryMoved) { + expect(outcome).toMatchObject({ needsReview: true }) + } + } + }) +}) diff --git a/src/main/plugins/catalog/plugin-update-approval.ts b/src/main/plugins/catalog/plugin-update-approval.ts new file mode 100644 index 00000000000..2bf291334c1 --- /dev/null +++ b/src/main/plugins/catalog/plugin-update-approval.ts @@ -0,0 +1,148 @@ +import { + pluginUpdateAccessGrowth, + pluginUpdateGrowsAccess, + type PluginUpdateAccessGrowth, + type PluginVmRecipeIdentities +} from '../../../shared/plugins/approval/plugin-update-access-growth' +import { readPluginVmRecipeIdentities } from '../plugin-vm-recipe-identities' +import { livePluginActivationState, type PluginPolicySource } from '../plugin-activation-policy' +import type { + PluginMarketplaceInstallPreview, + PluginMarketplaceInstallResult +} from './plugin-marketplace-installer' +import type { ValidDiscoveredPlugin } from '../plugin-discovery' +import { enableIfDataPlugin } from './plugin-one-click-enable' + +/** The installed plugin an update would replace, when it is running under a user approval. */ +export function readApprovedPredecessor( + source: PluginPolicySource, + plugin: ValidDiscoveredPlugin | null +): ValidDiscoveredPlugin | null { + // Why dev excluded: a development folder shadows the install, so its approval is not the install's. + if (!plugin || plugin.isDev || livePluginActivationState(source, plugin) !== 'approved') { + return null + } + return plugin +} + +/** What a previewed update asks for beyond the installed plugin. */ +export async function readPreviewAccessGrowth( + installed: ValidDiscoveredPlugin, + preview: Pick +): Promise { + return pluginUpdateAccessGrowth(installed.manifest, preview.manifest, { + installed: await readPluginVmRecipeIdentities(installed.rootDir, installed.manifest), + next: preview.vmRecipeIdentities ?? null + }) +} + +/** + * Trust once per access level: an update from the same repository that asks for nothing new keeps + * the approval of the version it replaces, code changes included. Returns whether it was carried. + */ +async function carryPluginApprovalAcrossUpdate(input: { + previous: ValidDiscoveredPlugin | null + /** Read before the install, which may replace the previous version's files. */ + previousVmRecipes: PluginVmRecipeIdentities + installedFingerprint: string + findValidPlugin: (pluginKey: string) => ValidDiscoveredPlugin | null + isDisabled: (pluginKey: string) => boolean + approve: (pluginKey: string, reviewedFingerprint: string) => Promise +}): Promise { + const { previous } = input + if (!previous) { + return false + } + const next = input.findValidPlugin(previous.pluginKey) + if (!next || next.isDev || next.consentFingerprint !== input.installedFingerprint) { + return false + } + if (next.consentFingerprint === previous.consentFingerprint) { + return true + } + const vmRecipes = { + installed: input.previousVmRecipes, + next: await readPluginVmRecipeIdentities(next.rootDir, next.manifest) + } + if ( + pluginUpdateGrowsAccess(pluginUpdateAccessGrowth(previous.manifest, next.manifest, vmRecipes)) + ) { + return false + } + // Why recheck after the waits: approving re-enables, and the user may have disabled it meanwhile. + if (input.isDisabled(next.pluginKey)) { + return false + } + try { + await input.approve(next.pluginKey, next.consentFingerprint) + return true + } catch (error) { + // Why not thrown: the update installed; it simply waits for review like a first install. + console.warn(`[plugins] kept ${next.pluginKey} for review after update:`, error) + return false + } +} + +type InstallingPluginService = { + readonly options: PluginPolicySource + findValidPlugin: (pluginKey: string) => ValidDiscoveredPlugin | null + refresh: () => Promise +} + +export type PluginCatalogInstallOutcome = PluginMarketplaceInstallResult & { needsReview?: boolean } + +/** Installs a catalog release and settles its approval: kept across a same-access update, granted + * for a content-only one-click install, else left for review. Shared by Update and auto-update. */ +export async function installCatalogReleaseWithApproval(input: { + pluginKey: string + pluginService: InstallingPluginService + install: () => Promise + approve: (pluginKey: string, reviewedFingerprint: string) => Promise + /** One-click consent for a content-only plugin; the preview's fingerprint when known. */ + enableIfData?: { expectedFingerprint?: string } +}): Promise { + const { pluginService } = input + // Why read first: the install replaces the version whose approval an update may keep. + const predecessor = readApprovedPredecessor( + pluginService.options, + pluginService.findValidPlugin(input.pluginKey) + ) + const previousVmRecipes = predecessor + ? await readPluginVmRecipeIdentities(predecessor.rootDir, predecessor.manifest) + : null + const result = await input.install() + if (!result.ok) { + return result + } + await pluginService.refresh() + // Why main decides: a repository move must reach review even if the renderer skipped its flag. + if (result.repositoryMoved) { + return { ...result, needsReview: true } + } + const findValidPlugin = (key: string): ValidDiscoveredPlugin | null => + pluginService.findValidPlugin(key) + if ( + await carryPluginApprovalAcrossUpdate({ + previous: predecessor, + previousVmRecipes, + installedFingerprint: result.consentFingerprint, + findValidPlugin, + isDisabled: (key) => pluginService.options.getDisabledPlugins().includes(key), + approve: input.approve + }) + ) { + return { ...result, needsReview: false } + } + if (!input.enableIfData) { + return predecessor ? { ...result, needsReview: true } : result + } + const { expectedFingerprint } = input.enableIfData + const { needsReview } = await enableIfDataPlugin({ + pluginKey: result.pluginKey, + installedFingerprint: result.consentFingerprint, + ...(expectedFingerprint ? { expectedFingerprint } : {}), + findValidPlugin, + approve: input.approve + }) + return { ...result, needsReview } +} diff --git a/src/main/plugins/plugin-artifact-pack-registry.ts b/src/main/plugins/contributions/plugin-artifact-pack-registry.ts similarity index 92% rename from src/main/plugins/plugin-artifact-pack-registry.ts rename to src/main/plugins/contributions/plugin-artifact-pack-registry.ts index 5460670120e..afd2ea12db1 100644 --- a/src/main/plugins/plugin-artifact-pack-registry.ts +++ b/src/main/plugins/contributions/plugin-artifact-pack-registry.ts @@ -1,10 +1,10 @@ -import type { PluginContentVerifier } from './plugin-content-integrity' -import { mapWithConcurrency } from '../../shared/map-with-concurrency' +import type { PluginContentVerifier } from '../plugin-content-integrity' +import { mapWithConcurrency } from '../../../shared/map-with-concurrency' import { isInvalidDiscoveredPlugin, type DiscoveredPlugin, type ValidDiscoveredPlugin -} from './plugin-discovery' +} from '../plugin-discovery' const ARTIFACT_PACK_LOAD_CONCURRENCY = 4 diff --git a/src/main/plugins/plugin-content-pack-registry.test.ts b/src/main/plugins/contributions/plugin-content-pack-registry.test.ts similarity index 90% rename from src/main/plugins/plugin-content-pack-registry.test.ts rename to src/main/plugins/contributions/plugin-content-pack-registry.test.ts index b9a3f57842e..1820190fa4c 100644 --- a/src/main/plugins/plugin-content-pack-registry.test.ts +++ b/src/main/plugins/contributions/plugin-content-pack-registry.test.ts @@ -2,12 +2,12 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' -import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' -import { PluginContentVerifier } from './plugin-content-integrity' -import { hashPluginTree } from './plugin-content-hash' +import { fingerprintPluginConsent } from '../../../shared/plugins/approval/plugin-consent-fingerprint' +import { pluginManifestSchema } from '../../../shared/plugins/manifest/plugin-manifest' +import { PluginContentVerifier } from '../plugin-content-integrity' +import { hashPluginTree } from '../plugin-content-hash' import { PluginContentPackRegistry } from './plugin-content-pack-registry' -import type { ValidDiscoveredPlugin } from './plugin-discovery' +import type { ValidDiscoveredPlugin } from '../plugin-discovery' const roots: string[] = [] @@ -30,7 +30,7 @@ describe('PluginContentPackRegistry', () => { const manifest = pluginManifestSchema.parse({ manifestVersion: 1, id: 'mixed-content', - publisher: 'orca-samples', + publisher: 'samples', name: 'Mixed Content', version: '1.0.0', engines: { orca: '>=1.0.0' }, @@ -44,7 +44,7 @@ describe('PluginContentPackRegistry', () => { capabilities: [] }) const plugin: ValidDiscoveredPlugin = { - pluginKey: 'orca-samples.mixed-content', + pluginKey: 'samples.mixed-content', rootDir, manifest, consentFingerprint: fingerprintPluginConsent(manifest), @@ -73,7 +73,7 @@ describe('PluginContentPackRegistry', () => { const manifest = pluginManifestSchema.parse({ manifestVersion: 1, id: 'mixed-recipes', - publisher: 'orca-samples', + publisher: 'samples', name: 'Mixed Recipes', version: '1.0.0', engines: { orca: '>=1.0.0' }, @@ -89,7 +89,7 @@ describe('PluginContentPackRegistry', () => { throw new Error(content.error) } const plugin: ValidDiscoveredPlugin = { - pluginKey: 'orca-samples.mixed-recipes', + pluginKey: 'samples.mixed-recipes', rootDir, manifest, consentFingerprint: fingerprintPluginConsent(manifest, content.hash), @@ -125,7 +125,7 @@ describe('PluginContentPackRegistry', () => { const manifest = pluginManifestSchema.parse({ manifestVersion: 1, id: 'kill-race', - publisher: 'orca-samples', + publisher: 'samples', name: 'Kill Race', version: '1.0.0', engines: { orca: '>=1.0.0' }, @@ -141,7 +141,7 @@ describe('PluginContentPackRegistry', () => { throw new Error(content.error) } const plugin: ValidDiscoveredPlugin = { - pluginKey: 'orca-samples.kill-race', + pluginKey: 'samples.kill-race', rootDir, manifest, consentFingerprint: fingerprintPluginConsent(manifest, content.hash), diff --git a/src/main/plugins/plugin-content-pack-registry.ts b/src/main/plugins/contributions/plugin-content-pack-registry.ts similarity index 82% rename from src/main/plugins/plugin-content-pack-registry.ts rename to src/main/plugins/contributions/plugin-content-pack-registry.ts index 7edbf1a9432..d9e6b9b12d6 100644 --- a/src/main/plugins/plugin-content-pack-registry.ts +++ b/src/main/plugins/contributions/plugin-content-pack-registry.ts @@ -1,18 +1,20 @@ -import type { PluginContentVerifier } from './plugin-content-integrity' +import type { PluginContentVerifier } from '../plugin-content-integrity' import { isInvalidDiscoveredPlugin, type DiscoveredPlugin, type ValidDiscoveredPlugin -} from './plugin-discovery' +} from '../plugin-discovery' import { PluginLanguagePackRegistry } from './plugin-language-pack-registry' +import { PluginIconThemeRegistry } from './plugin-icon-theme-registry' +import { PluginSyntaxRegistry } from './plugin-syntax-registry' import { createPluginTerminalThemeRegistry, createPluginThemeRegistry } from './plugin-theme-registries' -import { PluginVmRecipeRegistry } from './plugin-vm-recipe-registry' -import { PluginCommandRegistry } from './plugin-command-registry' -import { verifyInstructionalPluginContent } from './plugin-instructional-content-integrity' -import type { KeybindingOverrides } from '../../shared/keybindings' +import { PluginVmRecipeRegistry } from '../plugin-vm-recipe-registry' +import { PluginCommandRegistry } from '../plugin-command-registry' +import { verifyDeclarativePluginContent } from '../plugin-declarative-content-integrity' +import type { KeybindingOverrides } from '../../../shared/keybindings' type ContentFamily = { reconcile: ( @@ -26,6 +28,8 @@ export class PluginContentPackRegistry { readonly languagePacks: PluginLanguagePackRegistry readonly themes: ReturnType readonly terminalThemes: ReturnType + readonly iconThemes: PluginIconThemeRegistry + readonly syntaxes: PluginSyntaxRegistry readonly vmRecipes: PluginVmRecipeRegistry readonly commands: PluginCommandRegistry private readonly activationErrors = new Map() @@ -38,6 +42,8 @@ export class PluginContentPackRegistry { this.languagePacks = new PluginLanguagePackRegistry(contentVerifier) this.themes = createPluginThemeRegistry(contentVerifier) this.terminalThemes = createPluginTerminalThemeRegistry(contentVerifier) + this.iconThemes = new PluginIconThemeRegistry(contentVerifier) + this.syntaxes = new PluginSyntaxRegistry(contentVerifier) this.vmRecipes = new PluginVmRecipeRegistry() this.commands = new PluginCommandRegistry() } @@ -66,7 +72,7 @@ export class PluginContentPackRegistry { return } try { - await verifyInstructionalPluginContent(plugin) + await verifyDeclarativePluginContent(plugin) } catch (error) { excluded.add(plugin.pluginKey) this.activationErrors.set( @@ -122,6 +128,8 @@ export class PluginContentPackRegistry { this.languagePacks, this.themes, this.terminalThemes, + this.iconThemes, + this.syntaxes, this.vmRecipes, { reconcile: async (discovered, isApproved) => diff --git a/src/main/plugins/contributions/plugin-icon-theme-registry.test.ts b/src/main/plugins/contributions/plugin-icon-theme-registry.test.ts new file mode 100644 index 00000000000..33bf20f49e5 --- /dev/null +++ b/src/main/plugins/contributions/plugin-icon-theme-registry.test.ts @@ -0,0 +1,86 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { fingerprintPluginConsent } from '../../../shared/plugins/approval/plugin-consent-fingerprint' +import { pluginManifestSchema } from '../../../shared/plugins/manifest/plugin-manifest' +import { PluginContentVerifier } from '../plugin-content-integrity' +import type { ValidDiscoveredPlugin } from '../plugin-discovery' +import { PluginIconThemeRegistry } from './plugin-icon-theme-registry' + +const roots: string[] = [] +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function iconPlugin(svgs: Record): Promise { + const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-icon-theme-')) + roots.push(rootDir) + await mkdir(join(rootDir, 'icons')) + for (const [name, svg] of Object.entries(svgs)) { + await writeFile(join(rootDir, 'icons', name), svg) + } + await writeFile( + join(rootDir, 'theme.json'), + JSON.stringify({ + schemaVersion: 1, + icons: { folder: 'icons/folder.svg' }, + fileExtensions: { ts: 'icons/ts.svg' } + }) + ) + const manifest = pluginManifestSchema.parse({ + manifestVersion: 1, + id: 'minimal-icons', + publisher: 'samples', + name: 'Minimal icons', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + contributes: { iconThemes: [{ id: 'minimal', title: 'Minimal', path: 'theme.json' }] } + }) + return { + pluginKey: 'samples.minimal-icons', + rootDir, + manifest, + consentFingerprint: fingerprintPluginConsent(manifest), + contentHash: null, + isDev: true + } +} + +it('lists metadata and reads a theme only when it is loaded', async () => { + const plugin = await iconPlugin({ + 'folder.svg': '', + 'ts.svg': '' + }) + const registry = new PluginIconThemeRegistry(new PluginContentVerifier()) + await registry.reconcile([plugin], () => true) + + expect(registry.list()).toEqual([ + { + id: 'plugin:samples.minimal-icons/minimal', + pluginKey: 'samples.minimal-icons', + label: 'Minimal' + } + ]) + const theme = await registry.load('plugin:samples.minimal-icons/minimal') + expect(theme?.svgs).toHaveLength(2) + expect(theme?.svgs[theme.icons.folder ?? -1]).toContain('currentColor') + expect(theme?.svgs[theme.fileExtensions.ts ?? -1]).toContain('#3178c6') + await expect(registry.load('plugin:samples.other/x')).resolves.toBeNull() +}) + +it('fails a load whose file is not an SVG document and forgets themes once disabled', async () => { + const plugin = await iconPlugin({ + 'folder.svg': ']>', + 'ts.svg': '' + }) + const registry = new PluginIconThemeRegistry(new PluginContentVerifier()) + await registry.reconcile([plugin], () => true) + + await expect(registry.load('plugin:samples.minimal-icons/minimal')).rejects.toThrow( + 'icons/folder.svg' + ) + await registry.reconcile([plugin], () => false) + expect(registry.list()).toEqual([]) +}) diff --git a/src/main/plugins/contributions/plugin-icon-theme-registry.ts b/src/main/plugins/contributions/plugin-icon-theme-registry.ts new file mode 100644 index 00000000000..0a2c9f3120e --- /dev/null +++ b/src/main/plugins/contributions/plugin-icon-theme-registry.ts @@ -0,0 +1,121 @@ +import type { PluginThemeContribution } from '../../../shared/plugins/contributions/plugin-content-pack-contributions' +import { + PLUGIN_ICON_SVG_MAX_BYTES, + isPlausiblePluginIconSvg, + parsePluginIconThemeArtifact, + type PluginIconThemeArtifact, + type PluginIconThemeMetadata, + type PluginIconThemeRegistration +} from '../../../shared/plugins/artifacts/plugin-icon-theme-artifact' +import { + PLUGIN_ICON_THEME_MAX_BYTES, + readContainedPluginArtifactText +} from '../plugin-artifact-validation' +import { PluginArtifactPackRegistry } from './plugin-artifact-pack-registry' +import type { PluginContentVerifier } from '../plugin-content-integrity' +import type { DiscoveredPlugin, ValidDiscoveredPlugin } from '../plugin-discovery' + +const ICON_THEME_TOTAL_SVG_MAX_BYTES = 4 * 1024 * 1024 + +type IconThemeDescriptor = PluginIconThemeMetadata & { + rootDir: string + artifact: PluginIconThemeArtifact +} + +/** Lists icon themes cheaply and reads one theme's SVGs only when it is selected. */ +export class PluginIconThemeRegistry { + private readonly descriptors: PluginArtifactPackRegistry< + PluginThemeContribution, + IconThemeDescriptor + > + private readonly loaded = new Map>() + + constructor(contentVerifier: PluginContentVerifier) { + this.descriptors = new PluginArtifactPackRegistry({ + contentVerifier, + contributions: (plugin) => plugin.manifest.contributes.iconThemes, + load: async (plugin, contribution) => ({ + id: `plugin:${plugin.pluginKey}/${contribution.id}`, + pluginKey: plugin.pluginKey, + label: contribution.title, + rootDir: plugin.rootDir, + artifact: parsePluginIconThemeArtifact( + await readContainedPluginArtifactText( + plugin.rootDir, + contribution.path, + PLUGIN_ICON_THEME_MAX_BYTES + ) + ) + }) + }) + } + + list(): PluginIconThemeMetadata[] { + return this.descriptors.list().map(({ id, pluginKey, label }) => ({ id, pluginKey, label })) + } + + error(pluginKey: string): string | null { + return this.descriptors.error(pluginKey) + } + + async reconcile( + discovered: readonly DiscoveredPlugin[], + isApproved: (plugin: ValidDiscoveredPlugin) => boolean + ): Promise { + await this.descriptors.reconcile(discovered, isApproved) + this.loaded.clear() + } + + async load(id: string): Promise { + const descriptor = this.descriptors.list().find((theme) => theme.id === id) + if (!descriptor) { + return null + } + let loading = this.loaded.get(id) + if (!loading) { + loading = materialize(descriptor) + this.loaded.set(id, loading) + void loading.catch(() => this.loaded.delete(id)) + } + return loading + } +} + +async function materialize(descriptor: IconThemeDescriptor): Promise { + const { artifact } = descriptor + const paths = [ + ...new Set([ + ...Object.values(artifact.icons), + ...Object.values(artifact.fileNames), + ...Object.values(artifact.fileExtensions) + ]) + ] + const svgs: string[] = [] + let totalBytes = 0 + for (const path of paths) { + const svg = await readContainedPluginArtifactText( + descriptor.rootDir, + path, + PLUGIN_ICON_SVG_MAX_BYTES + ) + totalBytes += Buffer.byteLength(svg, 'utf8') + if (totalBytes > ICON_THEME_TOTAL_SVG_MAX_BYTES) { + throw new Error(`icon theme SVGs exceed ${ICON_THEME_TOTAL_SVG_MAX_BYTES} bytes in total`) + } + if (!isPlausiblePluginIconSvg(svg)) { + throw new Error(`${path}: not a single SVG document`) + } + svgs.push(svg) + } + const indexOf = (entries: Record): Record => + Object.fromEntries(Object.entries(entries).map(([key, path]) => [key, paths.indexOf(path)])) + return { + id: descriptor.id, + pluginKey: descriptor.pluginKey, + label: descriptor.label, + svgs, + icons: indexOf(artifact.icons), + fileNames: indexOf(artifact.fileNames), + fileExtensions: indexOf(artifact.fileExtensions) + } +} diff --git a/src/main/plugins/plugin-language-pack-registry.test.ts b/src/main/plugins/contributions/plugin-language-pack-registry.test.ts similarity index 67% rename from src/main/plugins/plugin-language-pack-registry.test.ts rename to src/main/plugins/contributions/plugin-language-pack-registry.test.ts index 22caf4ffc83..1b6f043ea5f 100644 --- a/src/main/plugins/plugin-language-pack-registry.test.ts +++ b/src/main/plugins/contributions/plugin-language-pack-registry.test.ts @@ -2,10 +2,10 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' -import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' -import { PluginContentVerifier } from './plugin-content-integrity' -import type { ValidDiscoveredPlugin } from './plugin-discovery' +import { fingerprintPluginConsent } from '../../../shared/plugins/approval/plugin-consent-fingerprint' +import { pluginManifestSchema } from '../../../shared/plugins/manifest/plugin-manifest' +import { PluginContentVerifier } from '../plugin-content-integrity' +import type { ValidDiscoveredPlugin } from '../plugin-discovery' import { PluginLanguagePackRegistry } from './plugin-language-pack-registry' const roots: string[] = [] @@ -18,7 +18,7 @@ async function pluginWithCatalog(catalog: unknown): Promise=1.0.0' }, @@ -29,7 +29,7 @@ async function pluginWithCatalog(catalog: unknown): Promise { expect(registry.list()).toEqual([ { - id: 'plugin:orca-samples.portuguese/pt-BR', - resourceLanguage: - 'pt-BR-x-orcapk-6f726361-2d73616d-706c6573-2e706f72-74756775-6573652f-70742d42-52', - pluginKey: 'orca-samples.portuguese', + id: 'plugin:samples.portuguese/pt-BR', + resourceLanguage: 'pt-BR-x-orcapk-73616d70-6c65732e-706f7274-75677565-73652f70-742d4252', + pluginKey: 'samples.portuguese', locale: 'pt-BR', - catalog: { common: { save: 'Salvar' } } + catalog: { common: { save: 'Salvar' } }, + // One string against the whole English catalog. + coverage: 0 } ]) expect(registry.error(plugin.pluginKey)).toBeNull() }) - it('fails closed for protected security copy and clears state when disabled', async () => { + it('loads a pack that covers protected security copy, leaving that copy in English', async () => { const plugin = await pluginWithCatalog({ + common: { save: 'Sávé' }, auto: { components: { settings: { PluginConsentDialog: { disclaimer: 'Safe' } } } } }) const registry = new PluginLanguagePackRegistry(new PluginContentVerifier()) await registry.reconcile([plugin], () => true) - expect(registry.list()).toEqual([]) - expect(registry.error(plugin.pluginKey)).toContain('protected security copy') - - await registry.reconcile([plugin], () => false) expect(registry.error(plugin.pluginKey)).toBeNull() + expect(registry.list()[0]?.catalog).toEqual({ + common: { save: 'Sávé' }, + auto: { components: { settings: {} } } + }) }) }) diff --git a/src/main/plugins/plugin-language-pack-registry.ts b/src/main/plugins/contributions/plugin-language-pack-registry.ts similarity index 57% rename from src/main/plugins/plugin-language-pack-registry.ts rename to src/main/plugins/contributions/plugin-language-pack-registry.ts index e7060fb3275..dea603afd84 100644 --- a/src/main/plugins/plugin-language-pack-registry.ts +++ b/src/main/plugins/contributions/plugin-language-pack-registry.ts @@ -2,17 +2,31 @@ import { parsePluginLanguagePackArtifact, pluginLanguageResourceId, type PluginLanguagePackRegistration -} from '../../shared/plugins/plugin-language-pack-artifact' -import type { PluginManifest } from '../../shared/plugins/plugin-manifest' +} from '../../../shared/plugins/artifacts/plugin-language-pack-artifact' +import { + pluginLanguagePackCoverage, + translatableCatalogPaths +} from '../../../shared/plugins/languages/plugin-language-pack-coverage' +import type { PluginManifest } from '../../../shared/plugins/manifest/plugin-manifest' import { PLUGIN_LANGUAGE_PACK_MAX_BYTES, readContainedPluginArtifactText -} from './plugin-artifact-validation' +} from '../plugin-artifact-validation' import { PluginArtifactPackRegistry } from './plugin-artifact-pack-registry' -import type { PluginContentVerifier } from './plugin-content-integrity' +import type { PluginContentVerifier } from '../plugin-content-integrity' type LanguagePackContribution = PluginManifest['contributes']['languagePacks'][number] +let englishPaths: Promise> | null = null + +// Why lazy: the English catalog is ~1 MB and only matters once a language pack loads. +function translatableEnglishPaths(): Promise> { + englishPaths ??= import('../../../renderer/src/i18n/locales/en.json').then((module) => + translatableCatalogPaths(module.default) + ) + return englishPaths +} + export class PluginLanguagePackRegistry extends PluginArtifactPackRegistry< LanguagePackContribution, PluginLanguagePackRegistration @@ -27,7 +41,7 @@ export class PluginLanguagePackRegistry extends PluginArtifactPackRegistry< contribution.path, PLUGIN_LANGUAGE_PACK_MAX_BYTES ) - const parsed = parsePluginLanguagePackArtifact(text) + const parsed = parsePluginLanguagePackArtifact(text, 'tolerant') if (!parsed.ok) { throw new Error(`language pack "${contribution.locale}" ${parsed.error}`) } @@ -38,7 +52,8 @@ export class PluginLanguagePackRegistry extends PluginArtifactPackRegistry< pluginKey: plugin.pluginKey, locale: contribution.locale, ...(contribution.displayName ? { displayName: contribution.displayName } : {}), - catalog: parsed.catalog + catalog: parsed.catalog, + coverage: pluginLanguagePackCoverage(parsed.catalog, await translatableEnglishPaths()) } } }) diff --git a/src/main/plugins/contributions/plugin-syntax-registry.test.ts b/src/main/plugins/contributions/plugin-syntax-registry.test.ts new file mode 100644 index 00000000000..4a2b16af224 --- /dev/null +++ b/src/main/plugins/contributions/plugin-syntax-registry.test.ts @@ -0,0 +1,117 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { fingerprintPluginConsent } from '../../../shared/plugins/approval/plugin-consent-fingerprint' +import { pluginManifestSchema } from '../../../shared/plugins/manifest/plugin-manifest' +import { PluginContentVerifier } from '../plugin-content-integrity' +import type { ValidDiscoveredPlugin } from '../plugin-discovery' +import { PluginSyntaxRegistry } from './plugin-syntax-registry' + +const roots: string[] = [] +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function syntaxPlugin(files: Record): Promise { + const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-syntax-')) + roots.push(rootDir) + for (const [name, content] of Object.entries(files)) { + await writeFile(join(rootDir, name), JSON.stringify(content)) + } + const manifest = pluginManifestSchema.parse({ + manifestVersion: 1, + id: 'demo-syntax', + publisher: 'samples', + name: 'Demo syntax', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + contributes: { + syntaxes: [ + { + id: 'demo', + title: 'Demo', + scopeName: 'source.demo', + path: 'demo.json', + configuration: 'demo-config.json', + extensions: ['.Demo'], + filenames: ['Demofile'] + }, + { id: 'embedded', title: 'Embedded', scopeName: 'source.embedded', path: 'embedded.json' } + ] + } + }) + return { + pluginKey: 'samples.demo-syntax', + rootDir, + manifest, + consentFingerprint: fingerprintPluginConsent(manifest), + contentHash: null, + isDev: true + } +} + +const demo = { + scopeName: 'source.demo', + patterns: [{ include: 'source.embedded' }, { match: '//.*', name: 'comment.line' }] +} +const embedded = { scopeName: 'source.embedded', patterns: [{ match: '\\d+' }] } + +it('lists file associations and hands over a language with its sibling grammars', async () => { + const registry = new PluginSyntaxRegistry(new PluginContentVerifier()) + await registry.reconcile( + [ + await syntaxPlugin({ + 'demo.json': demo, + 'demo-config.json': { comments: { lineComment: '//' } }, + 'embedded.json': embedded + }) + ], + () => true + ) + + expect(registry.list()).toEqual([ + expect.objectContaining({ + id: 'plugin:samples.demo-syntax/demo', + label: 'Demo', + extensions: ['.demo'], + filenames: ['demofile'], + revision: expect.stringMatching(/^[0-9a-f]{32}$/) + }), + expect.objectContaining({ id: 'plugin:samples.demo-syntax/embedded', extensions: [] }) + ]) + expect(registry.load('plugin:samples.demo-syntax/demo')).toMatchObject({ + scopeName: 'source.demo', + grammars: [{ scopeName: 'source.demo' }, { scopeName: 'source.embedded' }], + configuration: { comments: { lineComment: '//' } } + }) + expect(registry.load('plugin:samples.demo-syntax/missing')).toBeNull() +}) + +it('reports one error and lists nothing when a grammar is invalid', async () => { + const registry = new PluginSyntaxRegistry(new PluginContentVerifier()) + const plugin = await syntaxPlugin({ + 'demo.json': { ...demo, scopeName: 'source.other' }, + 'demo-config.json': {}, + 'embedded.json': embedded + }) + await registry.reconcile([plugin], () => true) + + expect(registry.list()).toEqual([]) + expect(registry.error(plugin.pluginKey)).toContain('syntax "demo"') +}) + +it('reports an include that does not resolve inside the plugin on the plugin card', async () => { + const registry = new PluginSyntaxRegistry(new PluginContentVerifier()) + const plugin = await syntaxPlugin({ + 'demo.json': { ...demo, patterns: [{ include: 'source.embedded#missing' }] }, + 'demo-config.json': {}, + 'embedded.json': embedded + }) + await registry.reconcile([plugin], () => true) + + expect(registry.list()).toEqual([]) + expect(registry.load('plugin:samples.demo-syntax/demo')).toBeNull() + expect(registry.error(plugin.pluginKey)).toContain('does not resolve') +}) diff --git a/src/main/plugins/contributions/plugin-syntax-registry.ts b/src/main/plugins/contributions/plugin-syntax-registry.ts new file mode 100644 index 00000000000..ec32532de64 --- /dev/null +++ b/src/main/plugins/contributions/plugin-syntax-registry.ts @@ -0,0 +1,140 @@ +import { createHash } from 'node:crypto' +import type { PluginSyntaxContribution } from '../../../shared/plugins/contributions/plugin-content-pack-contributions' +import { + PLUGIN_SYNTAX_CONFIGURATION_MAX_BYTES, + PLUGIN_SYNTAX_GRAMMAR_MAX_BYTES, + parsePluginGrammarArtifact, + parsePluginLanguageConfiguration, + validatePluginGrammarIncludes, + type PluginGrammarArtifact, + type PluginLanguageConfiguration, + type PluginSyntaxMetadata, + type PluginSyntaxPayload +} from '../../../shared/plugins/artifacts/plugin-syntax-artifact' +import { readContainedPluginArtifactText } from '../plugin-artifact-validation' +import { PluginArtifactPackRegistry } from './plugin-artifact-pack-registry' +import type { PluginContentVerifier } from '../plugin-content-integrity' +import type { DiscoveredPlugin, ValidDiscoveredPlugin } from '../plugin-discovery' + +type SyntaxEntry = PluginSyntaxMetadata & { + scopeName: string + grammar: PluginGrammarArtifact + configuration: PluginLanguageConfiguration +} + +async function loadSyntax( + plugin: ValidDiscoveredPlugin, + contribution: PluginSyntaxContribution +): Promise { + const grammarText = await readContainedPluginArtifactText( + plugin.rootDir, + contribution.path, + PLUGIN_SYNTAX_GRAMMAR_MAX_BYTES + ) + const configurationText = contribution.configuration + ? await readContainedPluginArtifactText( + plugin.rootDir, + contribution.configuration, + PLUGIN_SYNTAX_CONFIGURATION_MAX_BYTES + ) + : '{}' + try { + return { + id: `plugin:${plugin.pluginKey}/${contribution.id}`, + pluginKey: plugin.pluginKey, + label: contribution.title, + extensions: contribution.extensions.map((extension) => extension.toLowerCase()), + filenames: contribution.filenames.map((filename) => filename.toLowerCase()), + // Why: open files re-tokenize when a plugin update changes the grammar they use. + revision: createHash('sha256') + .update(grammarText) + .update('\0') + .update(configurationText) + .digest('hex') + .slice(0, 32), + scopeName: contribution.scopeName, + grammar: parsePluginGrammarArtifact(grammarText, contribution.scopeName), + configuration: parsePluginLanguageConfiguration(configurationText) + } + } catch (error) { + throw new Error( + `syntax "${contribution.id}": ${error instanceof Error ? error.message : String(error)}` + ) + } +} + +/** Validates every syntax grammar when its plugin is approved, and hands one language's + * grammars to the renderer only when a matching file opens. */ +export class PluginSyntaxRegistry { + private readonly entries: PluginArtifactPackRegistry + private readonly includeErrors = new Map() + + constructor(contentVerifier: PluginContentVerifier) { + this.entries = new PluginArtifactPackRegistry({ + contentVerifier, + contributions: (plugin) => plugin.manifest.contributes.syntaxes, + load: loadSyntax + }) + } + + private valid(): SyntaxEntry[] { + return this.entries.list().filter((entry) => !this.includeErrors.has(entry.pluginKey)) + } + + list(): PluginSyntaxMetadata[] { + return this.valid().map(({ id, pluginKey, label, extensions, filenames, revision }) => ({ + id, + pluginKey, + label, + extensions, + filenames, + revision + })) + } + + error(pluginKey: string): string | null { + return this.entries.error(pluginKey) ?? this.includeErrors.get(pluginKey) ?? null + } + + async reconcile( + discovered: readonly DiscoveredPlugin[], + isApproved: (plugin: ValidDiscoveredPlugin) => boolean + ): Promise { + await this.entries.reconcile(discovered, isApproved) + // Why at approval: a broken include then shows on the plugin card, and opening a file never + // pays for the check. + this.includeErrors.clear() + const byPlugin = new Map() + for (const entry of this.entries.list()) { + const grammars = byPlugin.get(entry.pluginKey) ?? [] + grammars.push(entry.grammar) + byPlugin.set(entry.pluginKey, grammars) + } + for (const [pluginKey, grammars] of byPlugin) { + try { + validatePluginGrammarIncludes(grammars) + } catch (error) { + this.includeErrors.set(pluginKey, error instanceof Error ? error.message : String(error)) + } + } + } + + load(id: string): PluginSyntaxPayload | null { + const all = this.valid() + const entry = all.find((candidate) => candidate.id === id) + if (!entry) { + return null + } + // Why the whole plugin: a grammar may include its sibling grammars' scopes. + const grammars = all + .filter((candidate) => candidate.pluginKey === entry.pluginKey) + .map((candidate) => candidate.grammar) + return { + id: entry.id, + revision: entry.revision, + scopeName: entry.scopeName, + grammars: [entry.grammar, ...grammars.filter((grammar) => grammar !== entry.grammar)], + configuration: entry.configuration + } + } +} diff --git a/src/main/plugins/plugin-theme-registries.test.ts b/src/main/plugins/contributions/plugin-theme-registries.test.ts similarity index 88% rename from src/main/plugins/plugin-theme-registries.test.ts rename to src/main/plugins/contributions/plugin-theme-registries.test.ts index b2dd7a26f08..d1934035358 100644 --- a/src/main/plugins/plugin-theme-registries.test.ts +++ b/src/main/plugins/contributions/plugin-theme-registries.test.ts @@ -2,10 +2,10 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' -import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' -import { PluginContentVerifier } from './plugin-content-integrity' -import type { ValidDiscoveredPlugin } from './plugin-discovery' +import { fingerprintPluginConsent } from '../../../shared/plugins/approval/plugin-consent-fingerprint' +import { pluginManifestSchema } from '../../../shared/plugins/manifest/plugin-manifest' +import { PluginContentVerifier } from '../plugin-content-integrity' +import type { ValidDiscoveredPlugin } from '../plugin-discovery' import { createPluginTerminalThemeRegistry, createPluginThemeRegistry @@ -30,8 +30,8 @@ async function themePlugin(files: { app?: unknown; terminal?: unknown }) { engines: { orca: '>=1.0.0' }, pluginApi: 1, contributes: { - themes: [{ id: 'midnight', label: 'Midnight', path: 'app.json' }], - terminalThemes: [{ id: 'midnight', label: 'Midnight', path: 'terminal.json' }] + themes: [{ id: 'midnight', title: 'Midnight', path: 'app.json' }], + terminalThemes: [{ id: 'midnight', title: 'Midnight', path: 'terminal.json' }] } }) const plugin: ValidDiscoveredPlugin = { diff --git a/src/main/plugins/plugin-theme-registries.ts b/src/main/plugins/contributions/plugin-theme-registries.ts similarity index 80% rename from src/main/plugins/plugin-theme-registries.ts rename to src/main/plugins/contributions/plugin-theme-registries.ts index 494fa49db4c..caf6cd3324e 100644 --- a/src/main/plugins/plugin-theme-registries.ts +++ b/src/main/plugins/contributions/plugin-theme-registries.ts @@ -1,20 +1,20 @@ import { createHash } from 'node:crypto' -import type { PluginThemeContribution } from '../../shared/plugins/plugin-content-pack-contributions' +import type { PluginThemeContribution } from '../../../shared/plugins/contributions/plugin-content-pack-contributions' import { parsePluginAppThemeArtifact, type PluginThemeRegistration -} from '../../shared/plugins/plugin-theme-artifact' +} from '../../../shared/plugins/artifacts/plugin-theme-artifact' import { parsePluginTerminalThemeArtifact, type PluginTerminalThemeRegistration -} from '../../shared/plugins/plugin-terminal-theme-artifact' +} from '../../../shared/plugins/artifacts/plugin-terminal-theme-artifact' import { PLUGIN_THEME_MAX_BYTES, readContainedPluginArtifactText -} from './plugin-artifact-validation' +} from '../plugin-artifact-validation' import { PluginArtifactPackRegistry } from './plugin-artifact-pack-registry' -import type { PluginContentVerifier } from './plugin-content-integrity' -import type { ValidDiscoveredPlugin } from './plugin-discovery' +import type { PluginContentVerifier } from '../plugin-content-integrity' +import type { ValidDiscoveredPlugin } from '../plugin-discovery' async function readTheme( plugin: ValidDiscoveredPlugin, @@ -42,7 +42,7 @@ async function readTheme( revision: createHash('sha256').update(text).digest('hex').slice(0, 32), pluginKey: plugin.pluginKey, pluginName: plugin.manifest.name, - label: contribution.label + label: contribution.title } } catch (error) { throw new Error( diff --git a/src/main/plugins/plugin-atomic-file-write.test.ts b/src/main/plugins/installation/plugin-atomic-file-write.test.ts similarity index 100% rename from src/main/plugins/plugin-atomic-file-write.test.ts rename to src/main/plugins/installation/plugin-atomic-file-write.test.ts diff --git a/src/main/plugins/plugin-atomic-file-write.ts b/src/main/plugins/installation/plugin-atomic-file-write.ts similarity index 100% rename from src/main/plugins/plugin-atomic-file-write.ts rename to src/main/plugins/installation/plugin-atomic-file-write.ts diff --git a/src/main/plugins/plugin-current-pointer.ts b/src/main/plugins/installation/plugin-current-pointer.ts similarity index 100% rename from src/main/plugins/plugin-current-pointer.ts rename to src/main/plugins/installation/plugin-current-pointer.ts diff --git a/src/main/plugins/plugin-git-repository.integration.test.ts b/src/main/plugins/installation/plugin-git-repository.integration.test.ts similarity index 79% rename from src/main/plugins/plugin-git-repository.integration.test.ts rename to src/main/plugins/installation/plugin-git-repository.integration.test.ts index 7c107676a7d..cd0617db9a5 100644 --- a/src/main/plugins/plugin-git-repository.integration.test.ts +++ b/src/main/plugins/installation/plugin-git-repository.integration.test.ts @@ -4,7 +4,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, expect, it, vi } from 'vitest' -import { serveRepositoriesOverTestSsh } from './__mocks__/plugin-git-ssh-remote-fixture' +import { serveRepositoriesOverTestSsh } from '../__mocks__/plugin-git-ssh-remote-fixture' +import { hashPluginTree } from '../plugin-content-hash' import { checkoutPluginGitSource, pluginGitAllowedProtocols, @@ -140,3 +141,38 @@ it('only narrows an inherited Git protocol allowlist', () => { expect(pluginGitAllowedProtocols('https:file:ext')).toBe('https') expect(pluginGitAllowedProtocols('')).toBe('') }) + +it('materializes only the catalog path, hashed the same after a sibling-only commit', async () => { + const f = await fixture() + for (const name of ['a', 'b']) { + await mkdir(join(f.repository, 'plugins', name, 'nested'), { recursive: true }) + await writeFile(join(f.repository, 'plugins', name, 'nested', 'file.txt'), `${name}\n`) + } + await f.git(['add', '--all']) + await f.git(['commit', '--quiet', '-m', 'Two plugins']) + const download = async (path: string) => { + const destination = join(f.root, `download-${randomBytes(4).toString('hex')}`) + const commit = await f.git(['rev-parse', 'HEAD']) + await checkoutPluginGitSource({ + url: PLUGIN_URL, + ref: commit, + path, + destination, + workingDirectory: f.root + }) + return destination + } + const first = await download('plugins/a') + expect(await readdir(first)).toEqual(['nested']) + expect(await readFile(join(first, 'nested', 'file.txt'), 'utf8')).toBe('a\n') + const before = await hashPluginTree(first) + + await writeFile(join(f.repository, 'plugins', 'b', 'nested', 'file.txt'), 'b2\n') + await f.git(['commit', '--quiet', '-am', 'Sibling only']) + expect(await hashPluginTree(await download('plugins/a'))).toEqual(before) + + await expect(download('plugins/missing')).rejects.toThrow() + for (const unsafe of ['../a', '/plugins/a', 'plugins\\a', 'plugins/./a', 'plugins/a/']) { + await expect(download(unsafe)).rejects.toThrow('unsafe plugin repository path') + } +}) diff --git a/src/main/plugins/plugin-git-repository.ts b/src/main/plugins/installation/plugin-git-repository.ts similarity index 92% rename from src/main/plugins/plugin-git-repository.ts rename to src/main/plugins/installation/plugin-git-repository.ts index 385a5e28874..777826f67b5 100644 --- a/src/main/plugins/plugin-git-repository.ts +++ b/src/main/plugins/installation/plugin-git-repository.ts @@ -4,14 +4,14 @@ import { join } from 'node:path' import { isAllowedPluginGitUrl, PLUGIN_COMMIT_PATTERN -} from '../../shared/plugins/plugin-install-lockfile' +} from '../../../shared/plugins/installation/plugin-install-lockfile' import { pluginGitCloneArgs, pluginGitConfigArgs, pluginGitFetchCommitArgs -} from '../../shared/plugins/plugin-git-arguments' -import { gitExecFileAsync } from '../git/runner' -import { MAX_PLUGIN_TOTAL_BYTES } from './plugin-content-hash' +} from '../../../shared/plugins/installation/plugin-git-arguments' +import { gitExecFileAsync } from '../../git/runner' +import { MAX_PLUGIN_TOTAL_BYTES } from '../plugin-content-hash' import { materializePluginGitTree } from './plugin-git-tree' const PLUGIN_GIT_TIMEOUT_MS = 120_000 @@ -162,6 +162,8 @@ export async function readPluginGitCommit( export async function checkoutPluginGitSource(input: { url: string ref: string + /** Plugin directory inside the repository; absent means the repository root. */ + path?: string destination: string workingDirectory: string maxDownloadBytes?: number @@ -173,7 +175,12 @@ export async function checkoutPluginGitSource(input: { const { commit } = await readPluginGitCommit( { ...input, maxDownloadBytes: input.maxDownloadBytes ?? PLUGIN_GIT_DOWNLOAD_MAX_BYTES }, (repository, resolved) => - materializePluginGitTree({ repository, commit: resolved, destination: input.destination }) + materializePluginGitTree({ + repository, + commit: resolved, + destination: input.destination, + ...(input.path ? { path: input.path } : {}) + }) ) return commit } diff --git a/src/main/plugins/plugin-git-tree.test.ts b/src/main/plugins/installation/plugin-git-tree.test.ts similarity index 100% rename from src/main/plugins/plugin-git-tree.test.ts rename to src/main/plugins/installation/plugin-git-tree.test.ts diff --git a/src/main/plugins/plugin-git-tree.ts b/src/main/plugins/installation/plugin-git-tree.ts similarity index 90% rename from src/main/plugins/plugin-git-tree.ts rename to src/main/plugins/installation/plugin-git-tree.ts index ea06cdc6228..25cf6c3e59e 100644 --- a/src/main/plugins/plugin-git-tree.ts +++ b/src/main/plugins/installation/plugin-git-tree.ts @@ -1,9 +1,12 @@ import { mkdir, writeFile } from 'node:fs/promises' import { join } from 'node:path' -import { gitExecFileAsyncBuffer } from '../git/runner' -import { pluginGitTreeArgs } from '../../shared/plugins/plugin-git-arguments' -import { pluginRelativePathError } from '../../shared/plugins/plugin-path-safety' -import { MAX_PLUGIN_FILES, MAX_PLUGIN_TOTAL_BYTES } from './plugin-content-hash' +import { gitExecFileAsyncBuffer } from '../../git/runner' +import { pluginGitTreeArgs } from '../../../shared/plugins/installation/plugin-git-arguments' +import { + isPluginRepositoryPath, + pluginRelativePathError +} from '../../../shared/plugins/installation/plugin-path-safety' +import { MAX_PLUGIN_FILES, MAX_PLUGIN_TOTAL_BYTES } from '../plugin-content-hash' const MAX_PATH_BYTES = 1024 const MAX_PATH_DEPTH = 64 @@ -128,9 +131,14 @@ export async function materializePluginGitTree(input: { repository: string commit: string destination: string + /** Plugin directory inside the repository; only its subtree is written. */ + path?: string }): Promise { + if (input.path !== undefined && !isPluginRepositoryPath(input.path)) { + throw new Error(`unsafe plugin repository path: ${input.path}`) + } const options = { cwd: input.repository, timeout: PLUGIN_GIT_TREE_TIMEOUT_MS } - const metadata = await gitExecFileAsyncBuffer(pluginGitTreeArgs(input.commit), { + const metadata = await gitExecFileAsyncBuffer(pluginGitTreeArgs(input.commit, input.path), { ...options, maxBuffer: TREE_METADATA_MAX_BYTES }) diff --git a/src/main/plugins/plugin-install-lockfile-store.ts b/src/main/plugins/installation/plugin-install-lockfile-store.ts similarity index 97% rename from src/main/plugins/plugin-install-lockfile-store.ts rename to src/main/plugins/installation/plugin-install-lockfile-store.ts index c19a8659f5e..2b64a71a65c 100644 --- a/src/main/plugins/plugin-install-lockfile-store.ts +++ b/src/main/plugins/installation/plugin-install-lockfile-store.ts @@ -6,7 +6,7 @@ import { parsePluginLockfile, serializePluginLockfile, type PluginLockfile -} from '../../shared/plugins/plugin-install-lockfile' +} from '../../../shared/plugins/installation/plugin-install-lockfile' import { writePluginFileAtomically } from './plugin-atomic-file-write' import { recoverPluginLockfile } from './plugin-install-provenance' diff --git a/src/main/plugins/plugin-install-provenance.ts b/src/main/plugins/installation/plugin-install-provenance.ts similarity index 96% rename from src/main/plugins/plugin-install-provenance.ts rename to src/main/plugins/installation/plugin-install-provenance.ts index 76f06c318fe..29e5f94cd83 100644 --- a/src/main/plugins/plugin-install-provenance.ts +++ b/src/main/plugins/installation/plugin-install-provenance.ts @@ -6,8 +6,8 @@ import { pluginLockEntrySchema, type PluginLockEntry, type PluginLockfile -} from '../../shared/plugins/plugin-install-lockfile' -import { isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest' +} from '../../../shared/plugins/installation/plugin-install-lockfile' +import { isQualifiedPluginKey } from '../../../shared/plugins/manifest/plugin-manifest' import { writePluginFileAtomically } from './plugin-atomic-file-write' import { readPluginCurrentPointer } from './plugin-current-pointer' diff --git a/src/main/plugins/plugin-install-publication.ts b/src/main/plugins/installation/plugin-install-publication.ts similarity index 98% rename from src/main/plugins/plugin-install-publication.ts rename to src/main/plugins/installation/plugin-install-publication.ts index 7dd05c96f09..a3e7041dd7d 100644 --- a/src/main/plugins/plugin-install-publication.ts +++ b/src/main/plugins/installation/plugin-install-publication.ts @@ -4,7 +4,7 @@ import { PLUGIN_CONTENT_HASH_PATTERN, upsertPluginLock, type PluginLockEntry -} from '../../shared/plugins/plugin-install-lockfile' +} from '../../../shared/plugins/installation/plugin-install-lockfile' import { readPluginCurrentPointer, restorePluginCurrentPointer, diff --git a/src/main/plugins/plugin-install-staging.ts b/src/main/plugins/installation/plugin-install-staging.ts similarity index 90% rename from src/main/plugins/plugin-install-staging.ts rename to src/main/plugins/installation/plugin-install-staging.ts index 6909c0847b0..fc1fcddf2fa 100644 --- a/src/main/plugins/plugin-install-staging.ts +++ b/src/main/plugins/installation/plugin-install-staging.ts @@ -7,25 +7,26 @@ import { qualifiedPluginKey, satisfiesOrcaEngineRange, type PluginManifest -} from '../../shared/plugins/plugin-manifest' -import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' +} from '../../../shared/plugins/manifest/plugin-manifest' +import { pluginManifestCompatibilityError } from '../../../shared/plugins/manifest/plugin-manifest-compatibility' +import { fingerprintPluginConsent } from '../../../shared/plugins/approval/plugin-consent-fingerprint' import { pluginCatalogReleaseError, type PluginCatalogRelease -} from '../../shared/plugins/plugin-catalog-release' +} from '../../../shared/plugins/catalog/plugin-catalog-release' import type { PluginInstallSource, PluginLockEntry -} from '../../shared/plugins/plugin-install-lockfile' +} from '../../../shared/plugins/installation/plugin-install-lockfile' import { validateDeclaredPluginArtifacts, validatePluginInstallContent, type PluginArtifactValidationResult -} from './plugin-artifact-validation' +} from '../plugin-artifact-validation' import { renamePluginFileWithWindowsRetry } from './plugin-atomic-file-write' -import { hashPluginTree } from './plugin-content-hash' +import { hashPluginTree } from '../plugin-content-hash' import { publishPluginInstall } from './plugin-install-publication' -import { readPluginManifestText } from './plugin-manifest-file' +import { readPluginManifestText } from '../plugin-manifest-file' import { pluginInstallTrustError } from './plugin-install-trust' export type PluginInstallResult = @@ -72,7 +73,8 @@ async function readInstallManifest( } const parsed = parsePluginManifest(raw) if (!parsed.ok) { - return { ok: false, error: `invalid manifest: ${parsed.error}` } + const incompatible = pluginManifestCompatibilityError(raw, hostVersion) + return { ok: false, error: incompatible ?? `invalid manifest: ${parsed.error}` } } if (!satisfiesOrcaEngineRange(hostVersion, parsed.manifest.engines.orca)) { return { @@ -141,6 +143,8 @@ export async function installStagedPluginTree(input: { /** Trusted bundled bytes may restore an immutable directory damaged on disk. */ repairCorruptedVersion?: boolean blockedPluginReason?: (pluginKey: string) => string | null + /** A refusal decided by the inspected manifest itself, e.g. full access being off. */ + manifestError?: (manifest: PluginManifest) => string | null }): Promise { const sourceInspection = await inspectPluginInstallTree({ rootDir: input.stagingDir, @@ -161,6 +165,10 @@ export async function installStagedPluginTree(input: { if (blockedReason) { return { ok: false, error: `plugin is blocked by Orca's safety list: ${blockedReason}` } } + const manifestError = input.manifestError?.(sourceInspection.manifest) + if (manifestError) { + return { ok: false, error: manifestError } + } let manifest = sourceInspection.manifest const pluginKey = sourceInspection.pluginKey const pluginDir = join(input.pluginsDir, pluginKey) diff --git a/src/main/plugins/plugin-install-trust.test.ts b/src/main/plugins/installation/plugin-install-trust.test.ts similarity index 62% rename from src/main/plugins/plugin-install-trust.test.ts rename to src/main/plugins/installation/plugin-install-trust.test.ts index 8f1c5e302d9..6a1cbeec3cd 100644 --- a/src/main/plugins/plugin-install-trust.test.ts +++ b/src/main/plugins/installation/plugin-install-trust.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import type { PluginInstallSource } from '../../shared/plugins/plugin-install-lockfile' +import type { PluginInstallSource } from '../../../shared/plugins/installation/plugin-install-lockfile' import { installBundledPlugin, installPluginFromLocalPath, @@ -39,25 +39,45 @@ afterEach(async () => { }) describe('plugin install trust', () => { - it.each<[PluginInstallSource, string | null]>([ + const official = 'https://github.com/stablyai/orca-plugins.git' + const marketplace = (url: string, plugin: string): PluginInstallSource => ({ + kind: 'marketplace', + marketplace: { url, ref: 'main', resolvedCommit: 'a'.repeat(40) }, + plugin: { url: plugin, ref: 'main' } + }) + const refused = (key: string): string => + `${key} uses a name reserved for official Orca plugins; Orca installs it only from its official catalog` + + it.each<[string, PluginInstallSource, boolean]>([ [ - { - kind: 'git', - url: 'https://github.com/attacker/orca-secrets.git', - ref: 'main' - }, - 'reserved plugin identity community.orca-secrets must resolve to the stablyai organization' + 'stablyai.orca-secrets', + marketplace(official, 'git@github.com:stablyai/orca-secrets.git'), + true + ], + ['openai.tools', marketplace(official, 'https://github.com/stablyai/tools.git'), true], + ['stablyai.orca-secrets', marketplace(official, 'https://github.com/attacker/x.git'), false], + [ + 'stablyai.orca-secrets', + marketplace( + 'https://github.com/acme/plugins.git', + 'https://github.com/stablyai/orca-secrets.git' + ), + false ], [ - { - kind: 'git', - url: 'git@github.com:stablyai/orca-secrets.git', - ref: 'main' - }, - null - ] - ])('enforces reserved source organization', (source, expected) => { - expect(pluginInstallTrustError('community.orca-secrets', source)).toBe(expected) + 'stablyai.tools', + { kind: 'git', url: 'https://github.com/stablyai/tools.git', ref: '' }, + false + ], + [ + 'orca-team.tools', + marketplace('https://gitlab.com/acme/plugins.git', 'https://gitlab.com/acme/t.git'), + false + ], + ['github.tools', { kind: 'git', url: 'https://github.com/acme/t.git', ref: '' }, false], + ['acme.tools', { kind: 'git', url: 'https://github.com/acme/t.git', ref: '' }, true] + ])('accepts reserved %s only from the official catalog', (key, source, accepted) => { + expect(pluginInstallTrustError(key, source)).toBe(accepted ? null : refused(key)) }) it('rejects locally installed reserved identities before publication', async () => { @@ -69,7 +89,7 @@ describe('plugin install trust', () => { installPluginFromLocalPath({ pluginsDir, sourcePath, hostVersion: '1.4.0' }) ).resolves.toEqual({ ok: false, - error: 'reserved plugin identity stablyai.orca-skills cannot be installed from a local path' + error: refused('stablyai.orca-skills') }) await expect(readPluginLockfile(pluginsDir)).resolves.toEqual({ version: 1, plugins: {} }) }) diff --git a/src/main/plugins/installation/plugin-install-trust.ts b/src/main/plugins/installation/plugin-install-trust.ts new file mode 100644 index 00000000000..580dcb70f63 --- /dev/null +++ b/src/main/plugins/installation/plugin-install-trust.ts @@ -0,0 +1,28 @@ +import type { PluginInstallSource } from '../../../shared/plugins/installation/plugin-install-lockfile' +import { + isOfficialMarketplaceGitSource, + isOfficialOrganizationGitSource, + hasOfficialPluginPublisher, + isReservedPluginIdentity +} from '../../../shared/plugins/catalog/plugin-marketplace' + +export function pluginInstallTrustError( + pluginKey: string, + source: PluginInstallSource +): string | null { + if (source.kind === 'bundled') { + return source.bundleId === pluginKey && hasOfficialPluginPublisher(pluginKey) + ? null + : 'bundled plugins must use the official stablyai publisher' + } + if (!isReservedPluginIdentity(pluginKey)) { + return null + } + // Why the catalog and not the repository: a direct Git URL or another catalog could point + // at any stablyai repository, including one never meant to ship. + return source.kind === 'marketplace' && + isOfficialMarketplaceGitSource(source.marketplace.url) && + isOfficialOrganizationGitSource(source.plugin.url) + ? null + : `${pluginKey} uses a name reserved for official Orca plugins; Orca installs it only from its official catalog` +} diff --git a/src/main/plugins/plugin-install.test.ts b/src/main/plugins/installation/plugin-install.test.ts similarity index 96% rename from src/main/plugins/plugin-install.test.ts rename to src/main/plugins/installation/plugin-install.test.ts index 39791ed55b2..9d3a4bdcccc 100644 --- a/src/main/plugins/plugin-install.test.ts +++ b/src/main/plugins/installation/plugin-install.test.ts @@ -20,12 +20,12 @@ import { readPluginLockfile, removeInstalledPlugin } from './plugin-install' -import { serveRepositoriesOverTestSsh } from './__mocks__/plugin-git-ssh-remote-fixture' -import { PLUGIN_MANIFEST_MAX_BYTES } from './plugin-manifest-file' +import { serveRepositoriesOverTestSsh } from '../__mocks__/plugin-git-ssh-remote-fixture' +import { PLUGIN_MANIFEST_MAX_BYTES } from '../plugin-manifest-file' import { readPluginCurrentPointer } from './plugin-current-pointer' import { writePluginLockfile } from './plugin-install-lockfile-store' import { installStagedPluginTree } from './plugin-install-staging' -import * as manifestFile from './plugin-manifest-file' +import * as manifestFile from '../plugin-manifest-file' const roots: string[] = [] const execFileAsync = promisify(execFile) @@ -46,7 +46,7 @@ async function writePluginSource( JSON.stringify({ manifestVersion: 1, id: options.id ?? 'demo', - publisher: 'orca-samples', + publisher: 'samples', name: 'Demo', version: '1.0.0', engines: { orca: '>=1.0.0' }, @@ -272,7 +272,7 @@ describe('installPluginFromLocalPath', () => { } const versionDirs = ( - await readdir(join(pluginsDir, 'orca-samples.demo'), { + await readdir(join(pluginsDir, 'samples.demo'), { withFileTypes: true }) ) @@ -300,9 +300,7 @@ describe('installPluginFromLocalPath', () => { } } - const versionDirs = ( - await readdir(join(pluginsDir, 'orca-samples.demo'), { withFileTypes: true }) - ) + const versionDirs = (await readdir(join(pluginsDir, 'samples.demo'), { withFileTypes: true })) .filter((entry) => entry.isDirectory() && /^[0-9a-f]{64}$/.test(entry.name)) .map((entry) => entry.name) .sort() @@ -382,7 +380,7 @@ describe('installPluginFromLocalPath', () => { const lock = JSON.parse(await readFile(join(pluginsDir, 'plugins.lock.json'), 'utf8')) as { plugins: Record } - expect(Object.keys(lock.plugins).sort()).toEqual(['orca-samples.first', 'orca-samples.second']) + expect(Object.keys(lock.plugins).sort()).toEqual(['samples.first', 'samples.second']) }) it('serializes concurrent lockfile publications without temporary-file collisions', async () => { @@ -487,7 +485,7 @@ describe('removeInstalledPlugin', () => { await writeFile(marker, 'keep') await symlink( outside, - join(pluginsDir, 'orca-samples.demo'), + join(pluginsDir, 'samples.demo'), process.platform === 'win32' ? 'junction' : 'dir' ) @@ -495,7 +493,7 @@ describe('removeInstalledPlugin', () => { removeInstalledPlugin({ pluginsDir, pluginsDataDir, - pluginKey: 'orca-samples.demo' + pluginKey: 'samples.demo' }) ).rejects.toThrow('outside') await expect(readFile(marker, 'utf8')).resolves.toBe('keep') @@ -504,7 +502,7 @@ describe('removeInstalledPlugin', () => { it('removes qualified install and data directories', async () => { const pluginsDir = await tempRoot('orca-plugin-installs-') const pluginsDataDir = await tempRoot('orca-plugin-data-') - const key = 'orca-samples.demo' + const key = 'samples.demo' await mkdir(join(pluginsDir, key)) await mkdir(join(pluginsDataDir, key)) await writeFile(join(pluginsDir, key, 'content'), 'installed') diff --git a/src/main/plugins/plugin-install.ts b/src/main/plugins/installation/plugin-install.ts similarity index 66% rename from src/main/plugins/plugin-install.ts rename to src/main/plugins/installation/plugin-install.ts index e76ad36d0ea..16ec49201ab 100644 --- a/src/main/plugins/plugin-install.ts +++ b/src/main/plugins/installation/plugin-install.ts @@ -1,32 +1,34 @@ -import { mkdtemp, readdir, realpath, rm } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { existsSync } from 'node:fs' import { tmpdir } from 'node:os' -import { isAbsolute, join, relative, resolve, sep } from 'node:path' +import { join } from 'node:path' import { PLUGIN_MANIFEST_FILENAME, - isQualifiedPluginKey -} from '../../shared/plugins/plugin-manifest' + isQualifiedPluginKey, + type PluginManifest +} from '../../../shared/plugins/manifest/plugin-manifest' import { isAllowedPluginGitUrl, PLUGIN_COMMIT_PATTERN, - PLUGIN_CONTENT_HASH_PATTERN, - pluginInstallSourceSchema, - removePluginLock -} from '../../shared/plugins/plugin-install-lockfile' -import type { PluginCatalogRelease } from '../../shared/plugins/plugin-catalog-release' -import { readPluginLockfile, writePluginLockfile } from './plugin-install-lockfile-store' + pluginInstallSourceSchema +} from '../../../shared/plugins/installation/plugin-install-lockfile' +import type { PluginCatalogRelease } from '../../../shared/plugins/catalog/plugin-catalog-release' +import { readPluginLockfile } from './plugin-install-lockfile-store' +import { serializePluginMutation } from './plugin-mutation-queue' import { inspectPluginInstallTree, installStagedPluginTree, type PluginInstallResult } from './plugin-install-staging' import { checkoutPluginGitSource } from './plugin-git-repository' -import { readPluginCurrentPointer } from './plugin-current-pointer' +import { findPluginRollbackCandidate } from './plugin-rollback-candidate' import { readPluginInstallProvenance } from './plugin-install-provenance' import { publishPluginInstall } from './plugin-install-publication' export type { PluginInstallResult } from './plugin-install-staging' +export { removeInstalledPlugin, retireBundledPlugin } from './plugin-removal' + export { PLUGIN_LOCKFILE_MAX_BYTES, pluginLockfilePath, @@ -35,28 +37,6 @@ export { // Use system Git credentials without install scripts; publish immutable trees atomically and retain one rollback. -const pluginMutationChains = new Map>() - -async function serializePluginMutation( - pluginsDir: string, - operation: () => Promise -): Promise { - const previous = pluginMutationChains.get(pluginsDir) ?? Promise.resolve() - const run = previous.catch(() => undefined).then(operation) - const settled = run.then( - () => undefined, - () => undefined - ) - pluginMutationChains.set(pluginsDir, settled) - try { - return await run - } finally { - if (pluginMutationChains.get(pluginsDir) === settled) { - pluginMutationChains.delete(pluginsDir) - } - } -} - export async function installPluginFromLocalPath(input: { pluginsDir: string sourcePath: string @@ -142,9 +122,12 @@ export async function installPluginFromMarketplace(input: { expectedPluginKey: string expectedResolvedCommit: string marketplace: { url: string; ref: string; resolvedCommit: string } - plugin: { url: string; ref: string } + plugin: { url: string; ref: string; path?: string } release?: PluginCatalogRelease blockedPluginReason?: (pluginKey: string) => string | null + manifestError?: (manifest: PluginManifest) => string | null + /** Only replace an install from this marketplace; set by auto-update so Remove wins a race. */ + requireInstalled?: boolean }): Promise { const source = pluginInstallSourceSchema.parse({ kind: 'marketplace', @@ -158,11 +141,27 @@ export async function installPluginFromMarketplace(input: { return { ok: false, error: 'invalid previewed plugin commit' } } return serializePluginMutation(input.pluginsDir, async () => { + const entry = (await readPluginLockfile(input.pluginsDir)).plugins[input.expectedPluginKey] + const installedFrom = entry?.source.kind === 'marketplace' ? entry.source.marketplace : null + if (input.requireInstalled && installedFrom?.url !== input.marketplace.url) { + return { ok: false, error: 'plugin was removed before its update installed' } + } + // Why: a second catalog listing the same id must never take over an install silently. + if ( + installedFrom && + (installedFrom.url !== input.marketplace.url || installedFrom.ref !== input.marketplace.ref) + ) { + return { + ok: false, + error: `${input.expectedPluginKey} is installed from ${installedFrom.url}; remove it first to install it from another catalog` + } + } const stagingDir = await mkdtemp(join(tmpdir(), 'orca-plugin-marketplace-install-')) try { const resolvedCommit = await checkoutPluginGitSource({ url: input.plugin.url, ref: input.release?.commit ?? input.plugin.ref, + ...(input.plugin.path ? { path: input.plugin.path } : {}), destination: stagingDir, workingDirectory: tmpdir() }) @@ -177,7 +176,8 @@ export async function installPluginFromMarketplace(input: { resolvedCommit, expectedPluginKey: input.expectedPluginKey, ...(input.release ? { release: input.release } : {}), - blockedPluginReason: input.blockedPluginReason + blockedPluginReason: input.blockedPluginReason, + manifestError: input.manifestError }) } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) } @@ -203,28 +203,11 @@ export async function rollbackInstalledPlugin(input: { } return serializePluginMutation(input.pluginsDir, async () => { const pluginDir = join(input.pluginsDir, input.pluginKey) - const currentContentHash = await readPluginCurrentPointer(pluginDir).catch(() => null) - if (!currentContentHash) { - return { ok: false, error: 'installed plugin has no current version' } + const candidate = await findPluginRollbackCandidate(pluginDir) + if (!candidate.ok) { + return candidate } - const candidates = (await readdir(pluginDir, { withFileTypes: true }).catch(() => [])) - .filter( - (entry) => - entry.isDirectory() && - PLUGIN_CONTENT_HASH_PATTERN.test(entry.name) && - entry.name !== currentContentHash - ) - .map((entry) => entry.name) - if (candidates.length !== 1) { - return { - ok: false, - error: - candidates.length === 0 - ? 'no rollback version is available' - : 'rollback state is ambiguous' - } - } - const contentHash = candidates[0]! + const { contentHash } = candidate const provenance = await readPluginInstallProvenance(pluginDir, contentHash) if ( !provenance || @@ -259,56 +242,3 @@ export async function rollbackInstalledPlugin(input: { } }) } - -export async function removeInstalledPlugin(input: { - pluginsDir: string - pluginsDataDir: string - pluginKey: string -}): Promise { - await serializePluginMutation(input.pluginsDir, async () => { - if (!isQualifiedPluginKey(input.pluginKey)) { - throw new Error(`invalid qualified plugin key: ${input.pluginKey}`) - } - const lock = await readPluginLockfile(input.pluginsDir) - if (lock.plugins[input.pluginKey]?.source.kind === 'bundled') { - throw new Error(`cannot remove protected plugin ${input.pluginKey}`) - } - await removeResolvedPluginDirectory(input.pluginsDir, input.pluginKey) - await removeResolvedPluginDirectory(input.pluginsDataDir, input.pluginKey) - await writePluginLockfile( - input.pluginsDir, - removePluginLock(await readPluginLockfile(input.pluginsDir), input.pluginKey) - ) - }) -} - -async function removeResolvedPluginDirectory(rootDir: string, pluginKey: string): Promise { - let rootReal: string - let targetReal: string - try { - rootReal = await realpath(resolve(rootDir)) - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return - } - throw error - } - try { - targetReal = await realpath(resolve(rootDir, pluginKey)) - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return - } - throw error - } - const fromRoot = relative(rootReal, targetReal) - if ( - fromRoot.length === 0 || - isAbsolute(fromRoot) || - fromRoot === '..' || - fromRoot.startsWith(`..${sep}`) - ) { - throw new Error(`refusing to remove plugin path outside ${rootReal}`) - } - await rm(resolve(rootDir, pluginKey), { recursive: true, force: true }) -} diff --git a/src/main/plugins/plugin-installation-state.ts b/src/main/plugins/installation/plugin-installation-state.ts similarity index 94% rename from src/main/plugins/plugin-installation-state.ts rename to src/main/plugins/installation/plugin-installation-state.ts index e36889d1ee9..23eda7d2bac 100644 --- a/src/main/plugins/plugin-installation-state.ts +++ b/src/main/plugins/installation/plugin-installation-state.ts @@ -2,9 +2,9 @@ import { isInvalidDiscoveredPlugin, type DiscoveredPlugin, type ValidDiscoveredPlugin -} from './plugin-discovery' +} from '../plugin-discovery' import { readPluginLockfile } from './plugin-install' -import { PluginLogBuffer, type PluginLogLine } from './plugin-log-buffer' +import { PluginLogBuffer, type PluginLogLine } from '../plugin-log-buffer' export class PluginInstallationState { discovered: DiscoveredPlugin[] = [] diff --git a/src/main/plugins/installation/plugin-mutation-queue.ts b/src/main/plugins/installation/plugin-mutation-queue.ts new file mode 100644 index 00000000000..ebc7e5ff755 --- /dev/null +++ b/src/main/plugins/installation/plugin-mutation-queue.ts @@ -0,0 +1,22 @@ +/** Serializes install, rollback and removal per plugins directory so lock writes never interleave. */ +const pluginMutationChains = new Map>() + +export async function serializePluginMutation( + pluginsDir: string, + operation: () => Promise +): Promise { + const previous = pluginMutationChains.get(pluginsDir) ?? Promise.resolve() + const run = previous.catch(() => undefined).then(operation) + const settled = run.then( + () => undefined, + () => undefined + ) + pluginMutationChains.set(pluginsDir, settled) + try { + return await run + } finally { + if (pluginMutationChains.get(pluginsDir) === settled) { + pluginMutationChains.delete(pluginsDir) + } + } +} diff --git a/src/main/plugins/installation/plugin-removal.ts b/src/main/plugins/installation/plugin-removal.ts new file mode 100644 index 00000000000..41fdb2dcd5d --- /dev/null +++ b/src/main/plugins/installation/plugin-removal.ts @@ -0,0 +1,84 @@ +import { realpath, rm } from 'node:fs/promises' +import { isAbsolute, relative, resolve, sep } from 'node:path' +import { isQualifiedPluginKey } from '../../../shared/plugins/manifest/plugin-manifest' +import { removePluginLock } from '../../../shared/plugins/installation/plugin-install-lockfile' +import { readPluginLockfile, writePluginLockfile } from './plugin-install-lockfile-store' +import { serializePluginMutation } from './plugin-mutation-queue' + +export async function removeInstalledPlugin(input: { + pluginsDir: string + pluginsDataDir: string + pluginKey: string +}): Promise { + await serializePluginMutation(input.pluginsDir, async () => { + if (!isQualifiedPluginKey(input.pluginKey)) { + throw new Error(`invalid qualified plugin key: ${input.pluginKey}`) + } + const lock = await readPluginLockfile(input.pluginsDir) + if (lock.plugins[input.pluginKey]?.source.kind === 'bundled') { + throw new Error(`cannot remove protected plugin ${input.pluginKey}`) + } + await removePluginFiles(input) + }) +} + +/** Removes a bundled install this Orca release no longer ships; any other source is left alone. */ +export async function retireBundledPlugin(input: { + pluginsDir: string + pluginsDataDir: string + pluginKey: string +}): Promise { + return serializePluginMutation(input.pluginsDir, async () => { + if (!isQualifiedPluginKey(input.pluginKey)) { + throw new Error(`invalid qualified plugin key: ${input.pluginKey}`) + } + const source = (await readPluginLockfile(input.pluginsDir)).plugins[input.pluginKey]?.source + if (source?.kind !== 'bundled' || source.bundleId !== input.pluginKey) { + return false + } + await removePluginFiles(input) + return true + }) +} + +async function removePluginFiles(input: { + pluginsDir: string + pluginsDataDir: string + pluginKey: string +}): Promise { + await removeResolvedPluginDirectory(input.pluginsDir, input.pluginKey) + await removeResolvedPluginDirectory(input.pluginsDataDir, input.pluginKey) + await writePluginLockfile( + input.pluginsDir, + removePluginLock(await readPluginLockfile(input.pluginsDir), input.pluginKey) + ) +} + +async function removeResolvedPluginDirectory(rootDir: string, pluginKey: string): Promise { + const rootReal = await realpathIfPresent(resolve(rootDir)) + const targetReal = rootReal ? await realpathIfPresent(resolve(rootDir, pluginKey)) : null + if (!rootReal || !targetReal) { + return + } + const fromRoot = relative(rootReal, targetReal) + if ( + fromRoot.length === 0 || + isAbsolute(fromRoot) || + fromRoot === '..' || + fromRoot.startsWith(`..${sep}`) + ) { + throw new Error(`refusing to remove plugin path outside ${rootReal}`) + } + await rm(resolve(rootDir, pluginKey), { recursive: true, force: true }) +} + +async function realpathIfPresent(path: string): Promise { + try { + return await realpath(path) + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return null + } + throw error + } +} diff --git a/src/main/plugins/installation/plugin-rollback-candidate.test.ts b/src/main/plugins/installation/plugin-rollback-candidate.test.ts new file mode 100644 index 00000000000..3b7fae5daaf --- /dev/null +++ b/src/main/plugins/installation/plugin-rollback-candidate.test.ts @@ -0,0 +1,58 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { writePluginCurrentPointer } from './plugin-current-pointer' +import { readPluginRollbackVersion } from './plugin-rollback-candidate' + +const roots: string[] = [] + +async function pluginDirWith(versions: Record, current: string): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-plugin-rollback-')) + roots.push(root) + for (const [hash, version] of Object.entries(versions)) { + await mkdir(join(root, hash), { recursive: true }) + await writeFile( + join(root, hash, 'orca-plugin.json'), + JSON.stringify({ + manifestVersion: 1, + id: 'notes', + publisher: 'community', + name: 'Notes', + version, + engines: { orca: '>=1.0.0' }, + pluginApi: 1 + }) + ) + } + await writePluginCurrentPointer(root, current) + return root +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('readPluginRollbackVersion', () => { + const current = 'a'.repeat(64) + const previous = 'b'.repeat(64) + + it('names the single retained version a rollback restores', async () => { + const dir = await pluginDirWith({ [current]: '2.0.0', [previous]: '1.4.0' }, current) + await expect(readPluginRollbackVersion(dir)).resolves.toBe('1.4.0') + }) + + it('offers nothing when no single retained version exists', async () => { + await expect( + readPluginRollbackVersion(await pluginDirWith({ [current]: '2.0.0' }, current)) + ).resolves.toBeNull() + await expect( + readPluginRollbackVersion( + await pluginDirWith( + { [current]: '2.0.0', [previous]: '1.4.0', ['c'.repeat(64)]: '1.3.0' }, + current + ) + ) + ).resolves.toBeNull() + }) +}) diff --git a/src/main/plugins/installation/plugin-rollback-candidate.ts b/src/main/plugins/installation/plugin-rollback-candidate.ts new file mode 100644 index 00000000000..87815d55d17 --- /dev/null +++ b/src/main/plugins/installation/plugin-rollback-candidate.ts @@ -0,0 +1,53 @@ +import { readdir } from 'node:fs/promises' +import { join } from 'node:path' +import { parsePluginManifest } from '../../../shared/plugins/manifest/plugin-manifest' +import { readPluginCurrentPointer } from './plugin-current-pointer' +import { readPluginManifestText } from '../plugin-manifest-file' +import { PLUGIN_CONTENT_HASH_PATTERN } from '../../../shared/plugins/installation/plugin-install-lockfile' + +export type PluginRollbackCandidate = + | { ok: true; currentContentHash: string; contentHash: string } + | { ok: false; error: string } + +/** The one retained version besides the current one; anything else is not a safe rollback. */ +export async function findPluginRollbackCandidate( + pluginDir: string +): Promise { + const currentContentHash = await readPluginCurrentPointer(pluginDir).catch(() => null) + if (!currentContentHash) { + return { ok: false, error: 'installed plugin has no current version' } + } + const candidates = (await readdir(pluginDir, { withFileTypes: true }).catch(() => [])) + .filter( + (entry) => + entry.isDirectory() && + PLUGIN_CONTENT_HASH_PATTERN.test(entry.name) && + entry.name !== currentContentHash + ) + .map((entry) => entry.name) + const [contentHash] = candidates + if (candidates.length !== 1 || !contentHash) { + return { + ok: false, + error: + candidates.length === 0 ? 'no rollback version is available' : 'rollback state is ambiguous' + } + } + return { ok: true, currentContentHash, contentHash } +} + +/** Version a rollback would restore, for "Roll back to X"; null when none is safe to offer. */ +export async function readPluginRollbackVersion(pluginDir: string): Promise { + const candidate = await findPluginRollbackCandidate(pluginDir) + if (!candidate.ok) { + return null + } + try { + const parsed = parsePluginManifest( + JSON.parse(await readPluginManifestText(join(pluginDir, candidate.contentHash))) + ) + return parsed.ok ? parsed.manifest.version : null + } catch { + return null + } +} diff --git a/src/main/plugins/panels/plugin-custom-editor-entry.test.ts b/src/main/plugins/panels/plugin-custom-editor-entry.test.ts new file mode 100644 index 00000000000..bcfdbf41c22 --- /dev/null +++ b/src/main/plugins/panels/plugin-custom-editor-entry.test.ts @@ -0,0 +1,120 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { pluginManifestSchema } from '../../../shared/plugins/manifest/plugin-manifest' +import { PLUGIN_MANIFEST_V2_SINCE } from '../../../shared/plugins/manifest/plugin-schema-field-docs' +import { PLUGIN_PANEL_CSP } from '../../../shared/plugins/frames/plugin-panel-shell' +import type { ValidDiscoveredPlugin } from '../plugin-discovery' +import { PluginPanelController } from './plugin-panel-controller' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function createEditorPlugin(): Promise { + const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-custom-editor-')) + roots.push(rootDir) + await mkdir(join(rootDir, 'editor')) + await writeFile(join(rootDir, 'editor', 'index.html'), '
') + return { + pluginKey: 'stablyai.excalidraw', + rootDir, + manifest: pluginManifestSchema.parse({ + manifestVersion: 2, + execution: 'sandboxed-ui', + id: 'excalidraw', + publisher: 'stablyai', + name: 'Excalidraw', + version: '1.0.0', + engines: { orca: `>=${PLUGIN_MANIFEST_V2_SINCE}` }, + contributes: { + customEditors: [ + { + id: 'excalidraw', + title: 'Excalidraw', + extensions: ['.excalidraw'], + entry: 'editor/index.html' + } + ] + } + }), + consentFingerprint: 'sha256-consented', + contentHash: null, + isDev: true + } +} + +function controllerFor(plugin: ValidDiscoveredPlugin | null, verify = vi.fn()) { + return new PluginPanelController({ + resolveApprovedPlugin: (pluginKey) => (plugin?.pluginKey === pluginKey ? plugin : null), + contentVerifier: { verify }, + executeHostCall: vi.fn(), + log: () => vi.fn() + }) +} + +describe('PluginPanelController custom editor entries', () => { + it('wraps the editor in the panel CSP shell with the key forwarder, after verifying bytes', async () => { + const plugin = await createEditorPlugin() + const verify = vi.fn().mockResolvedValue(undefined) + const entry = await controllerFor(plugin, verify).openCustomEditor( + 'owner', + plugin.pluginKey, + 'excalidraw' + ) + expect(verify).toHaveBeenCalledWith(plugin) + expect(entry?.html).toContain(PLUGIN_PANEL_CSP) + // The same CSP shell as panels. + expect(PLUGIN_PANEL_CSP).toContain("connect-src 'none'") + expect(PLUGIN_PANEL_CSP).toContain("default-src 'none'") + // The frozen frame API, editor flavour, is injected before plugin code. + expect(entry?.html).toContain('"surface":"editor"') + expect(entry?.html).toContain("Object.defineProperty(window, 'orca'") + expect(entry?.html.endsWith('
')).toBe(true) + // Why: the editor is told nothing about where the file lives or where the plugin is. + expect(entry?.html).not.toContain(plugin.rootDir) + }) + + it('returns null for unknown editors, unapproved plugins and tampered bytes', async () => { + const plugin = await createEditorPlugin() + await expect( + controllerFor(plugin).openCustomEditor('owner', plugin.pluginKey, 'other') + ).resolves.toBeNull() + await expect( + controllerFor(null).openCustomEditor('owner', plugin.pluginKey, 'excalidraw') + ).resolves.toBeNull() + const tampered = vi.fn().mockRejectedValue(new Error('content changed')) + await expect( + controllerFor(plugin, tampered).openCustomEditor('owner', plugin.pluginKey, 'excalidraw') + ).resolves.toBeNull() + }) + + it('gives custom editors an editor session: frame methods only, never a panel', async () => { + const plugin = await createEditorPlugin() + const executeHostCall = vi.fn().mockResolvedValue({ ok: true, value: null }) + const controller = new PluginPanelController({ + resolveApprovedPlugin: (pluginKey) => (plugin.pluginKey === pluginKey ? plugin : null), + contentVerifier: { verify: vi.fn() }, + executeHostCall, + log: () => vi.fn() + }) + const entry = await controller.openCustomEditor('owner', plugin.pluginKey, 'excalidraw') + expect(entry?.sessionToken).toEqual(expect.any(String)) + await controller.execute('owner', { + sessionToken: entry?.sessionToken, + action: 'notifications.show', + params: { title: 'hi' } + }) + expect(executeHostCall).toHaveBeenCalledWith( + plugin.pluginKey, + 'notifications.show', + { title: 'hi' }, + 'editor' + ) + // The editor id names no panel, so it cannot open one. + await expect(controller.open('owner', plugin.pluginKey, 'excalidraw')).resolves.toBeNull() + }) +}) diff --git a/src/main/plugins/plugin-panel-controller.test.ts b/src/main/plugins/panels/plugin-panel-controller.test.ts similarity index 91% rename from src/main/plugins/plugin-panel-controller.test.ts rename to src/main/plugins/panels/plugin-panel-controller.test.ts index fa60c785292..3fa9682f7f9 100644 --- a/src/main/plugins/plugin-panel-controller.test.ts +++ b/src/main/plugins/panels/plugin-panel-controller.test.ts @@ -2,9 +2,9 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' -import { createPluginPanelCallAdmission } from '../../shared/plugins/plugin-panel-call-admission' -import type { ValidDiscoveredPlugin } from './plugin-discovery' +import { pluginManifestSchema } from '../../../shared/plugins/manifest/plugin-manifest' +import { createPluginPanelCallAdmission } from '../../../shared/plugins/frames/plugin-panel-call-admission' +import type { ValidDiscoveredPlugin } from '../plugin-discovery' import { PluginPanelController } from './plugin-panel-controller' const roots: string[] = [] @@ -18,12 +18,12 @@ async function createPlugin(): Promise { roots.push(rootDir) await writeFile(join(rootDir, 'panel.html'), '

Panel

') return { - pluginKey: 'orca-samples.demo', + pluginKey: 'samples.demo', rootDir, manifest: pluginManifestSchema.parse({ manifestVersion: 1, id: 'demo', - publisher: 'orca-samples', + publisher: 'samples', name: 'Demo', version: '1.0.0', engines: { orca: '>=1.0.0' }, @@ -57,7 +57,7 @@ describe('PluginPanelController identity binding', () => { await expect( controller.execute('runtime:one', { sessionToken: entry!.sessionToken, - pluginId: 'orca-samples.other', + pluginId: 'samples.other', action: 'notifications.show', params: { title: 'Hello' } }) @@ -71,9 +71,12 @@ describe('PluginPanelController identity binding', () => { params: { title: 'Hello' } }) ).resolves.toMatchObject({ ok: true }) - expect(executeHostCall).toHaveBeenCalledWith(plugin.pluginKey, 'notifications.show', { - title: 'Hello' - }) + expect(executeHostCall).toHaveBeenCalledWith( + plugin.pluginKey, + 'notifications.show', + { title: 'Hello' }, + 'panel' + ) await expect( controller.execute('runtime:other', { sessionToken: entry!.sessionToken, @@ -113,7 +116,7 @@ describe('PluginPanelController identity binding', () => { }) ).resolves.toEqual({ ok: false, - code: 'invalid_request', + code: 'too_large', error: 'panel message exceeds the size limit' }) await expect( diff --git a/src/main/plugins/plugin-panel-controller.ts b/src/main/plugins/panels/plugin-panel-controller.ts similarity index 57% rename from src/main/plugins/plugin-panel-controller.ts rename to src/main/plugins/panels/plugin-panel-controller.ts index 739b10cff5e..04cd71f59c6 100644 --- a/src/main/plugins/plugin-panel-controller.ts +++ b/src/main/plugins/panels/plugin-panel-controller.ts @@ -1,20 +1,26 @@ +import { + panelActionCallSchema, + type PluginFrameEntry +} from '../../../shared/plugins/frames/plugin-frame-bridge' import type { - PluginPanelActionOutcome, - PluginPanelEntry -} from '../../shared/plugins/plugin-panel-bridge' -import { panelActionCallSchema } from '../../shared/plugins/plugin-panel-bridge' + PluginHostCallOutcome, + PluginSurface +} from '../../../shared/plugins/frames/plugin-protocol' import { admitPluginPanelCall, createPluginPanelCallAdmission, type PluginPanelCallAdmission -} from '../../shared/plugins/plugin-panel-call-admission' -import { buildPluginPanelShellHtml } from '../../shared/plugins/plugin-panel-shell' -import type { ValidDiscoveredPlugin } from './plugin-discovery' -import type { PluginContentVerifier } from './plugin-content-integrity' +} from '../../../shared/plugins/frames/plugin-panel-call-admission' +import { + buildPluginPanelShellHtml, + type PluginFrameSurface +} from '../../../shared/plugins/frames/plugin-panel-shell' +import type { ValidDiscoveredPlugin } from '../plugin-discovery' +import type { PluginContentVerifier } from '../plugin-content-integrity' import { PLUGIN_PANEL_ENTRY_MAX_BYTES, readContainedPluginArtifactText -} from './plugin-artifact-validation' +} from '../plugin-artifact-validation' import { PluginPanelSessions, type PluginPanelSessionBinding } from './plugin-panel-sessions' type PluginPanelControllerOptions = { @@ -23,8 +29,9 @@ type PluginPanelControllerOptions = { executeHostCall: ( pluginKey: string, method: string, - params: unknown - ) => Promise + params: unknown, + surface: PluginSurface + ) => Promise log: (pluginKey: string) => (line: string) => void panelAdmission?: PluginPanelCallAdmission } @@ -44,29 +51,28 @@ export class PluginPanelController { } async readEntry(pluginKey: string, panelId: string): Promise<{ html: string } | null> { - return (await this.load(pluginKey, panelId))?.entry ?? null + return (await this.load(pluginKey, 'panel', panelId))?.entry ?? null } - async open( + open( ownerKey: string, pluginKey: string, panelId: string, isCurrent?: () => boolean - ): Promise { - if (isCurrent?.() === false) { - return null - } - const loaded = await this.load(pluginKey, panelId) - if (!loaded || isCurrent?.() === false) { - return null - } - return { - ...loaded.entry, - sessionToken: this.sessions.issue(ownerKey, loaded.binding) - } + ): Promise { + return this.openFrame(ownerKey, pluginKey, 'panel', panelId, isCurrent) } - async execute(ownerKey: string, call: unknown): Promise { + openCustomEditor( + ownerKey: string, + pluginKey: string, + editorId: string, + isCurrent?: () => boolean + ): Promise { + return this.openFrame(ownerKey, pluginKey, 'editor', editorId, isCurrent) + } + + async execute(ownerKey: string, call: unknown): Promise { const sessionToken = this.extractSessionToken(call) if (!sessionToken) { return { ok: false, code: 'invalid_request', error: 'invalid panel session' } @@ -84,18 +90,20 @@ export class PluginPanelController { return { ok: false, code: 'invalid_request', error: 'malformed panel action call' } } const plugin = this.options.resolveApprovedPlugin(binding.pluginKey) - const panelExists = plugin?.manifest.contributes.panels.some( - (panel) => panel.id === binding.panelId - ) if ( !plugin || plugin.rootDir !== binding.rootDir || JSON.stringify(plugin.manifest) !== binding.manifestRevision || - !panelExists + !findFrameContribution(plugin, binding.surface, binding.contributionId) ) { return { ok: false, code: 'unavailable', error: 'panel session is no longer available' } } - return this.options.executeHostCall(binding.pluginKey, parsed.data.action, parsed.data.params) + return this.options.executeHostCall( + binding.pluginKey, + parsed.data.action, + parsed.data.params, + binding.surface + ) } revokeOwner(ownerKey: string): void { @@ -135,40 +143,88 @@ export class PluginPanelController { } } - private async load(pluginKey: string, panelId: string): Promise { - const plugin = this.options.resolveApprovedPlugin(pluginKey) - const panel = plugin?.manifest.contributes.panels.find((entry) => entry.id === panelId) - if (!plugin || !panel) { + private async openFrame( + ownerKey: string, + pluginKey: string, + surface: PluginPanelSessionBinding['surface'], + contributionId: string, + isCurrent?: () => boolean + ): Promise { + if (isCurrent?.() === false) { return null } + const loaded = await this.load(pluginKey, surface, contributionId) + if (!loaded || isCurrent?.() === false) { + return null + } + return { ...loaded.entry, sessionToken: this.sessions.issue(ownerKey, loaded.binding) } + } + + private async load( + pluginKey: string, + surface: PluginPanelSessionBinding['surface'], + contributionId: string + ): Promise { + const plugin = this.options.resolveApprovedPlugin(pluginKey) + const contribution = plugin ? findFrameContribution(plugin, surface, contributionId) : null + if (!plugin || !contribution) { + return null + } + const html = await this.readShell(plugin, contribution.entry, surface) + if (html === null) { + return null + } + return { + entry: { html }, + binding: { + pluginKey, + surface, + contributionId, + rootDir: plugin.rootDir, + manifestRevision: JSON.stringify(plugin.manifest) + } + } + } + + private async readShell( + plugin: ValidDiscoveredPlugin, + entryPath: string, + surface: PluginFrameSurface + ): Promise { + const { pluginKey } = plugin const log = this.options.log(pluginKey) try { await this.options.contentVerifier.verify(plugin) const html = buildPluginPanelShellHtml( await readContainedPluginArtifactText( plugin.rootDir, - panel.entry, + entryPath, PLUGIN_PANEL_ENTRY_MAX_BYTES - ) + ), + surface ) const current = this.options.resolveApprovedPlugin(pluginKey) if (current !== plugin || current.rootDir !== plugin.rootDir) { return null } - return { - entry: { html }, - binding: { - pluginKey, - panelId, - rootDir: plugin.rootDir, - manifestRevision: JSON.stringify(plugin.manifest) - } - } + return html } catch (error) { log( - `panel entry ${panel.entry} rejected: ${error instanceof Error ? error.message : String(error)}` + `${surface} entry ${entryPath} rejected: ${error instanceof Error ? error.message : String(error)}` ) return null } } } + +function findFrameContribution( + plugin: ValidDiscoveredPlugin, + surface: PluginPanelSessionBinding['surface'], + id: string +): { entry: string } | null { + const list = + surface === 'panel' + ? plugin.manifest.contributes.panels + : plugin.manifest.contributes.customEditors + return list.find((entry) => entry.id === id) ?? null +} diff --git a/src/main/plugins/plugin-panel-owner-lifecycle.test.ts b/src/main/plugins/panels/plugin-panel-owner-lifecycle.test.ts similarity index 100% rename from src/main/plugins/plugin-panel-owner-lifecycle.test.ts rename to src/main/plugins/panels/plugin-panel-owner-lifecycle.test.ts diff --git a/src/main/plugins/plugin-panel-owner-lifecycle.ts b/src/main/plugins/panels/plugin-panel-owner-lifecycle.ts similarity index 100% rename from src/main/plugins/plugin-panel-owner-lifecycle.ts rename to src/main/plugins/panels/plugin-panel-owner-lifecycle.ts diff --git a/src/main/plugins/plugin-panel-sessions.test.ts b/src/main/plugins/panels/plugin-panel-sessions.test.ts similarity index 80% rename from src/main/plugins/plugin-panel-sessions.test.ts rename to src/main/plugins/panels/plugin-panel-sessions.test.ts index df38e34c1da..d8fdb215e13 100644 --- a/src/main/plugins/plugin-panel-sessions.test.ts +++ b/src/main/plugins/panels/plugin-panel-sessions.test.ts @@ -2,9 +2,10 @@ import { describe, expect, it } from 'vitest' import { PluginPanelSessions } from './plugin-panel-sessions' const binding = { - pluginKey: 'orca-samples.demo', - panelId: 'dashboard', - rootDir: '/plugins/orca-samples.demo/hash-one', + pluginKey: 'samples.demo', + surface: 'panel' as const, + contributionId: 'dashboard', + rootDir: '/plugins/samples.demo/hash-one', manifestRevision: 'manifest-v1' } @@ -21,12 +22,14 @@ describe('PluginPanelSessions', () => { expect(sessions.issue('renderer:1', { ...binding, manifestRevision: 'manifest-v2' })).not.toBe( token ) + // An editor with the same id as a panel is a different contribution. + expect(sessions.issue('renderer:1', { ...binding, surface: 'editor' })).not.toBe(token) }) it('revokes every session owned by a disconnected transport', () => { const sessions = new PluginPanelSessions() const first = sessions.issue('connection:one', binding) - const second = sessions.issue('connection:one', { ...binding, panelId: 'secondary' }) + const second = sessions.issue('connection:one', { ...binding, contributionId: 'secondary' }) const other = sessions.issue('connection:two', binding) sessions.revokeOwner('connection:one') diff --git a/src/main/plugins/plugin-panel-sessions.ts b/src/main/plugins/panels/plugin-panel-sessions.ts similarity index 88% rename from src/main/plugins/plugin-panel-sessions.ts rename to src/main/plugins/panels/plugin-panel-sessions.ts index 34364544af9..061ad7f8011 100644 --- a/src/main/plugins/plugin-panel-sessions.ts +++ b/src/main/plugins/panels/plugin-panel-sessions.ts @@ -2,7 +2,9 @@ import { randomBytes } from 'node:crypto' export type PluginPanelSessionBinding = { pluginKey: string - panelId: string + /** Which contribution list `contributionId` names; it also picks the callable methods. */ + surface: 'panel' | 'editor' + contributionId: string rootDir: string manifestRevision: string } @@ -17,7 +19,8 @@ function bindingKey(ownerKey: string, binding: PluginPanelSessionBinding): strin return JSON.stringify([ ownerKey, binding.pluginKey, - binding.panelId, + binding.surface, + binding.contributionId, binding.rootDir, binding.manifestRevision ]) @@ -57,7 +60,8 @@ export class PluginPanelSessions { } return { pluginKey: session.pluginKey, - panelId: session.panelId, + surface: session.surface, + contributionId: session.contributionId, rootDir: session.rootDir, manifestRevision: session.manifestRevision } diff --git a/src/main/plugins/panels/plugin-panel-webrtc.electron.test.ts b/src/main/plugins/panels/plugin-panel-webrtc.electron.test.ts new file mode 100644 index 00000000000..498de620a97 --- /dev/null +++ b/src/main/plugins/panels/plugin-panel-webrtc.electron.test.ts @@ -0,0 +1,102 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createServer, type Server, type Socket } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { runBrowserRouteEgressElectron } from '../../browser/browser-route-egress-electron-launch' +import { buildPluginPanelShellHtml } from '../../../shared/plugins/frames/plugin-panel-shell' +import { PLUGIN_PANEL_FRAME_NAME_PREFIX } from '../../../shared/plugins/frames/plugin-frame-bridge' + +const roots: string[] = [] +const servers: Server[] = [] + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => new Promise((done) => server.close(done)))) + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +/** A TURN-over-TCP stand-in: counts the connections a peer connection opens to it. */ +async function turnListener(): Promise<{ port: number; connections: () => number }> { + let connections = 0 + const server = createServer((socket: Socket) => { + connections += 1 + socket.destroy() + }) + servers.push(server) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('turn listener has no port') + } + return { port: address.port, connections: () => connections } +} + +const escapeAttribute = (html: string): string => + html.replace(/&/g, '&').replace(/"/g, '"') + +const peerConnectionScript = (port: number): string => + '' + +const PROBE_MAIN = String.raw` +const { app, BrowserWindow } = require('electron') +const { readFileSync, writeFileSync } = require('node:fs') +if (process.platform === 'linux') app.disableHardwareAcceleration() +const config = JSON.parse(readFileSync(process.argv[2], 'utf8')) +app.on('window-all-closed', () => {}) +app.whenReady().then(async () => { + const timeout = setTimeout(() => app.exit(2), 25000) + const window = new BrowserWindow({ show: false, webPreferences: { sandbox: true, contextIsolation: true } }) + // Same fence as the main window that hosts plugin panels. + window.webContents.setWebRTCIPHandlingPolicy('disable_non_proxied_udp') + await window.loadFile(config.pagePath) + await new Promise((resolve) => setTimeout(resolve, 4000)) + window.destroy() + writeFileSync(config.resultPath, JSON.stringify({ ok: true })) + clearTimeout(timeout) + app.quit() +}).catch((error) => { + writeFileSync(config.resultPath, JSON.stringify({ error: String(error && error.stack || error) })) + app.exit(1) +}) +` + +// Why a tripwire, not a guard: it pins the residual documented in plugin-panel-shell.ts. The +// shell fences the panel's own window, but a child srcdoc frame is a new realm that commits +// without will-frame-navigate, and Electron 43 has no per-frame WebRTC switch. When a fix +// lands, flip the nested-frame assertion to expect no connection. +describe('plugin panel WebRTC under Electron', () => { + // Why the 60s timeout: a real Electron window negotiates WebRTC against two local TURN relays. + it('blocks the panel window but not a nested srcdoc frame', async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-panel-webrtc-')) + roots.push(root) + const panelTurn = await turnListener() + const nestedTurn = await turnListener() + const panel = buildPluginPanelShellHtml( + `` + + `${peerConnectionScript(panelTurn.port)}` + ) + const pagePath = join(root, 'page.html') + writeFileSync( + pagePath, + `` + ) + const mainPath = join(root, 'main.cjs') + writeFileSync(mainPath, PROBE_MAIN) + writeFileSync( + join(root, 'config.json'), + JSON.stringify({ pagePath, resultPath: join(root, 'result.json') }) + ) + + await runBrowserRouteEgressElectron(root, mainPath) + + expect(panelTurn.connections()).toBe(0) + // Residual today: the nested frame opens a TURN-over-TCP connection out of the panel. + expect(nestedTurn.connections()).toBeGreaterThan(0) + }, 60_000) +}) diff --git a/src/main/plugins/plugin-activation-policy.ts b/src/main/plugins/plugin-activation-policy.ts index 9ef9bd59b3b..ea83f28889b 100644 --- a/src/main/plugins/plugin-activation-policy.ts +++ b/src/main/plugins/plugin-activation-policy.ts @@ -1,8 +1,10 @@ import { getPluginActivationState, + type PluginActivationState, type PluginConsentLists -} from '../../shared/plugins/plugin-consent-state' +} from '../../shared/plugins/approval/plugin-consent-state' import type { ValidDiscoveredPlugin } from './plugin-discovery' +import type { PluginServiceOptions } from './plugin-service-options' export function snapshotPluginConsentLists(source: { getPluginConsents: () => Record @@ -24,3 +26,34 @@ export function isPluginApproved( getPluginActivationState(plugin.pluginKey, plugin.consentFingerprint, lists) === 'approved' ) } + +/** The settings every plugin policy decision reads. */ +export type PluginPolicySource = Pick< + PluginServiceOptions, + 'isPluginSystemEnabled' | 'getPluginConsents' | 'getDisabledPlugins' | 'getPluginKillListEntry' +> + +/** Live consent state. The feature flag is an authority boundary, not only a discovery hint: + * callers fail closed immediately even before async reconciliation ends. */ +export function livePluginActivationState( + source: PluginPolicySource, + plugin: ValidDiscoveredPlugin +): PluginActivationState { + if (!source.isPluginSystemEnabled()) { + return 'disabled' + } + return getPluginActivationState( + plugin.pluginKey, + plugin.consentFingerprint, + snapshotPluginConsentLists(source) + ) +} + +/** Why the plugin may not run despite consent: Orca's safety list. Full access needs only consent. */ +export function pluginPolicyBlockError( + source: PluginPolicySource, + pluginKey: string +): string | null { + const blocked = source.getPluginKillListEntry?.(pluginKey) + return blocked ? `Blocked by Orca's plugin safety list: ${blocked.reason}` : null +} diff --git a/src/main/plugins/plugin-agent-status-event.test.ts b/src/main/plugins/plugin-agent-status-event.test.ts index 89f7930d65f..1cf816c2d2a 100644 --- a/src/main/plugins/plugin-agent-status-event.test.ts +++ b/src/main/plugins/plugin-agent-status-event.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { agentStatusChangedPayloadSchema } from '../../shared/plugins/plugin-events' +import { agentStatusChangedPayloadSchema } from '../../shared/plugins/events/plugin-events' import { projectPluginAgentStatusChangedPayload } from './plugin-agent-status-event' const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' @@ -29,20 +29,20 @@ describe('projectPluginAgentStatusChangedPayload', () => { }) ) expect(projected).toEqual({ - worktreeId: 'wt-1', + workspaceId: 'wt-1', paneKey: PANE, state: 'working', receivedAt: 1_700_000_000_000, mainAgent: { state: 'done', outcome: 'cancellation', stateStartedAt: 1_700_000_000_500 } }) - // The bus validates before delivery; a field the schema strips never reaches a plugin. + // Delivery validates first; a field the schema strips never reaches a plugin. expect(agentStatusChangedPayloadSchema.parse(projected)).toEqual(projected) }) it("leaves `mainAgent` absent for a row that carries none, so an old host's rows look as they did", () => { const projected = projectPluginAgentStatusChangedPayload(row()) expect(projected).toEqual({ - worktreeId: 'wt-1', + workspaceId: 'wt-1', paneKey: PANE, state: 'working', receivedAt: 1_700_000_000_000 @@ -78,7 +78,7 @@ describe('projectPluginAgentStatusChangedPayload', () => { } }) ) - expect(projected?.worktreeId).toBeNull() + expect(projected?.workspaceId).toBeNull() expect(projected?.mainAgent).toEqual({ state: 'working', stateStartedAt: 7 }) }) }) diff --git a/src/main/plugins/plugin-agent-status-event.ts b/src/main/plugins/plugin-agent-status-event.ts index 8f0d9316c8f..26280c8b958 100644 --- a/src/main/plugins/plugin-agent-status-event.ts +++ b/src/main/plugins/plugin-agent-status-event.ts @@ -1,5 +1,5 @@ import type { EnrichedAgentHookEventPayload } from '../agent-hooks/server/server-types' -import type { PluginAgentStatusChangedPayload } from '../../shared/plugins/plugin-events' +import type { PluginAgentStatusChangedPayload } from '../../shared/plugins/events/plugin-events' /** Exclude restored rows from live automation triggers; optional mainAgent preserves old-host compatibility. */ export function projectPluginAgentStatusChangedPayload( @@ -13,7 +13,7 @@ export function projectPluginAgentStatusChangedPayload( } const mainAgent = enriched.payload.mainAgent return { - worktreeId: enriched.worktreeId ?? null, + workspaceId: enriched.worktreeId ?? null, paneKey: enriched.paneKey, state: enriched.payload.state, receivedAt: enriched.receivedAt, diff --git a/src/main/plugins/plugin-artifact-validation.ts b/src/main/plugins/plugin-artifact-validation.ts index 4a8d71011bb..5ee986709aa 100644 --- a/src/main/plugins/plugin-artifact-validation.ts +++ b/src/main/plugins/plugin-artifact-validation.ts @@ -1,8 +1,15 @@ import { createReadStream } from 'node:fs' import { realpath, stat } from 'node:fs/promises' import { isAbsolute, relative, resolve, sep } from 'node:path' -import type { PluginManifest } from '../../shared/plugins/plugin-manifest' -import { parsePluginVmRecipeArtifact } from '../../shared/plugins/plugin-vm-recipe-artifact' +import type { PluginManifest } from '../../shared/plugins/manifest/plugin-manifest' +import { + PLUGIN_SYNTAX_CONFIGURATION_MAX_BYTES, + PLUGIN_SYNTAX_GRAMMAR_MAX_BYTES +} from '../../shared/plugins/artifacts/plugin-syntax-artifact' +import { parsePluginVmRecipeArtifact } from '../../shared/plugins/artifacts/plugin-vm-recipe-artifact' +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' export type PluginArtifactValidationResult = { ok: true } | { ok: false; error: string } @@ -11,9 +18,10 @@ export const PLUGIN_WORKER_ENTRY_MAX_BYTES = 50 * 1024 * 1024 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 -const PLUGIN_AGENT_PROFILE_MAX_BYTES = 1024 * 1024 // Why 16 KiB: a theme is a few dozen tokens or palette slots. export const PLUGIN_THEME_MAX_BYTES = 16 * 1024 +// Why larger: an icon theme maps up to 512 file names and extensions. +export const PLUGIN_ICON_THEME_MAX_BYTES = 64 * 1024 type DeclaredArtifact = | { label: string; path: string; kind: 'file'; maxBytes: number } @@ -27,25 +35,69 @@ function declaredArtifactPaths(manifest: PluginManifest): DeclaredArtifact[] { const c = manifest.contributes return [ ...declaredFile('icon', manifest.icon, PLUGIN_ICON_MAX_BYTES), + ...(manifest.manifestVersion === 2 + ? [ + ...declaredFile('cover', manifest.cover?.path, PLUGIN_IMAGE_RULES.cover.maxBytes), + ...(manifest.screenshots ?? []).flatMap((shot) => + declaredFile('screenshot', shot.path, PLUGIN_IMAGE_RULES.screenshot.maxBytes) + ) + ] + : []), ...declaredFile('worker entry', manifest.main, PLUGIN_WORKER_ENTRY_MAX_BYTES), ...c.panels.flatMap((panel) => declaredFile(`panel "${panel.id}" entry`, panel.entry, PLUGIN_PANEL_ENTRY_MAX_BYTES) ), + ...c.customEditors.flatMap((editor) => [ + ...declaredFile( + `custom editor "${editor.id}" entry`, + editor.entry, + PLUGIN_PANEL_ENTRY_MAX_BYTES + ), + ...declaredFile( + `custom editor "${editor.id}" icon`, + editor.icon, + PLUGIN_IMAGE_RULES.icon.maxBytes + ) + ]), + ...c.newFiles.flatMap((newFile) => [ + ...declaredFile( + `new file "${newFile.id}" template`, + newFile.template, + PLUGIN_NEW_FILE_TEMPLATE_MAX_BYTES + ), + ...declaredFile( + `new file "${newFile.id}" icon`, + newFile.icon, + PLUGIN_IMAGE_RULES.icon.maxBytes + ) + ]), ...c.languagePacks.flatMap((pack) => declaredFile(`language pack "${pack.locale}"`, pack.path, PLUGIN_LANGUAGE_PACK_MAX_BYTES) ), ...c.vmRecipes.flatMap((recipe) => declaredFile('VM recipe', recipe.path, PLUGIN_VM_RECIPE_MAX_BYTES) ), - ...c.agents.flatMap((agent) => - declaredFile('agent profile', agent.path, PLUGIN_AGENT_PROFILE_MAX_BYTES) - ), ...c.themes.flatMap((theme) => declaredFile(`theme "${theme.id}"`, theme.path, PLUGIN_THEME_MAX_BYTES) ), ...c.terminalThemes.flatMap((theme) => declaredFile(`terminal theme "${theme.id}"`, theme.path, PLUGIN_THEME_MAX_BYTES) - ) + ), + ...c.iconThemes.flatMap((theme) => + declaredFile(`icon theme "${theme.id}"`, theme.path, PLUGIN_ICON_THEME_MAX_BYTES) + ), + ...c.syntaxes.flatMap((syntax) => [ + ...declaredFile( + `syntax "${syntax.id}" grammar`, + syntax.path, + PLUGIN_SYNTAX_GRAMMAR_MAX_BYTES + ), + ...declaredFile( + `syntax "${syntax.id}" configuration`, + syntax.configuration, + PLUGIN_SYNTAX_CONFIGURATION_MAX_BYTES + ) + ]) ] } @@ -63,6 +115,14 @@ export async function readContainedPluginArtifactText( 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 @@ -74,7 +134,7 @@ export async function readContainedPluginArtifactText( } chunks.push(bytes) } - return Buffer.concat(chunks, totalBytes).toString('utf8') + return Buffer.concat(chunks, totalBytes) } async function resolvePathFromRealRoot( @@ -150,6 +210,14 @@ export async function validatePluginInstallContent( rootDir: string, manifest: PluginManifest ): Promise { + const images = await validatePluginImageArtifacts( + rootDir, + manifest, + readContainedPluginArtifactBytes + ) + if (!images.ok) { + return images + } const vmRecipeIds = new Set() for (const contribution of manifest.contributes.vmRecipes) { try { @@ -171,5 +239,35 @@ export async function validatePluginInstallContent( } } } + for (const newFile of manifest.contributes.newFiles) { + if (newFile.template === undefined) { + continue + } + try { + await readPluginNewFileTemplate(rootDir, newFile.template) + } catch (error) { + return { + ok: false, + error: `new file "${newFile.id}" template ${newFile.template}: ${error instanceof Error ? error.message : String(error)}` + } + } + } return { ok: true } } + +/** A new-file template's text; refuses bytes that are not UTF-8 so a file never starts garbled. */ +export async function readPluginNewFileTemplate( + rootDir: string, + relativePath: string +): Promise { + const bytes = await readContainedPluginArtifactBytes( + rootDir, + relativePath, + PLUGIN_NEW_FILE_TEMPLATE_MAX_BYTES + ) + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes) + } catch { + throw new Error('is not UTF-8 text') + } +} diff --git a/src/main/plugins/plugin-audit-log.test.ts b/src/main/plugins/plugin-audit-log.test.ts index ab70de89da8..ce7311048f2 100644 --- a/src/main/plugins/plugin-audit-log.test.ts +++ b/src/main/plugins/plugin-audit-log.test.ts @@ -19,7 +19,7 @@ describe('PluginAuditLog retention', () => { for (let index = 0; index < 8; index += 1) { await audit.record({ ts: index, - actor: 'plugin:orca-samples.demo', + actor: 'plugin:samples.demo', method: 'storage.set', summary: `key=${index}`, outcome: 'ok' diff --git a/src/main/plugins/plugin-bundled-bootstrap-coordinator.test.ts b/src/main/plugins/plugin-bundled-bootstrap-coordinator.test.ts index ab6543bc6b7..556288a1413 100644 --- a/src/main/plugins/plugin-bundled-bootstrap-coordinator.test.ts +++ b/src/main/plugins/plugin-bundled-bootstrap-coordinator.test.ts @@ -5,11 +5,12 @@ import { PluginBundledBootstrapCoordinator } from './plugin-bundled-bootstrap-co const unchanged: PluginBundledBootstrapResult = { installed: [], unchanged: ['stablyai.orca-theme'], + retired: [], errors: [] } describe('PluginBundledBootstrapCoordinator', () => { - it('skips disabled requests and refreshes discovery only after publication', async () => { + it('skips disabled requests and refreshes discovery only after publication or retirement', async () => { let enabled = false const bootstrap = vi .fn() @@ -17,6 +18,13 @@ describe('PluginBundledBootstrapCoordinator', () => { .mockResolvedValueOnce({ installed: ['stablyai.orca-theme'], unchanged: [], + retired: [], + errors: [] + }) + .mockResolvedValueOnce({ + installed: [], + unchanged: [], + retired: ['stablyai.orca-theme'], errors: [] }) const refreshPlugins = vi.fn().mockResolvedValue(undefined) @@ -35,6 +43,8 @@ describe('PluginBundledBootstrapCoordinator', () => { expect(refreshPlugins).not.toHaveBeenCalled() await coordinator.request() expect(refreshPlugins).toHaveBeenCalledOnce() + await coordinator.request() + expect(refreshPlugins).toHaveBeenCalledTimes(2) }) it('serializes overlapping startup and feature-toggle requests', async () => { diff --git a/src/main/plugins/plugin-bundled-bootstrap-coordinator.ts b/src/main/plugins/plugin-bundled-bootstrap-coordinator.ts index 3caa98dd8c9..29603aa33c2 100644 --- a/src/main/plugins/plugin-bundled-bootstrap-coordinator.ts +++ b/src/main/plugins/plugin-bundled-bootstrap-coordinator.ts @@ -39,7 +39,7 @@ export class PluginBundledBootstrapCoordinator { ? { blockedPluginReason: this.options.blockedPluginReason } : {}) }) - if (result.installed.length > 0) { + if (result.installed.length > 0 || result.retired.length > 0) { await this.options.refreshPlugins() } return result diff --git a/src/main/plugins/plugin-bundled-bootstrap.test.ts b/src/main/plugins/plugin-bundled-bootstrap.test.ts index d5e2e64bd8f..5e195c1c7f3 100644 --- a/src/main/plugins/plugin-bundled-bootstrap.test.ts +++ b/src/main/plugins/plugin-bundled-bootstrap.test.ts @@ -1,9 +1,9 @@ -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { access, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { hashPluginTree } from './plugin-content-hash' -import { readPluginLockfile } from './plugin-install' +import { installPluginFromLocalPath, readPluginLockfile } from './installation/plugin-install' import { bootstrapBundledPlugins, resolveBundledPluginRoot } from './plugin-bundled-bootstrap' const roots: string[] = [] @@ -61,10 +61,20 @@ describe('bundled plugin bootstrap', () => { await expect( bootstrapBundledPlugins({ root, userDataPath, hostVersion: '1.4.0' }) - ).resolves.toEqual({ installed: ['stablyai.orca-skills'], unchanged: [], errors: [] }) + ).resolves.toEqual({ + installed: ['stablyai.orca-skills'], + unchanged: [], + retired: [], + errors: [] + }) await expect( bootstrapBundledPlugins({ root, userDataPath, hostVersion: '1.4.0' }) - ).resolves.toEqual({ installed: [], unchanged: ['stablyai.orca-skills'], errors: [] }) + ).resolves.toEqual({ + installed: [], + unchanged: ['stablyai.orca-skills'], + retired: [], + errors: [] + }) }) it('publishes an updated immutable bundle only when the indexed hash matches', async () => { @@ -78,7 +88,12 @@ describe('bundled plugin bootstrap', () => { const updated = await bootstrapBundledPlugins({ root, userDataPath, hostVersion: '1.4.0' }) - expect(updated).toEqual({ installed: ['stablyai.orca-skills'], unchanged: [], errors: [] }) + expect(updated).toEqual({ + installed: ['stablyai.orca-skills'], + unchanged: [], + retired: [], + errors: [] + }) const lock = await readPluginLockfile(join(userDataPath, 'plugins')) expect(lock.plugins['stablyai.orca-skills']?.contentHash).toBe(second.hash) }) @@ -94,12 +109,81 @@ describe('bundled plugin bootstrap', () => { await expect( bootstrapBundledPlugins({ root, userDataPath, hostVersion: '1.4.0' }) - ).resolves.toEqual({ installed: ['stablyai.orca-skills'], unchanged: [], errors: [] }) + ).resolves.toEqual({ + installed: ['stablyai.orca-skills'], + unchanged: [], + retired: [], + errors: [] + }) await rm(versionDir, { recursive: true, force: true }) await expect( bootstrapBundledPlugins({ root, userDataPath, hostVersion: '1.4.0' }) - ).resolves.toEqual({ installed: ['stablyai.orca-skills'], unchanged: [], errors: [] }) + ).resolves.toEqual({ + installed: ['stablyai.orca-skills'], + unchanged: [], + retired: [], + errors: [] + }) + }) + + it('uninstalls a previously bundled plugin once a release stops shipping it', async () => { + const root = await tempRoot('orca-bundled-resources-') + const userDataPath = await tempRoot('orca-bundled-user-data-') + const bundle = await writeBundle(root) + await writeIndex(root, bundle.path, bundle.hash) + await bootstrapBundledPlugins({ root, userDataPath, hostVersion: '1.4.0' }) + const dataDir = join(userDataPath, 'plugins-data', 'stablyai.orca-skills') + await mkdir(dataDir, { recursive: true }) + await writeFile(join(root, 'bundled-plugins.json'), JSON.stringify({ version: 1, plugins: [] })) + + await expect( + bootstrapBundledPlugins({ root, userDataPath, hostVersion: '1.4.0' }) + ).resolves.toEqual({ + installed: [], + unchanged: [], + retired: ['stablyai.orca-skills'], + errors: [] + }) + expect((await readPluginLockfile(join(userDataPath, 'plugins'))).plugins).toEqual({}) + await expect(access(join(userDataPath, 'plugins', 'stablyai.orca-skills'))).rejects.toThrow() + await expect(access(dataDir)).rejects.toThrow() + await expect( + bootstrapBundledPlugins({ root, userDataPath, hostVersion: '1.4.0' }) + ).resolves.toEqual({ installed: [], unchanged: [], retired: [], errors: [] }) + }) + + it('leaves plugins the user installed alone', async () => { + const root = await tempRoot('orca-bundled-resources-') + const userDataPath = await tempRoot('orca-bundled-user-data-') + const sourcePath = join(root, 'local-plugin') + await mkdir(sourcePath, { recursive: true }) + await writeFile( + join(sourcePath, 'orca-plugin.json'), + JSON.stringify({ + manifestVersion: 1, + id: 'local-plugin', + publisher: 'someone', + name: 'Local Plugin', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + capabilities: [] + }) + ) + await writeFile(join(root, 'bundled-plugins.json'), JSON.stringify({ version: 1, plugins: [] })) + const installed = await installPluginFromLocalPath({ + pluginsDir: join(userDataPath, 'plugins'), + sourcePath, + hostVersion: '1.4.0' + }) + expect(installed).toMatchObject({ ok: true }) + + await expect( + bootstrapBundledPlugins({ root, userDataPath, hostVersion: '1.4.0' }) + ).resolves.toEqual({ installed: [], unchanged: [], retired: [], errors: [] }) + const lock = await readPluginLockfile(join(userDataPath, 'plugins')) + expect(lock.plugins['someone.local-plugin']?.source.kind).toBe('local-path') }) it('refuses mismatched release hashes before publication', async () => { diff --git a/src/main/plugins/plugin-bundled-bootstrap.ts b/src/main/plugins/plugin-bundled-bootstrap.ts index a7cfbbc1736..67c178fd199 100644 --- a/src/main/plugins/plugin-bundled-bootstrap.ts +++ b/src/main/plugins/plugin-bundled-bootstrap.ts @@ -1,13 +1,17 @@ import { readFile, realpath, stat } from 'node:fs/promises' import { isAbsolute, join, relative, sep } from 'node:path' import { z } from 'zod' -import { isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest' -import { pluginRelativeDirectorySchema } from '../../shared/plugins/plugin-manifest-fields' -import { isOfficialPluginIdentity } from '../../shared/plugins/plugin-marketplace' -import { getUserPluginsDir } from './plugin-discovery' -import { installBundledPlugin, readPluginLockfile } from './plugin-install' -import { inspectPluginInstallTree } from './plugin-install-staging' -import { readPluginCurrentPointer } from './plugin-current-pointer' +import { isQualifiedPluginKey } from '../../shared/plugins/manifest/plugin-manifest' +import { pluginRelativeDirectorySchema } from '../../shared/plugins/manifest/plugin-manifest-fields' +import { hasOfficialPluginPublisher } from '../../shared/plugins/catalog/plugin-marketplace' +import { getPluginsDataDir, getUserPluginsDir } from './plugin-discovery' +import { + installBundledPlugin, + readPluginLockfile, + retireBundledPlugin +} from './installation/plugin-install' +import { inspectPluginInstallTree } from './installation/plugin-install-staging' +import { readPluginCurrentPointer } from './installation/plugin-current-pointer' import { hashPluginTree } from './plugin-content-hash' export const BUNDLED_PLUGIN_INDEX_FILENAME = 'bundled-plugins.json' @@ -23,7 +27,10 @@ const bundledPluginIndexSchema = z pluginKey: z .string() .refine(isQualifiedPluginKey, 'invalid qualified plugin identity') - .refine(isOfficialPluginIdentity, 'bundled plugins must use an official identity'), + .refine( + hasOfficialPluginPublisher, + 'bundled plugins must use the official publisher' + ), path: pluginRelativeDirectorySchema, contentHash: z.string().regex(/^[0-9a-f]{64}$/) }) @@ -49,6 +56,8 @@ const bundledPluginIndexSchema = z export type PluginBundledBootstrapResult = { installed: string[] unchanged: string[] + /** Previously bundled plugins this release stopped shipping, now uninstalled. */ + retired: string[] errors: { pluginKey: string; error: string }[] } @@ -107,7 +116,12 @@ export async function bootstrapBundledPlugins(options: { const index = await readBundledPluginIndex(options.root) const pluginsDir = getUserPluginsDir(options.userDataPath) const lock = await readPluginLockfile(pluginsDir) - const result: PluginBundledBootstrapResult = { installed: [], unchanged: [], errors: [] } + const result: PluginBundledBootstrapResult = { + installed: [], + unchanged: [], + retired: [], + errors: [] + } for (const entry of index.plugins) { const locked = lock.plugins[entry.pluginKey] if ( @@ -150,5 +164,27 @@ export async function bootstrapBundledPlugins(options: { }) } } + // Why: a release that drops a bundled plugin must not leave it installed but unremovable. + const shipped = new Set(index.plugins.map((entry) => entry.pluginKey)) + for (const [pluginKey, locked] of Object.entries(lock.plugins)) { + if (locked.source.kind !== 'bundled' || shipped.has(pluginKey)) { + continue + } + try { + const retired = await retireBundledPlugin({ + pluginsDir, + pluginsDataDir: getPluginsDataDir(options.userDataPath), + pluginKey + }) + if (retired) { + result.retired.push(pluginKey) + } + } catch (error) { + result.errors.push({ + pluginKey, + error: error instanceof Error ? error.message : String(error) + }) + } + } return result } diff --git a/src/main/plugins/plugin-client-list.test.ts b/src/main/plugins/plugin-client-list.test.ts new file mode 100644 index 00000000000..d2d32facbaf --- /dev/null +++ b/src/main/plugins/plugin-client-list.test.ts @@ -0,0 +1,87 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { PLUGIN_SCHEMA_NEWEST_SINCE } from '../../shared/plugins/installation/plugin-host-version' +import { PLUGIN_MANIFEST_V2_SINCE } from '../../shared/plugins/manifest/plugin-schema-field-docs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getUserPluginsDir } from './plugin-discovery' +import { listPluginsForClients, readInstalledPluginManifest } from './plugin-client-list' +import type { PluginService } from './plugin-service' + +const roots: string[] = [] + +async function userDataWithInstalledPlugin(): Promise { + const userDataPath = await mkdtemp(join(tmpdir(), 'orca-plugin-client-list-')) + roots.push(userDataPath) + const hash = 'a'.repeat(64) + const pluginDir = join(getUserPluginsDir(userDataPath), 'acme.notes') + await mkdir(join(pluginDir, hash), { recursive: true }) + await writeFile(join(pluginDir, 'current'), hash) + await writeFile( + join(pluginDir, hash, 'orca-plugin.json'), + JSON.stringify({ + manifestVersion: 2, + execution: 'data', + id: 'notes', + publisher: 'acme', + name: 'Notes', + version: '1.0.0', + engines: { orca: `>=${PLUGIN_MANIFEST_V2_SINCE}` }, + license: 'MIT' + }) + ) + return userDataPath +} + +function serviceOff(userDataPath: string) { + const getDiscovered = vi.fn(() => []) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the list reads only the members stubbed here. + const service = { + options: { + isPluginSystemEnabled: () => false, + userDataPath, + hostVersion: PLUGIN_SCHEMA_NEWEST_SINCE, + getDevPluginPaths: () => [], + getPluginConsents: () => ({}), + getDisabledPlugins: () => [] + }, + whenReady: async () => undefined, + getDiscovered, + activationState: () => 'disabled', + workerState: () => ({ state: 'inactive', restarts: 0 }), + activationError: () => null, + contentPacks: { + vmRecipes: { preview: () => [] }, + commands: { preview: () => [] } + } + } as unknown as PluginService + return { service, getDiscovered } +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('plugin client list with the plugin system off', () => { + it('lists installed plugins read-only as disabled', async () => { + const { service, getDiscovered } = serviceOff(await userDataWithInstalledPlugin()) + + const plugins = await listPluginsForClients(service) + + expect(plugins).toMatchObject([ + { pluginKey: 'acme.notes', name: 'Notes', status: 'disabled', license: 'MIT' } + ]) + // Why: the live registry stays empty while off; the read must not populate or activate it. + expect(getDiscovered).not.toHaveBeenCalled() + }) + + it('reads one installed manifest on demand', async () => { + const { service } = serviceOff(await userDataWithInstalledPlugin()) + + await expect(readInstalledPluginManifest(service, 'acme.notes')).resolves.toMatchObject({ + name: 'Notes', + execution: 'data' + }) + await expect(readInstalledPluginManifest(service, 'acme.missing')).resolves.toBeNull() + }) +}) diff --git a/src/main/plugins/plugin-client-list.ts b/src/main/plugins/plugin-client-list.ts index 20892b60a0d..26868f92b02 100644 --- a/src/main/plugins/plugin-client-list.ts +++ b/src/main/plugins/plugin-client-list.ts @@ -1,13 +1,53 @@ -import { getUserPluginsDir } from './plugin-discovery' -import { readPluginLockfile } from './plugin-install' -import { buildPluginList, type PluginListEntry } from './plugin-list-projection' +import type { PluginManifest } from '../../shared/plugins/manifest/plugin-manifest' +import { + discoverPlugins, + getUserPluginsDir, + isInvalidDiscoveredPlugin, + type DiscoveredPlugin +} from './plugin-discovery' +import { readPluginLockfile } from './installation/plugin-install' +import { buildPluginList } from './plugin-list-projection' +import type { PluginHostListEntry } from '../../shared/plugins/plugin-list-entry' import type { PluginService } from './plugin-service' -/** Keep client listing independent of ipcMain so headless RPC can import it. */ +/** Installed plugins as clients see them. With the plugin system off the service discovers + * nothing, so this reads the install tree on demand; that never activates or starts anything, + * and startup with the system off stays free of plugin I/O. */ +async function discoveredForClients( + pluginService: PluginService +): Promise { + if (pluginService.options.isPluginSystemEnabled()) { + return pluginService.getDiscovered() + } + return discoverPlugins({ + pluginsDir: getUserPluginsDir(pluginService.options.userDataPath), + devPluginPaths: pluginService.options.getDevPluginPaths(), + hostVersion: pluginService.options.hostVersion + }) +} + +/** + * The plugin list paired clients see. Split out of `ipc/plugins.ts` so the runtime's + * `plugins.list` RPC can reach it without dragging `ipcMain` into its module graph — + * the same reason preflight and the SSH registry moved. + */ export async function listPluginsForClients( pluginService: PluginService -): Promise { +): Promise { await pluginService.whenReady() const lock = await readPluginLockfile(getUserPluginsDir(pluginService.options.userDataPath)) - return buildPluginList(pluginService, lock) + return buildPluginList(pluginService, lock, await discoveredForClients(pluginService)) +} + +/** One installed plugin's parsed manifest, for pages that show what it declares before (or + * without) activation. Null when the plugin is unknown or its manifest is invalid. */ +export async function readInstalledPluginManifest( + pluginService: PluginService, + pluginKey: string +): Promise { + await pluginService.whenReady() + const plugin = (await discoveredForClients(pluginService)).find( + (candidate) => candidate.pluginKey === pluginKey + ) + return plugin && !isInvalidDiscoveredPlugin(plugin) ? plugin.manifest : null } diff --git a/src/main/plugins/plugin-command-registry.test.ts b/src/main/plugins/plugin-command-registry.test.ts index 08de9926a5a..df3d922b7cc 100644 --- a/src/main/plugins/plugin-command-registry.test.ts +++ b/src/main/plugins/plugin-command-registry.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' -import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' +import { fingerprintPluginConsent } from '../../shared/plugins/approval/plugin-consent-fingerprint' +import { pluginManifestSchema } from '../../shared/plugins/manifest/plugin-manifest' import type { ValidDiscoveredPlugin } from './plugin-discovery' import { PluginCommandRegistry } from './plugin-command-registry' @@ -14,7 +14,7 @@ function commandPlugin( const manifest = pluginManifestSchema.parse({ manifestVersion: 1, id, - publisher: 'orca-samples', + publisher: 'samples', name: id, version: '1.0.0', engines: { orca: '>=1.0.0' }, @@ -26,7 +26,7 @@ function commandPlugin( capabilities: [] }) return { - pluginKey: `orca-samples.${id}`, + pluginKey: `samples.${id}`, rootDir: `/plugins/${id}`, manifest, consentFingerprint: fingerprintPluginConsent(manifest, `content-${id}`), @@ -102,7 +102,7 @@ describe('PluginCommandRegistry', () => { const plugin = commandPlugin('repeat', { commands: [ { id: 'one', title: 'One', action: 'view.tasks' }, - { id: 'two', title: 'Two', action: 'view.tasks', context: 'worktree' } + { id: 'two', title: 'Two', action: 'view.tasks', context: 'workspace' } ] }) const registry = new PluginCommandRegistry() @@ -110,8 +110,8 @@ describe('PluginCommandRegistry', () => { [plugin], () => true, { - 'plugin:orca-samples.repeat/one': ['Mod+Alt+T', 'Mod+Alt+Y'], - 'plugin:orca-samples.repeat/two': ['Ctrl+Alt+T', 'Ctrl+Alt+Y'] + 'plugin:samples.repeat/one': ['Mod+Alt+T', 'Mod+Alt+Y'], + 'plugin:samples.repeat/two': ['Ctrl+Alt+T', 'Ctrl+Alt+Y'] }, 'linux' ) @@ -147,7 +147,7 @@ describe('PluginCommandRegistry', () => { it('projects worker commands and inherited worktree keybinding context', () => { const plugin = commandPlugin('worker', { - commands: [{ id: 'create', title: 'Create Task', context: 'worktree' }], + commands: [{ id: 'create', title: 'Create Task', context: 'workspace' }], keybindings: [{ command: 'create', key: 'Mod+Shift+A' }] }) const registry = new PluginCommandRegistry() @@ -156,9 +156,9 @@ describe('PluginCommandRegistry', () => { expect(registry.list()).toMatchObject([ { - context: 'worktree', + context: 'workspace', handler: { type: 'worker' }, - keybindings: [{ key: 'Mod+Shift+A', when: 'worktree' }] + keybindings: [{ key: 'Mod+Shift+A', when: 'workspace' }] } ]) }) @@ -169,8 +169,8 @@ describe('PluginCommandRegistry', () => { keybindings: [{ command: 'tasks', key: 'Mod+Alt+T', when: 'global' }] }) const worktree = commandPlugin('worktree', { - commands: [{ id: 'tasks', title: 'Tasks', context: 'worktree', action: 'view.tasks' }], - keybindings: [{ command: 'tasks', key: 'Mod+Alt+T', when: 'worktree' }] + commands: [{ id: 'tasks', title: 'Tasks', context: 'workspace', action: 'view.tasks' }], + keybindings: [{ command: 'tasks', key: 'Mod+Alt+T', when: 'workspace' }] }) const registry = new PluginCommandRegistry() @@ -195,7 +195,7 @@ describe('PluginCommandRegistry', () => { registry.reconcile( [first, second], () => true, - { 'plugin:orca-samples.first/tasks': ['Mod+Shift+T'] }, + { 'plugin:samples.first/tasks': ['Mod+Shift+T'] }, 'linux' ) @@ -217,8 +217,8 @@ describe('PluginCommandRegistry', () => { [plugin], () => true, { - 'plugin:orca-samples.aliases/tasks': ['Mod+Alt+T'], - 'plugin:orca-samples.aliases/sidebar': ['Mod+Alt+T'] + 'plugin:samples.aliases/tasks': ['Mod+Alt+T'], + 'plugin:samples.aliases/sidebar': ['Mod+Alt+T'] }, 'linux' ) @@ -247,11 +247,11 @@ describe('PluginCommandRegistry', () => { it('allows the same worktree-only chord after one plugin is disabled', () => { const first = commandPlugin('first', { - commands: [{ id: 'tasks', title: 'Tasks', context: 'worktree', action: 'view.tasks' }], + commands: [{ id: 'tasks', title: 'Tasks', context: 'workspace', action: 'view.tasks' }], keybindings: [{ command: 'tasks', key: 'Mod+Alt+T' }] }) const second = commandPlugin('second', { - commands: [{ id: 'tasks', title: 'Tasks', context: 'worktree', action: 'view.tasks' }], + commands: [{ id: 'tasks', title: 'Tasks', context: 'workspace', action: 'view.tasks' }], keybindings: [{ command: 'tasks', key: 'Mod+Alt+T' }] }) const registry = new PluginCommandRegistry() diff --git a/src/main/plugins/plugin-command-registry.ts b/src/main/plugins/plugin-command-registry.ts index fc18fa3e093..8351a19b882 100644 --- a/src/main/plugins/plugin-command-registry.ts +++ b/src/main/plugins/plugin-command-registry.ts @@ -1,13 +1,13 @@ import { pluginCommandKeybindingActionId, type PluginCommandAliasActionId -} from '../../shared/plugins/plugin-command-actions' +} from '../../shared/plugins/contributions/plugin-command-actions' import { getKeybindingConflictIdentity, type KeybindingOverrides } from '../../shared/keybindings' import type { PluginCommandContribution, PluginManifest -} from '../../shared/plugins/plugin-manifest' -import type { PluginKeybindingContribution } from '../../shared/plugins/plugin-content-pack-contributions' +} from '../../shared/plugins/manifest/plugin-manifest' +import type { PluginKeybindingContribution } from '../../shared/plugins/contributions/plugin-content-pack-contributions' import { isInvalidDiscoveredPlugin, type DiscoveredPlugin, @@ -16,14 +16,14 @@ import { export type PluginCommandKeybinding = { key: string - when: 'global' | 'worktree' + when: 'global' | 'workspace' } export type PluginCommandRegistration = { pluginKey: string id: string title: string - context: 'global' | 'worktree' + context: 'global' | 'workspace' handler: { type: 'built-in'; action: PluginCommandAliasActionId } | { type: 'worker' } keybindings: PluginCommandKeybinding[] } @@ -138,7 +138,7 @@ function registrationsForManifest( pluginKey, id: command.id, title: command.title, - context: command.context ?? 'global', + context: command.when ?? 'global', handler: command.action === undefined ? { type: 'worker' as const } @@ -153,6 +153,6 @@ function keybindingsForCommand( ): PluginCommandKeybinding[] { return keybindings.map((keybinding) => ({ key: keybinding.key, - when: keybinding.when ?? command.context ?? 'global' + when: keybinding.when ?? command.when ?? 'global' })) } diff --git a/src/main/plugins/plugin-content-hash.ts b/src/main/plugins/plugin-content-hash.ts index 27b3e55610b..4ed696d3771 100644 --- a/src/main/plugins/plugin-content-hash.ts +++ b/src/main/plugins/plugin-content-hash.ts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto' import { createReadStream } from 'node:fs' import { lstat, readdir } from 'node:fs/promises' import { join, relative } from 'node:path' -import { pluginPathSegmentError } from '../../shared/plugins/plugin-path-safety' +import { pluginPathSegmentError } from '../../shared/plugins/installation/plugin-path-safety' // Immutable installs use sorted path/content hashes and reject symlinks to stay self-contained. diff --git a/src/main/plugins/plugin-content-safety.test.ts b/src/main/plugins/plugin-content-safety.test.ts index 0f9689632bf..c6b16295d0f 100644 --- a/src/main/plugins/plugin-content-safety.test.ts +++ b/src/main/plugins/plugin-content-safety.test.ts @@ -3,8 +3,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { disposeWatcherProcess } from '../ipc/filesystem/watcher-process/parcel-watcher-process' -import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' -import { pluginManifestSchema, type PluginManifest } from '../../shared/plugins/plugin-manifest' +import { fingerprintPluginConsent } from '../../shared/plugins/approval/plugin-consent-fingerprint' +import { + pluginManifestSchema, + type PluginManifest +} from '../../shared/plugins/manifest/plugin-manifest' import { PLUGIN_PANEL_ENTRY_MAX_BYTES, validateDeclaredPluginArtifacts, @@ -31,7 +34,7 @@ function manifest(overrides: ManifestOverrides = {}): PluginManifest { return pluginManifestSchema.parse({ manifestVersion: 1, id: 'demo', - publisher: 'orca-samples', + publisher: 'samples', name: 'Demo', version: '1.0.0', engines: { orca: '>=1.0.0' }, @@ -52,11 +55,7 @@ afterEach(async () => { describe('declared plugin artifacts', () => { it('validates every content-pack file and directory before enablement', async () => { const root = await tempRoot() - await Promise.all([ - mkdir(join(root, 'locales')), - mkdir(join(root, 'recipes')), - writeFile(join(root, 'agent.json'), '{}') - ]) + await Promise.all([mkdir(join(root, 'locales')), mkdir(join(root, 'recipes'))]) await Promise.all([ writeFile(join(root, 'locales', 'pt-BR.json'), '{}'), writeFile(join(root, 'recipes', 'vm.json'), '{}') @@ -64,8 +63,7 @@ describe('declared plugin artifacts', () => { const pluginManifest = manifest({ contributes: { languagePacks: [{ locale: 'pt-BR', path: 'locales/pt-BR.json' }], - vmRecipes: [{ path: 'recipes/vm.json' }], - agents: [{ path: 'agent.json' }] + vmRecipes: [{ path: 'recipes/vm.json' }] } }) diff --git a/src/main/plugins/plugin-declarative-content-integrity.ts b/src/main/plugins/plugin-declarative-content-integrity.ts new file mode 100644 index 00000000000..d27e4998f6a --- /dev/null +++ b/src/main/plugins/plugin-declarative-content-integrity.ts @@ -0,0 +1,22 @@ +import { hasDeclarativePluginContributions } from '../../shared/plugins/approval/plugin-consent-fingerprint' +import { hashPluginTree } from './plugin-content-hash' +import { pluginTreeHashMatches } from './plugin-content-integrity' +import type { ValidDiscoveredPlugin } from './plugin-discovery' + +/** Declarative bytes execute later, so every read must still match the tree + * identity the user reviewed rather than a cached discovery-time snapshot. */ +export async function verifyDeclarativePluginContent(plugin: ValidDiscoveredPlugin): Promise { + if (!hasDeclarativePluginContributions(plugin.manifest)) { + return + } + if (!plugin.consentContentHash) { + throw new Error(`plugin ${plugin.pluginKey} has no declarative consent content identity`) + } + const actual = await hashPluginTree(plugin.rootDir) + if (!actual.ok) { + throw new Error(`plugin ${plugin.pluginKey} declarative content is unreadable: ${actual.error}`) + } + if (!pluginTreeHashMatches(actual.hash, plugin.consentContentHash)) { + throw new Error(`plugin ${plugin.pluginKey} declarative content changed since it was reviewed`) + } +} diff --git a/src/main/plugins/plugin-discovery.test.ts b/src/main/plugins/plugin-discovery.test.ts index 04bdecd761a..039f17387d8 100644 --- a/src/main/plugins/plugin-discovery.test.ts +++ b/src/main/plugins/plugin-discovery.test.ts @@ -1,9 +1,11 @@ import { mkdir, mkdtemp, rm, truncate, writeFile } from 'node:fs/promises' +import { PLUGIN_SCHEMA_NEWEST_SINCE } from '../../shared/plugins/installation/plugin-host-version' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { discoverPlugins, isInvalidDiscoveredPlugin } from './plugin-discovery' -import { PLUGIN_CURRENT_POINTER_MAX_BYTES } from './plugin-current-pointer' +import { PLUGIN_CURRENT_POINTER_MAX_BYTES } from './installation/plugin-current-pointer' +import { getPluginActivationState } from '../../shared/plugins/approval/plugin-consent-state' const roots: string[] = [] @@ -17,10 +19,54 @@ async function tempPluginsDir(): Promise { return root } +describe('project repositories', () => { + it('can neither load nor approve a plugin they carry', async () => { + const userPlugins = await tempPluginsDir() + const worktree = await tempPluginsDir() + const manifest = JSON.stringify({ + manifestVersion: 2, + execution: 'sandboxed-ui', + id: 'repo-panel', + publisher: 'acme', + name: 'Repo panel', + version: '1.0.0', + engines: { orca: `>=${PLUGIN_SCHEMA_NEWEST_SINCE}` }, + contributes: { panels: [{ id: 'p', title: 'P', entry: 'p.html' }] } + }) + // Every place a repository might plausibly drop a plugin for Orca to pick up. + for (const dir of [worktree, join(worktree, '.orca', 'plugins', 'acme.repo-panel')]) { + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'orca-plugin.json'), manifest) + await writeFile(join(dir, 'p.html'), '

repo

') + } + const discover = (devPluginPaths: string[]) => + discoverPlugins({ + pluginsDir: userPlugins, + devPluginPaths, + hostVersion: PLUGIN_SCHEMA_NEWEST_SINCE + }) + + // Why: roots are the user's plugins dir and dev folders the user added, never a workspace. + expect(await discover([])).toEqual([]) + // Even after the user adds the repo as a dev folder, it waits for approval: consent lives + // only in user settings, keyed by the reviewed fingerprint. + const [plugin] = await discover([worktree]) + if (!plugin || isInvalidDiscoveredPlugin(plugin)) { + throw new Error('expected a valid dev plugin') + } + expect( + getPluginActivationState(plugin.pluginKey, plugin.consentFingerprint, { + pluginConsents: {}, + disabledPlugins: [] + }) + ).toBe('pending') + }) +}) + describe('installed plugin discovery identity', () => { it('requires a new review when an installed v2 worker has different bytes', async () => { const pluginsDir = await tempPluginsDir() - const key = 'orca-samples.worker' + const key = 'samples.worker' const discoverAt = async (hash: string) => { const versionDir = join(pluginsDir, key, hash) await mkdir(versionDir, { recursive: true }) @@ -32,10 +78,10 @@ describe('installed plugin discovery identity', () => { manifestVersion: 2, execution: 'trusted-worker', id: 'worker', - publisher: 'orca-samples', + publisher: 'samples', name: 'Worker', version: '1.0.0', - engines: { orca: '>=1.0.0' }, + engines: { orca: `>=${PLUGIN_SCHEMA_NEWEST_SINCE}` }, pluginApi: 1, main: 'main.mjs' }) @@ -43,7 +89,7 @@ describe('installed plugin discovery identity', () => { const [plugin] = await discoverPlugins({ pluginsDir, devPluginPaths: [], - hostVersion: '1.4.0' + hostVersion: PLUGIN_SCHEMA_NEWEST_SINCE }) if (!plugin || isInvalidDiscoveredPlugin(plugin)) { throw new Error('expected a valid plugin') @@ -60,7 +106,7 @@ describe('installed plugin discovery identity', () => { it('keeps the install directory identity when a manifest is invalid or mismatched', async () => { const pluginsDir = await tempPluginsDir() - const installedKey = 'orca-samples.expected' + const installedKey = 'samples.expected' const hash = 'a'.repeat(64) const versionDir = join(pluginsDir, installedKey, hash) await mkdir(versionDir, { recursive: true }) @@ -70,7 +116,7 @@ describe('installed plugin discovery identity', () => { JSON.stringify({ manifestVersion: 1, id: 'different', - publisher: 'orca-samples', + publisher: 'samples', name: 'Different', version: '1.0.0', engines: { orca: '>=1.0.0' }, @@ -87,9 +133,37 @@ describe('installed plugin discovery identity', () => { expect(plugin && 'error' in plugin ? plugin.error : '').toContain('does not match') }) + it('names the version gap when a plugin for a newer Orca uses fields this build lacks', async () => { + const pluginsDir = await tempPluginsDir() + const hash = 'b'.repeat(64) + const versionDir = join(pluginsDir, 'acme.future', hash) + await mkdir(versionDir, { recursive: true }) + await writeFile(join(pluginsDir, 'acme.future', 'current'), hash) + await writeFile( + join(versionDir, 'orca-plugin.json'), + JSON.stringify({ + manifestVersion: 2, + execution: 'data', + id: 'future', + publisher: 'acme', + name: 'Future', + version: '1.0.0', + engines: { orca: '>=9.0.0' }, + pluginApi: 1, + contributes: { widgets: [{ id: 'clock' }] } + }) + ) + + const [plugin] = await discoverPlugins({ pluginsDir, devPluginPaths: [], hostVersion: '1.4.0' }) + + expect(plugin && 'error' in plugin ? plugin.error : '').toBe( + 'requires Orca >=9.0.0 (this is 1.4.0)' + ) + }) + it('keeps a removable qualified identity when the current pointer is missing', async () => { const pluginsDir = await tempPluginsDir() - const installedKey = 'orca-samples.broken' + const installedKey = 'samples.broken' await mkdir(join(pluginsDir, installedKey), { recursive: true }) const [plugin] = await discoverPlugins({ pluginsDir, devPluginPaths: [], hostVersion: '1.4.0' }) @@ -100,7 +174,7 @@ describe('installed plugin discovery identity', () => { it('rejects an oversized current pointer without an unbounded startup read', async () => { const pluginsDir = await tempPluginsDir() - const installedKey = 'orca-samples.broken' + const installedKey = 'samples.broken' const pluginDir = join(pluginsDir, installedKey) await mkdir(pluginDir, { recursive: true }) const pointer = join(pluginDir, 'current') @@ -114,7 +188,7 @@ describe('installed plugin discovery identity', () => { }) }) -describe('instructional plugin discovery identity', () => { +describe('declarative plugin discovery identity', () => { it('changes dev consent when a VM recipe command changes', async () => { const pluginsDir = await tempPluginsDir() const devRoot = await tempPluginsDir() @@ -124,7 +198,7 @@ describe('instructional plugin discovery identity', () => { JSON.stringify({ manifestVersion: 1, id: 'recipes', - publisher: 'orca-samples', + publisher: 'samples', name: 'Recipes', version: '1.0.0', engines: { orca: '>=1.0.0' }, diff --git a/src/main/plugins/plugin-discovery.ts b/src/main/plugins/plugin-discovery.ts index 3cc0debf10d..1dd89da31c5 100644 --- a/src/main/plugins/plugin-discovery.ts +++ b/src/main/plugins/plugin-discovery.ts @@ -1,7 +1,7 @@ -import { readdir } from 'node:fs/promises' +import { readdir, realpath } from 'node:fs/promises' import type { Dirent } from 'node:fs' import { join } from 'node:path' -import { PLUGIN_CONTENT_HASH_PATTERN } from '../../shared/plugins/plugin-install-lockfile' +import { PLUGIN_CONTENT_HASH_PATTERN } from '../../shared/plugins/installation/plugin-install-lockfile' import { PLUGIN_MANIFEST_FILENAME, isQualifiedPluginKey, @@ -9,18 +9,19 @@ import { qualifiedPluginKey, satisfiesOrcaEngineRange, type PluginManifest -} from '../../shared/plugins/plugin-manifest' +} from '../../shared/plugins/manifest/plugin-manifest' +import { pluginManifestCompatibilityError } from '../../shared/plugins/manifest/plugin-manifest-compatibility' import { fingerprintPluginConsent, - hasInstructionalPluginContributions, + hasDeclarativePluginContributions, requiresPluginContentApproval -} from '../../shared/plugins/plugin-consent-fingerprint' +} from '../../shared/plugins/approval/plugin-consent-fingerprint' import { validateDeclaredPluginArtifacts } from './plugin-artifact-validation' import { readPluginManifestText } from './plugin-manifest-file' -import { readPluginCurrentPointer } from './plugin-current-pointer' +import { readPluginCurrentPointer } from './installation/plugin-current-pointer' import { hashPluginTree } from './plugin-content-hash' -export { PLUGIN_CURRENT_POINTER_FILENAME } from './plugin-current-pointer' +export { PLUGIN_CURRENT_POINTER_FILENAME } from './installation/plugin-current-pointer' const INSTALLED_PLUGIN_DISCOVERY_CONCURRENCY = 8 @@ -32,7 +33,7 @@ export type ValidDiscoveredPlugin = { manifest: PluginManifest /** Fingerprint of the capabilities and trusted-worker execution tier. */ consentFingerprint: string - /** Immutable tree identity included in consent for instructional packs. */ + /** Immutable tree identity included in consent for declarative packs. */ consentContentHash?: string | null /** Null for mutable dev trees. */ contentHash: string | null @@ -93,7 +94,8 @@ async function readManifestDir( } const parsed = parsePluginManifest(raw) if (!parsed.ok) { - return { rootDir, error: `invalid manifest: ${parsed.error}`, isDev } + const incompatible = pluginManifestCompatibilityError(raw, hostVersion) + return { rootDir, error: incompatible ?? `invalid manifest: ${parsed.error}`, isDev } } const manifest = parsed.manifest const pluginKey = qualifiedPluginKey(manifest) @@ -120,7 +122,7 @@ async function readManifestDir( } // Why dev trees bind only instructions: dev code is the author's working copy, and // binding it would re-prompt on every save. - if (isDev && hasInstructionalPluginContributions(manifest)) { + if (isDev && hasDeclarativePluginContributions(manifest)) { const treeHash = await hashPluginTree(rootDir) if (!treeHash.ok) { return { pluginKey, rootDir, error: treeHash.error, isDev } @@ -131,13 +133,27 @@ async function readManifestDir( pluginKey, rootDir, manifest, - consentFingerprint: fingerprintPluginConsent(manifest, consentContentIdentity), + consentFingerprint: fingerprintPluginConsent( + manifest, + isDev + ? await devFolderConsentIdentity(rootDir, consentContentIdentity) + : consentContentIdentity + ), consentContentHash: consentContentIdentity ?? null, contentHash: null, isDev } } +/** Why the folder: otherwise approving one dev folder approves any other claiming the same key. */ +export async function devFolderConsentIdentity( + rootDir: string, + contentIdentity?: string +): Promise { + const folder = await realpath(rootDir).catch(() => rootDir) + return `dev-folder:${folder}\0${contentIdentity ?? ''}` +} + async function readInstalledPlugin( pluginDir: string, dirName: string, diff --git a/src/main/plugins/plugin-enablement.test.ts b/src/main/plugins/plugin-enablement.test.ts index f0698b80a1b..5f0d5847fd1 100644 --- a/src/main/plugins/plugin-enablement.test.ts +++ b/src/main/plugins/plugin-enablement.test.ts @@ -2,17 +2,17 @@ import { tmpdir } from 'node:os' import { describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../shared/constants' import type { GlobalSettings } from '../../shared/global-settings-types' -import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' +import { pluginManifestSchema } from '../../shared/plugins/manifest/plugin-manifest' import type { Store } from '../persistence' import { applyPluginConsent, applyPluginEnablement } from './plugin-enablement' import type { ValidDiscoveredPlugin } from './plugin-discovery' import type { PluginService } from './plugin-service' -const pluginKey = 'orca-samples.demo' +const pluginKey = 'samples.demo' const manifest = pluginManifestSchema.parse({ manifestVersion: 1, id: 'demo', - publisher: 'orca-samples', + publisher: 'samples', name: 'Demo', version: '1.0.0', engines: { orca: '>=1.0.0' }, @@ -41,7 +41,9 @@ function createPluginService( getFingerprint: () => string, overrides: Partial = {} ): PluginService { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the enablement paths read only these members. return { + options: {}, findValidPlugin: (requestedKey: string) => requestedKey === pluginKey ? { @@ -59,6 +61,25 @@ function createPluginService( } as unknown as PluginService } +describe('full access', () => { + const workerManifest = pluginManifestSchema.parse({ ...manifest, main: 'main.mjs' }) + + it('approves and enables a trusted worker with its own consent alone', async () => { + const harness = createStore() + const pluginService = createPluginService(() => 'sha256-reviewed', { + manifest: workerManifest + }) + const request = { store: harness.store, pluginService, pluginKey } + await applyPluginConsent({ + ...request, + reviewedFingerprint: 'sha256-reviewed', + decision: 'approve' + }) + await applyPluginEnablement({ ...request, enabled: true }) + expect(harness.getSettings().pluginConsents[pluginKey]).toBe('sha256-reviewed') + }) +}) + describe('applyPluginConsent', () => { it('stores approval only for the fingerprint the user reviewed', async () => { const harness = createStore() @@ -122,7 +143,7 @@ describe('applyPluginEnablement', () => { applyPluginEnablement({ store: harness.store, pluginService, - pluginKey: 'orca-samples.unknown', + pluginKey: 'samples.unknown', enabled: false }) ).rejects.toThrow('unknown plugin') diff --git a/src/main/plugins/plugin-enablement.ts b/src/main/plugins/plugin-enablement.ts index 029e4fd4669..135f36c36bc 100644 --- a/src/main/plugins/plugin-enablement.ts +++ b/src/main/plugins/plugin-enablement.ts @@ -1,11 +1,11 @@ import { normalizePluginConsents, normalizePluginIdList -} from '../../shared/plugins/plugin-consent-state' +} from '../../shared/plugins/approval/plugin-consent-state' import type { Store } from '../persistence' import type { PluginService } from './plugin-service' -import type { PluginConsentRequest } from '../../shared/plugins/plugin-consent-request' -import { verifyInstructionalPluginContent } from './plugin-instructional-content-integrity' +import type { PluginConsentRequest } from '../../shared/plugins/approval/plugin-consent-request' +import { verifyDeclarativePluginContent } from './plugin-declarative-content-integrity' // Desktop and headless writes share qualified-key consent; capability or worker-tier expansion requires reapproval. @@ -28,8 +28,9 @@ export async function applyPluginConsent(input: { throw new Error(`plugin ${pluginKey} changed since its permissions were reviewed`) } if (input.decision === 'approve') { - // Direct IPC/serve callers bypass the dialog; verify instructional bytes in main. - await verifyInstructionalPluginContent(plugin) + // Why: IPC and serve callers can bypass the renderer dialog, so main must + // prove every declarative byte is still reviewable before enabling it. + await verifyDeclarativePluginContent(plugin) } const settings = store.getSettings() const disabled = new Set(normalizePluginIdList(settings.disabledPlugins)) @@ -56,7 +57,8 @@ export async function applyPluginEnablement(input: { originWebContentsId?: number }): Promise { const { store, pluginService, pluginKey, enabled } = input - if (!pluginService.findValidPlugin(pluginKey)) { + const plugin = pluginService.findValidPlugin(pluginKey) + if (!plugin) { throw new Error(`cannot change enablement for unknown plugin ${pluginKey}`) } const settings = store.getSettings() diff --git a/src/main/plugins/plugin-event-bus.ts b/src/main/plugins/plugin-event-bus.ts deleted file mode 100644 index d51d179424d..00000000000 --- a/src/main/plugins/plugin-event-bus.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { PLUGIN_EVENT_PAYLOAD_SCHEMAS } from '../../shared/plugins/plugin-events' -import type { PluginEventName } from '../../shared/plugins/plugin-manifest' - -// Manifest subscriptions activate durably; dynamic subscriptions die with their worker. - -export class PluginEventBus { - private readonly dynamicSubscriptions = new Map>() - - subscribe(pluginKey: string, events: PluginEventName[]): PluginEventName[] { - const existing = this.dynamicSubscriptions.get(pluginKey) ?? new Set() - for (const event of events) { - existing.add(event) - } - this.dynamicSubscriptions.set(pluginKey, existing) - return [...existing] - } - - isDynamicallySubscribed(pluginKey: string, event: PluginEventName): boolean { - return this.dynamicSubscriptions.get(pluginKey)?.has(event) ?? false - } - - clear(pluginKey: string): void { - this.dynamicSubscriptions.delete(pluginKey) - } - - projectPayload( - event: PluginEventName, - payload: unknown - ): { ok: true; payload: unknown } | { ok: false; error: string } { - const parsed = PLUGIN_EVENT_PAYLOAD_SCHEMAS[event].safeParse(payload) - return parsed.success - ? { ok: true, payload: parsed.data } - : { ok: false, error: `malformed ${event} payload` } - } -} diff --git a/src/main/plugins/plugin-event-delivery.ts b/src/main/plugins/plugin-event-delivery.ts index 3444151c668..bebcfde950d 100644 --- a/src/main/plugins/plugin-event-delivery.ts +++ b/src/main/plugins/plugin-event-delivery.ts @@ -1,48 +1,50 @@ -import type { PluginEventName } from '../../shared/plugins/plugin-manifest' +import type { PluginEventName } from '../../shared/plugins/manifest/plugin-manifest' +import type { PluginCapabilityKind } from '../../shared/plugins/capabilities/plugin-capabilities' +import { PLUGIN_EVENT_CAPABILITY } from '../../shared/plugins/contributions/plugin-event-contribution' +import { PLUGIN_EVENT_PAYLOAD_SCHEMAS } from '../../shared/plugins/events/plugin-events' import { isInvalidDiscoveredPlugin, type DiscoveredPlugin, type ValidDiscoveredPlugin } from './plugin-discovery' -import type { PluginEventBus } from './plugin-event-bus' -import type { PluginWorkerController } from './plugin-worker-controller' +import type { PluginWorkerController } from './workers/plugin-worker-controller' +/** Delivers only to plugins whose manifest subscribes and whose grant covers the event's domain. */ export function deliverPluginEvent(options: { event: PluginEventName payload: unknown plugins: readonly DiscoveredPlugin[] - eventBus: PluginEventBus workerController: PluginWorkerController isRuntimeApproved: (plugin: ValidDiscoveredPlugin) => boolean + grantedCapabilities: (pluginKey: string) => readonly PluginCapabilityKind[] | null logWarning: (pluginKey: string) => (line: string) => void }): void { - const projected = options.eventBus.projectPayload(options.event, options.payload) - if (!projected.ok) { + // Why: payloads are bounded projections, so a malformed one never reaches a plugin. + const projected = PLUGIN_EVENT_PAYLOAD_SCHEMAS[options.event].safeParse(options.payload) + if (!projected.success) { return } + const required = PLUGIN_EVENT_CAPABILITY[options.event] for (const plugin of options.plugins) { - if (isInvalidDiscoveredPlugin(plugin) || !options.isRuntimeApproved(plugin)) { + if ( + isInvalidDiscoveredPlugin(plugin) || + !plugin.manifest.main || + !options.isRuntimeApproved(plugin) || + !plugin.manifest.contributes.events.some( + (subscription) => subscription.on === options.event + ) || + !options.grantedCapabilities(plugin.pluginKey)?.includes(required) + ) { continue } - const manifestSubscribed = plugin.manifest.contributes.events.some( - (subscription) => subscription.on === options.event - ) - if (manifestSubscribed && plugin.manifest.main) { - const logWarning = options.logWarning(plugin.pluginKey) - void options.workerController - .ensure(plugin) - .then((handle) => handle.deliverEvent(options.event, projected.payload)) - .catch((error) => { - logWarning( - `event ${options.event} dropped: ${error instanceof Error ? error.message : String(error)}` - ) - }) - } else if (options.eventBus.isDynamicallySubscribed(plugin.pluginKey, options.event)) { - options.workerController.deliverEventIfRunning( - plugin.pluginKey, - options.event, - projected.payload - ) - } + const logWarning = options.logWarning(plugin.pluginKey) + void options.workerController + .ensure(plugin) + .then((handle) => handle.deliverEvent(options.event, projected.data)) + .catch((error) => { + logWarning( + `event ${options.event} dropped: ${error instanceof Error ? error.message : String(error)}` + ) + }) } } diff --git a/src/main/plugins/plugin-example-gleam-syntax.test.ts b/src/main/plugins/plugin-example-gleam-syntax.test.ts new file mode 100644 index 00000000000..cfcef7b99f6 --- /dev/null +++ b/src/main/plugins/plugin-example-gleam-syntax.test.ts @@ -0,0 +1,48 @@ +import { join } from 'node:path' +import { PLUGIN_SCHEMA_NEWEST_SINCE } from '../../shared/plugins/installation/plugin-host-version' +import { expect, it } from 'vitest' +import { fingerprintPluginConsent } from '../../shared/plugins/approval/plugin-consent-fingerprint' +import { PluginContentVerifier } from './plugin-content-integrity' +import { inspectPluginInstallTree } from './installation/plugin-install-staging' +import { PluginSyntaxRegistry } from './contributions/plugin-syntax-registry' + +const root = join(process.cwd(), 'examples', 'plugins', 'orca-gleam-syntax') + +it('ships an installable Gleam syntax pack whose grammar loads', async () => { + const inspection = await inspectPluginInstallTree({ + rootDir: root, + hostVersion: PLUGIN_SCHEMA_NEWEST_SINCE, + expectedPluginKey: 'stablyai.orca-gleam-syntax' + }) + expect(inspection).toMatchObject({ ok: true }) + if (!inspection.ok) { + return + } + expect(inspection.manifest).toMatchObject({ manifestVersion: 2, execution: 'data' }) + + const registry = new PluginSyntaxRegistry(new PluginContentVerifier()) + await registry.reconcile( + [ + { + pluginKey: 'stablyai.orca-gleam-syntax', + rootDir: root, + manifest: inspection.manifest, + consentFingerprint: fingerprintPluginConsent(inspection.manifest), + contentHash: null, + isDev: true + } + ], + () => true + ) + expect(registry.error('stablyai.orca-gleam-syntax')).toBeNull() + expect(registry.list()).toEqual([ + expect.objectContaining({ + id: 'plugin:stablyai.orca-gleam-syntax/gleam', + extensions: ['.gleam'] + }) + ]) + expect(registry.load('plugin:stablyai.orca-gleam-syntax/gleam')).toMatchObject({ + scopeName: 'source.gleam', + configuration: { comments: { lineComment: '//' } } + }) +}) diff --git a/src/main/plugins/plugin-host-method-bindings.ts b/src/main/plugins/plugin-host-method-bindings.ts deleted file mode 100644 index 9cddce1f064..00000000000 --- a/src/main/plugins/plugin-host-method-bindings.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { - getPluginHostMethodSpec, - PLUGIN_HOST_API_V0, - PLUGIN_TERMINAL_ID_MAX_LENGTH, - PLUGIN_WORKSPACE_LABEL_MAX_LENGTH, - PLUGIN_WORKSPACE_TERMINAL_LIMIT, - type PluginHostMethodSpec -} from '../../shared/plugins/plugin-host-api' -import type { PluginEventName } from '../../shared/plugins/plugin-manifest' - -export type PluginWorktreeContext = { - worktreeId: string - branch: string - displayName: string -} - -export type PluginHostServices = { - resolveActiveWorktreeContext(): Promise - listWorktreeTerminals(worktreeId: string): Promise<{ id: string }[]> - sendTerminalText( - terminalId: string, - action: { text: string; enter: boolean } - ): Promise<{ accepted: boolean }> - dispatchPluginNotification(input: { - pluginId: string - title: string - body?: string - }): Promise<{ delivered: boolean }> - storage: { - get(pluginId: string, key: string): unknown - set(pluginId: string, key: string, value: unknown): { ok: true } | { ok: false; error: string } - delete(pluginId: string, key: string): void - keys(pluginId: string): string[] - } - secrets: { - get( - pluginId: string, - key: string - ): { ok: true; value: string | null } | { ok: false; error: string } - set(pluginId: string, key: string, value: string): { ok: true } | { ok: false; error: string } - delete(pluginId: string, key: string): void - } - settings: { - getAll(pluginId: string): Record - set(pluginId: string, key: string, value: unknown): { ok: true } | { ok: false; error: string } - } - subscribeEvents(pluginId: string, events: PluginEventName[]): PluginEventName[] -} - -export type BoundPluginHostMethod = { - spec: PluginHostMethodSpec - handler: ( - params: unknown, - ctx: { pluginId: string; services: PluginHostServices } - ) => Promise -} - -function definePluginMethod( - name: string, - handler: BoundPluginHostMethod['handler'] -): [string, BoundPluginHostMethod] { - const spec = getPluginHostMethodSpec(name) - if (!spec) { - throw new Error(`no host API spec for method ${name}`) - } - return [name, { spec, handler }] -} - -const HANDLERS = new Map([ - definePluginMethod('workspace.readContext', async (_params, { services }) => { - const context = await services.resolveActiveWorktreeContext() - if (!context) { - return null - } - const terminals = await services.listWorktreeTerminals(context.worktreeId) - // Worktree IDs embed provider paths; explicitly project safe public fields. - return { - branch: context.branch.slice(0, PLUGIN_WORKSPACE_LABEL_MAX_LENGTH), - displayName: context.displayName.slice(0, PLUGIN_WORKSPACE_LABEL_MAX_LENGTH), - terminals: terminals - .filter( - (terminal) => - terminal.id.length > 0 && terminal.id.length <= PLUGIN_TERMINAL_ID_MAX_LENGTH - ) - .slice(0, PLUGIN_WORKSPACE_TERMINAL_LIMIT) - .map((terminal) => ({ id: terminal.id })) - } - }), - definePluginMethod('terminal.sendText', async (params, { services }) => { - const { terminalId, text, enter } = params as { - terminalId: string - text: string - enter: boolean - } - const context = await services.resolveActiveWorktreeContext() - if (!context) { - throw new Error('no active worktree is available for terminal input') - } - // Handles outlive focus changes; revalidate current workspace membership before terminal input. - const terminals = await services.listWorktreeTerminals(context.worktreeId) - if (!terminals.some((terminal) => terminal.id === terminalId)) { - throw new Error('terminal is outside the active worktree') - } - const result = await services.sendTerminalText(terminalId, { text, enter }) - return { accepted: result.accepted } - }), - definePluginMethod('notifications.show', async (params, { pluginId, services }) => { - const { title, body } = params as { title: string; body?: string } - return services.dispatchPluginNotification({ pluginId, title, body }) - }), - definePluginMethod('storage.get', async (params, { pluginId, services }) => { - const { key } = params as { key: string } - return { value: services.storage.get(pluginId, key) ?? null } - }), - definePluginMethod('storage.set', async (params, { pluginId, services }) => { - const { key, value } = params as { key: string; value: unknown } - const result = services.storage.set(pluginId, key, value) - if (!result.ok) { - throw new Error(result.error) - } - return { ok: true } - }), - definePluginMethod('storage.delete', async (params, { pluginId, services }) => { - const { key } = params as { key: string } - services.storage.delete(pluginId, key) - return { ok: true } - }), - definePluginMethod('storage.keys', async (_params, { pluginId, services }) => { - return { keys: services.storage.keys(pluginId) } - }), - definePluginMethod('secrets.get', async (params, { pluginId, services }) => { - const { key } = params as { key: string } - const result = services.secrets.get(pluginId, key) - if (!result.ok) { - throw new Error(result.error) - } - return { value: result.value } - }), - definePluginMethod('secrets.set', async (params, { pluginId, services }) => { - const { key, value } = params as { key: string; value: string } - const result = services.secrets.set(pluginId, key, value) - if (!result.ok) { - throw new Error(result.error) - } - return { ok: true } - }), - definePluginMethod('secrets.delete', async (params, { pluginId, services }) => { - const { key } = params as { key: string } - services.secrets.delete(pluginId, key) - return { ok: true } - }), - definePluginMethod('settings.get', async (_params, { pluginId, services }) => { - return { settings: services.settings.getAll(pluginId) } - }), - definePluginMethod('settings.set', async (params, { pluginId, services }) => { - const { key, value } = params as { key: string; value: unknown } - const result = services.settings.set(pluginId, key, value) - if (!result.ok) { - throw new Error(result.error) - } - return { ok: true } - }), - definePluginMethod('events.subscribe', async (params, { pluginId, services }) => { - const { events } = params as { events: PluginEventName[] } - return { subscribed: services.subscribeEvents(pluginId, events) } - }) -]) - -// Missing bindings must fail before transports expose inconsistent API behavior. -if (HANDLERS.size !== PLUGIN_HOST_API_V0.length) { - throw new Error('plugin host API spec table and handler bindings are out of sync') -} - -export function getBoundPluginHostMethod(name: string): BoundPluginHostMethod | null { - return HANDLERS.get(name) ?? null -} diff --git a/src/main/plugins/plugin-host-runtime.test.ts b/src/main/plugins/plugin-host-runtime.test.ts deleted file mode 100644 index 48d680a0382..00000000000 --- a/src/main/plugins/plugin-host-runtime.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { join } from 'node:path' -import { pathToFileURL } from 'node:url' -import { createPluginWorkerRuntime } from './plugin-host-runtime' - -describe('plugin worker shutdown', () => { - it('normalizes either manifest separator before importing the worker', async () => { - const importModule = vi.fn(async () => ({ default: vi.fn() })) - const runtime = createPluginWorkerRuntime({ send: vi.fn(), importModule }) - - await runtime.handleMessage({ - type: 'init', - pluginId: 'orca-samples.demo', - pluginRoot: join('plugin-root'), - mainEntry: 'nested\\worker.js', - grantedCapabilities: [] - }) - - expect(importModule).toHaveBeenCalledWith( - pathToFileURL(join('plugin-root', 'nested', 'worker.js')).href - ) - }) - - it('awaits an optional deactivate export before exiting', async () => { - let finishDeactivate!: () => void - const deactivate = vi.fn( - () => - new Promise((resolve) => { - finishDeactivate = resolve - }) - ) - const send = vi.fn() - const exit = vi.fn() - const runtime = createPluginWorkerRuntime({ - send, - exit, - importModule: async () => ({ default: vi.fn(), deactivate }) - }) - await runtime.handleMessage({ - type: 'init', - pluginId: 'orca-samples.demo', - pluginRoot: '/plugin', - mainEntry: 'worker.js', - grantedCapabilities: [] - }) - - const shutdown = runtime.handleMessage({ type: 'shutdown' }) - await Promise.resolve() - expect(deactivate).toHaveBeenCalledOnce() - expect(exit).not.toHaveBeenCalled() - finishDeactivate() - await shutdown - - expect(exit).toHaveBeenCalledWith(0) - }) - - it('exits immediately when the plugin has no deactivate export', async () => { - const exit = vi.fn() - const runtime = createPluginWorkerRuntime({ - send: vi.fn(), - exit, - importModule: async () => ({ default: vi.fn() }) - }) - await runtime.handleMessage({ - type: 'init', - pluginId: 'orca-samples.demo', - pluginRoot: '/plugin', - mainEntry: 'worker.js', - grantedCapabilities: [] - }) - - await runtime.handleMessage({ type: 'shutdown' }) - - expect(exit).toHaveBeenCalledWith(0) - }) -}) diff --git a/src/main/plugins/plugin-host-runtime.ts b/src/main/plugins/plugin-host-runtime.ts deleted file mode 100644 index 501f9c84220..00000000000 --- a/src/main/plugins/plugin-host-runtime.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { join } from 'node:path' -import { pathToFileURL } from 'node:url' -import { - pluginWorkerParentMessageSchema, - type PluginWorkerChildMessage -} from '../../shared/plugins/plugin-host-protocol' -import type { PluginEventName } from '../../shared/plugins/plugin-manifest' - -export type PluginHostCallError = Error & { code?: string } - -/** Experimental activate(orca) surface; host capability checks remain authoritative. */ -export type PluginWorkerOrcaApi = { - commands: { - register(commandId: string, handler: (args: unknown) => unknown): void - } - events: { - on(event: PluginEventName, handler: (payload: unknown) => void | Promise): void - } - host: { - call(method: string, params?: unknown): Promise - } - /** Consented capability kinds (informational — the host re-gates). */ - grantedCapabilities: readonly string[] - log(message: string): void -} - -export type PluginWorkerRuntimeOptions = { - send: (message: PluginWorkerChildMessage) => void - importModule?: (specifier: string) => Promise - exit?: (code: number) => void -} - -export type PluginWorkerRuntime = { - handleMessage(raw: unknown): Promise -} - -function toErrorMessage(error: unknown): string { - return error instanceof Error ? (error.stack ?? error.message) : String(error) -} - -export function createPluginWorkerRuntime( - options: PluginWorkerRuntimeOptions -): PluginWorkerRuntime { - const send = options.send - const importModule = options.importModule ?? ((specifier: string) => import(specifier)) - const exit = options.exit ?? ((code: number) => process.exit(code)) - const commandHandlers = new Map unknown>() - const eventHandlers = new Map void | Promise)[]>() - const pendingHostCalls = new Map< - number, - { resolve: (value: unknown) => void; reject: (error: PluginHostCallError) => void } - >() - let nextHostCallId = 0 - let initialized = false - let shuttingDown = false - let deactivate: (() => unknown) | null = null - - async function handleInit(input: { - pluginRoot: string - mainEntry: string - grantedCapabilities: string[] - }): Promise { - if (initialized) { - send({ type: 'log', level: 'warn', message: 'ignoring duplicate init message' }) - return - } - initialized = true - // File URLs support Windows ESM imports; split either separator for cross-platform manifests. - const entryUrl = pathToFileURL(join(input.pluginRoot, ...input.mainEntry.split(/[\\/]/))).href - const module = (await importModule(entryUrl)) as { default?: unknown; deactivate?: unknown } - const activate = module?.default - if (typeof activate !== 'function') { - throw new Error(`plugin entry ${input.mainEntry} has no default-exported activate function`) - } - if (module.deactivate !== undefined && typeof module.deactivate !== 'function') { - throw new Error(`plugin entry ${input.mainEntry} has a non-function deactivate export`) - } - deactivate = (module.deactivate as (() => unknown) | undefined) ?? null - const orca: PluginWorkerOrcaApi = { - commands: { - register(commandId, handler) { - commandHandlers.set(commandId, handler) - } - }, - events: { - on(event, handler) { - const handlers = eventHandlers.get(event) ?? [] - handlers.push(handler) - eventHandlers.set(event, handlers) - } - }, - host: { - call(method, params) { - const callId = nextHostCallId++ - return new Promise((resolve, reject) => { - pendingHostCalls.set(callId, { resolve, reject }) - send({ type: 'hostCall', callId, method, params }) - }) - } - }, - grantedCapabilities: input.grantedCapabilities, - log(message) { - send({ type: 'log', level: 'info', message: String(message).slice(0, 8192) }) - } - } - await activate(orca) - send({ type: 'ready', commands: [...commandHandlers.keys()] }) - } - - return { - async handleMessage(raw) { - const parsed = pluginWorkerParentMessageSchema.safeParse(raw) - if (!parsed.success) { - send({ type: 'log', level: 'warn', message: 'ignoring malformed parent message' }) - return - } - const message = parsed.data - try { - switch (message.type) { - case 'init': { - await handleInit(message) - return - } - case 'invokeCommand': { - const handler = commandHandlers.get(message.commandId) - if (!handler) { - send({ - type: 'commandResult', - callId: message.callId, - ok: false, - error: `no handler registered for command ${message.commandId}` - }) - return - } - try { - const value = await handler(message.args) - send({ type: 'commandResult', callId: message.callId, ok: true, value }) - } catch (error) { - send({ - type: 'commandResult', - callId: message.callId, - ok: false, - error: toErrorMessage(error) - }) - } - return - } - case 'deliverEvent': { - const handlers = eventHandlers.get(message.event) ?? [] - for (const handler of handlers) { - try { - await handler(message.payload) - } catch (error) { - send({ type: 'log', level: 'error', message: toErrorMessage(error) }) - } - } - send({ type: 'eventAck', eventId: message.eventId }) - return - } - case 'hostResult': { - const pending = pendingHostCalls.get(message.callId) - if (!pending) { - return - } - pendingHostCalls.delete(message.callId) - if (message.ok) { - pending.resolve(message.value) - } else { - const error: PluginHostCallError = new Error(message.error ?? 'host call failed') - error.code = message.errorCode - pending.reject(error) - } - return - } - case 'shutdown': { - if (shuttingDown) { - return - } - shuttingDown = true - try { - await deactivate?.() - } catch (error) { - send({ type: 'log', level: 'error', message: toErrorMessage(error).slice(0, 8192) }) - } - exit(0) - } - } - } catch (error) { - // Failed activation cannot become ready; report and exit without waiting for the parent timeout. - send({ type: 'fatal', error: toErrorMessage(error) }) - exit(1) - } - } - } -} diff --git a/src/main/plugins/plugin-image-artifact-validation.test.ts b/src/main/plugins/plugin-image-artifact-validation.test.ts new file mode 100644 index 00000000000..6fa867d4e5b --- /dev/null +++ b/src/main/plugins/plugin-image-artifact-validation.test.ts @@ -0,0 +1,165 @@ +import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { pluginManifestSchema } from '../../shared/plugins/manifest/plugin-manifest' +import { PLUGIN_MANIFEST_V2_SINCE } from '../../shared/plugins/manifest/plugin-schema-field-docs' +import { + readPluginNewFileTemplate, + validatePluginInstallContent +} from './plugin-artifact-validation' + +const roots: string[] = [] +const be32 = (n: number) => [n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255] + +function png(width: number, height: number): Buffer { + return Buffer.from([ + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, + 0, + 0, + 0, + 13, + ...Buffer.from('IHDR'), + ...be32(width), + ...be32(height), + 8, + 6, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ...Buffer.from('IEND'), + 0, + 0, + 0, + 0 + ]) +} + +async function pluginWith(files: Record, fields: Record) { + const root = await mkdtemp(join(tmpdir(), 'orca-plugin-images-')) + roots.push(root) + for (const [path, bytes] of Object.entries(files)) { + await mkdir(join(root, path, '..'), { recursive: true }) + await writeFile(join(root, path), bytes) + } + const manifest = pluginManifestSchema.parse({ + manifestVersion: 2, + execution: 'data', + id: 'demo', + publisher: 'acme', + name: 'Demo', + version: '1.0.0', + engines: { orca: `>=${PLUGIN_MANIFEST_V2_SINCE}` }, + ...fields + }) + return { root, manifest } +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('plugin image artifacts', () => { + it('accepts images that meet their rules', async () => { + const { root, manifest } = await pluginWith( + { 'icon.png': png(512, 512), 'cover.png': png(1600, 1000), 'shot.png': png(1280, 800) }, + { + icon: 'icon.png', + cover: { path: 'cover.png', alt: 'The theme' }, + screenshots: [{ path: 'shot.png', alt: 'The editor' }] + } + ) + await expect(validatePluginInstallContent(root, manifest)).resolves.toEqual({ ok: true }) + }) + + it.each([ + [ + 'an SVG icon', + { 'icon.svg': Buffer.from('') }, + { icon: 'icon.svg' }, + 'icon icon.svg: must be a PNG, JPEG or WebP image' + ], + [ + 'a small cover', + { 'cover.png': png(640, 400) }, + { cover: { path: 'cover.png', alt: 'x' } }, + 'cover cover.png: cover must be at least 800×500' + ], + [ + 'a non-square icon', + { 'icon.png': png(512, 256) }, + { icon: 'icon.png' }, + 'icon icon.png: icon must be square' + ] + ])('refuses %s', async (_label, files, fields, error) => { + const { root, manifest } = await pluginWith(files, fields) + await expect(validatePluginInstallContent(root, manifest)).resolves.toEqual({ + ok: false, + error + }) + }) + + it('requires alt text and caps screenshots', () => { + const base = { + manifestVersion: 2, + execution: 'data', + id: 'demo', + publisher: 'acme', + name: 'Demo', + version: '1.0.0', + engines: { orca: `>=${PLUGIN_MANIFEST_V2_SINCE}` } + } + expect(pluginManifestSchema.safeParse({ ...base, cover: { path: 'c.png' } }).success).toBe( + false + ) + expect( + pluginManifestSchema.safeParse({ + ...base, + screenshots: Array.from({ length: 7 }, (_, i) => ({ path: `s${i}.png`, alt: 'x' })) + }).success + ).toBe(false) + }) +}) + +describe('new-file and editor artifacts', () => { + it('holds editor and new-file icons to the plugin icon rules', async () => { + const { root, manifest } = await pluginWith( + { 'tab.png': png(256, 128) }, + { contributes: { newFiles: [{ id: 'a', title: 'New A', extension: '.a', icon: 'tab.png' }] } } + ) + expect(await validatePluginInstallContent(root, manifest)).toEqual({ + ok: false, + error: 'icon tab.png: icon must be square' + }) + }) + + it('reads a UTF-8 template verbatim and refuses other bytes', async () => { + const scene = '{"type":"excalidraw","elements":[]}\n' + const { root, manifest } = await pluginWith( + { 'empty.a': Buffer.from(scene), 'bad.a': Buffer.from([0xff, 0xfe, 0x00]) }, + { + contributes: { + newFiles: [{ id: 'a', title: 'New A', extension: '.a', template: 'empty.a' }] + } + } + ) + expect(await validatePluginInstallContent(root, manifest)).toEqual({ ok: true }) + await expect(readPluginNewFileTemplate(root, 'empty.a')).resolves.toBe(scene) + await expect(readPluginNewFileTemplate(root, 'bad.a')).rejects.toThrow('is not UTF-8 text') + }) +}) diff --git a/src/main/plugins/plugin-image-artifact-validation.ts b/src/main/plugins/plugin-image-artifact-validation.ts new file mode 100644 index 00000000000..cc0930e57e2 --- /dev/null +++ b/src/main/plugins/plugin-image-artifact-validation.ts @@ -0,0 +1,76 @@ +import type { PluginManifest } from '../../shared/plugins/manifest/plugin-manifest' +import { readPluginImageHeader } from '../../shared/plugins/images/plugin-image-format' +import { + PLUGIN_IMAGE_RULES, + PLUGIN_IMAGES_TOTAL_MAX_BYTES, + pluginImageRuleError, + type PluginImageKind +} from '../../shared/plugins/images/plugin-image-fields' + +/** The images a format 2 manifest declares, each with the URL slot it is served under and the + * rules it must meet. Editor and new-file icons follow the plugin icon's rules. */ +export function declaredPluginImages( + manifest: PluginManifest +): { kind: PluginImageKind; path: string; slot: string }[] { + if (manifest.manifestVersion !== 2) { + // Why: format 1 never limited image formats, and shipped plugins must keep loading. + return [] + } + const icon = (slot: string, path: string | undefined) => + path === undefined ? [] : [{ kind: 'icon' as const, path, slot }] + return [ + ...icon('icon', manifest.icon), + ...(manifest.cover + ? [{ kind: 'cover' as const, path: manifest.cover.path, slot: 'cover' }] + : []), + ...(manifest.screenshots ?? []).map((shot, index) => ({ + kind: 'screenshot' as const, + path: shot.path, + slot: `screenshot-${index}` + })), + ...manifest.contributes.customEditors.flatMap((editor, index) => + icon(`editor-${index}`, editor.icon) + ), + ...manifest.contributes.newFiles.flatMap((newFile, index) => + icon(`new-file-${index}`, newFile.icon) + ) + ] +} + +/** Checks each declared image by its header at the install boundary; nothing is decoded. */ +// Why the reader is passed in: the artifact validator owns contained reads and calls this module. +export async function validatePluginImageArtifacts( + rootDir: string, + manifest: PluginManifest, + readContainedBytes: (rootDir: string, path: string, maxBytes: number) => Promise +): Promise<{ ok: true } | { ok: false; error: string }> { + let total = 0 + for (const image of declaredPluginImages(manifest)) { + try { + const bytes = await readContainedBytes( + rootDir, + image.path, + PLUGIN_IMAGE_RULES[image.kind].maxBytes + ) + total += bytes.byteLength + const info = readPluginImageHeader(bytes) + const error = + typeof info === 'string' ? info : pluginImageRuleError(image.kind, info, bytes.byteLength) + if (error) { + throw new Error(error) + } + } catch (error) { + return { + ok: false, + error: `${image.kind} ${image.path}: ${error instanceof Error ? error.message : String(error)}` + } + } + } + if (total > PLUGIN_IMAGES_TOTAL_MAX_BYTES) { + return { + ok: false, + error: `images total more than ${PLUGIN_IMAGES_TOTAL_MAX_BYTES / 1024 / 1024} MB` + } + } + return { ok: true } +} diff --git a/src/main/plugins/plugin-image-cache.test.ts b/src/main/plugins/plugin-image-cache.test.ts new file mode 100644 index 00000000000..4f2d5f2e3e7 --- /dev/null +++ b/src/main/plugins/plugin-image-cache.test.ts @@ -0,0 +1,74 @@ +import { createHash } from 'node:crypto' +import * as fs from 'node:fs/promises' +import { mkdtemp, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { PluginImageCache } from './plugin-image-cache' + +const renameHook = vi.hoisted((): { before: null | (() => Promise) } => ({ before: null })) +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + rename: async (from: string, to: string) => { + await renameHook.before?.() + return actual.rename(from, to) + } + } +}) + +const be32 = (n: number) => [n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255] +function png(width: number, height: number): Buffer { + const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] + const ihdr = [0, 0, 0, 13, ...Buffer.from('IHDR'), ...be32(width), ...be32(height), 8, 6, 0, 0, 0] + return Buffer.from([...signature, ...ihdr, 0, 0, 0, 0]) +} +const sha = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex') + +const roots: string[] = [] +async function temp(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-plugin-image-cache-')) + roots.push(root) + return root +} + +afterEach(async () => { + renameHook.before = null + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('PluginImageCache.put', () => { + it('lets concurrent saves of the same image all succeed', async () => { + const dir = await temp() + const cache = new PluginImageCache(dir) + const bytes = png(4, 4) + const results = await Promise.all(Array.from({ length: 8 }, () => cache.put(bytes))) + expect(results).toEqual(Array.from({ length: 8 }, () => sha(bytes))) + expect(await readdir(dir)).toEqual([sha(bytes)]) + expect((await cache.get(sha(bytes)))?.bytes.equals(bytes)).toBe(true) + }) + + it('treats a rename failure as success when another writer already produced the file', async () => { + const dir = await temp() + const cache = new PluginImageCache(dir) + const bytes = png(2, 2) + renameHook.before = async () => { + renameHook.before = null + await fs.writeFile(join(dir, sha(bytes)), bytes) + throw Object.assign(new Error('rename failed'), { code: 'EPERM' }) + } + await expect(cache.put(bytes)).resolves.toBe(sha(bytes)) + expect(await readdir(dir)).toEqual([sha(bytes)]) + }) + + it('still rejects when the rename fails and no valid file exists', async () => { + const dir = await temp() + const cache = new PluginImageCache(dir) + renameHook.before = async () => { + throw Object.assign(new Error('rename failed'), { code: 'ENOENT' }) + } + await expect(cache.put(png(2, 2))).rejects.toThrow('rename failed') + expect(await readdir(dir)).toEqual([]) + }) +}) diff --git a/src/main/plugins/plugin-image-cache.ts b/src/main/plugins/plugin-image-cache.ts new file mode 100644 index 00000000000..c46a4213f92 --- /dev/null +++ b/src/main/plugins/plugin-image-cache.ts @@ -0,0 +1,66 @@ +import { createHash, randomUUID } from 'node:crypto' +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { + PLUGIN_IMAGE_MIME, + readPluginImageHeader +} from '../../shared/plugins/images/plugin-image-format' + +const MAX_CACHED_IMAGE_BYTES = 2 * 1024 * 1024 + +export function sha256Hex(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex') +} + +/** Verified plugin and catalog images, addressed by SHA-256 so a name can never change meaning. */ +export class PluginImageCache { + constructor(private readonly directory: string) {} + + async put(bytes: Uint8Array): Promise { + if ( + bytes.byteLength > MAX_CACHED_IMAGE_BYTES || + typeof readPluginImageHeader(bytes) === 'string' + ) { + return null + } + const sha256 = sha256Hex(bytes) + await mkdir(this.directory, { recursive: true }) + const target = join(this.directory, sha256) + if (await this.holds(target, sha256)) { + return sha256 + } + // Unique per write: concurrent saves of the same image must not share a temp file. + const temporary = `${target}.${randomUUID()}.tmp` + try { + await writeFile(temporary, bytes) + await rename(temporary, target) + } catch (error) { + // Content-addressed: a concurrent writer that already produced the file is a success + // (Windows rejects renaming over a file another handle has open). + if (!(await this.holds(target, sha256))) { + throw error + } + } finally { + await rm(temporary, { force: true }).catch(() => undefined) + } + return sha256 + } + + private async holds(target: string, sha256: string): Promise { + const existing = await readFile(target).catch(() => null) + return existing !== null && sha256Hex(existing) === sha256 + } + + /** The bytes and MIME type for a hash, re-verified on every read. */ + async get(sha256: string): Promise<{ bytes: Buffer; mimeType: string } | null> { + if (!/^[0-9a-f]{64}$/.test(sha256)) { + return null + } + const bytes = await readFile(join(this.directory, sha256)).catch(() => null) + if (!bytes || sha256Hex(bytes) !== sha256) { + return null + } + const info = readPluginImageHeader(bytes) + return typeof info === 'string' ? null : { bytes, mimeType: PLUGIN_IMAGE_MIME[info.format] } + } +} diff --git a/src/main/plugins/plugin-image-protocol.test.ts b/src/main/plugins/plugin-image-protocol.test.ts new file mode 100644 index 00000000000..25c2c22b478 --- /dev/null +++ b/src/main/plugins/plugin-image-protocol.test.ts @@ -0,0 +1,233 @@ +import { createHash } from 'node:crypto' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { pluginManifestSchema } from '../../shared/plugins/manifest/plugin-manifest' +import { + parsePluginImageUrl, + pluginInstalledImageUrl, + pluginShaImageUrl +} from '../../shared/plugins/images/plugin-image-url' +import { PLUGIN_MANIFEST_V2_SINCE } from '../../shared/plugins/manifest/plugin-schema-field-docs' +import { extractCatalogImages } from './catalog/plugin-catalog-image-extraction' +import { PluginImageCache } from './plugin-image-cache' + +vi.mock('electron', () => ({ protocol: { handle: vi.fn() } })) +const { createPluginImageRequestHandler } = await import('./plugin-image-protocol') + +const roots: string[] = [] +const be32 = (n: number) => [n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255] +function png(width: number, height: number): Buffer { + return Buffer.from([ + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, + 0, + 0, + 0, + 13, + ...Buffer.from('IHDR'), + ...be32(width), + ...be32(height), + 8, + 6, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ]) +} +const sha = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex') + +async function temp(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-plugin-image-')) + roots.push(root) + return root +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('plugin image URLs', () => { + it('round-trip and refuse anything else', () => { + const hash = 'a'.repeat(64) + expect(parsePluginImageUrl(pluginShaImageUrl(hash))).toEqual({ kind: 'sha', sha256: hash }) + expect(parsePluginImageUrl(pluginInstalledImageUrl('acme.demo', hash, 'screenshot-2'))).toEqual( + { + kind: 'installed', + pluginKey: 'acme.demo', + contentHash: hash, + slot: 'screenshot-2' + } + ) + expect(parsePluginImageUrl(pluginInstalledImageUrl('acme.demo', hash, 'new-file-3'))).toEqual({ + kind: 'installed', + pluginKey: 'acme.demo', + contentHash: hash, + slot: 'new-file-3' + }) + for (const url of [ + `orca-plugin-image://installed/acme.demo/${hash}/editor-x`, + 'orca-plugin-image://sha/../etc', + 'orca-plugin-image://installed/acme.demo/abc/icon', + 'orca-plugin-image://installed/acme.demo/dev/main', + 'https://example.com/a.png' + ]) { + expect(parsePluginImageUrl(url), url).toBeNull() + } + }) +}) + +describe('plugin image protocol', () => { + it('serves cached bytes by hash with nosniff, and installed images by slot', async () => { + const cache = new PluginImageCache(await temp()) + const cover = png(1600, 1000) + const hash = await cache.put(cover) + const pluginRoot = await temp() + await mkdir(join(pluginRoot, 'images'), { recursive: true }) + await writeFile(join(pluginRoot, 'images', 'icon.png'), png(256, 256)) + const manifest = pluginManifestSchema.parse({ + manifestVersion: 2, + execution: 'data', + id: 'demo', + publisher: 'acme', + name: 'Demo', + version: '1.0.0', + engines: { orca: `>=${PLUGIN_MANIFEST_V2_SINCE}` }, + icon: 'images/icon.png' + }) + const handler = createPluginImageRequestHandler({ + cache, + findValidPlugin: () => ({ + pluginKey: 'acme.demo', + rootDir: pluginRoot, + manifest, + consentFingerprint: 'x', + contentHash: 'b'.repeat(64), + isDev: false + }) + }) + const fromCache = await handler(new Request(pluginShaImageUrl(hash ?? ''))) + expect(fromCache.status).toBe(200) + expect(fromCache.headers.get('content-type')).toBe('image/png') + expect(fromCache.headers.get('x-content-type-options')).toBe('nosniff') + const icon = await handler( + new Request(pluginInstalledImageUrl('acme.demo', 'b'.repeat(64), 'icon')) + ) + expect(icon.status).toBe(200) + const stale = await handler( + new Request(pluginInstalledImageUrl('acme.demo', 'c'.repeat(64), 'icon')) + ) + expect(stale.status).toBe(404) + const missing = await handler(new Request(pluginShaImageUrl('d'.repeat(64)))) + expect(missing.status).toBe(404) + }) + + it('serves custom editor and new-file icons by slot, under the plugin icon rules', async () => { + const pluginRoot = await temp() + await mkdir(join(pluginRoot, 'images'), { recursive: true }) + await writeFile(join(pluginRoot, 'images', 'tab.png'), png(128, 128)) + await writeFile(join(pluginRoot, 'images', 'wide.png'), png(256, 128)) + const manifest = pluginManifestSchema.parse({ + manifestVersion: 2, + execution: 'sandboxed-ui', + id: 'draw', + publisher: 'acme', + name: 'Draw', + version: '1.0.0', + engines: { orca: `>=${PLUGIN_MANIFEST_V2_SINCE}` }, + contributes: { + customEditors: [ + { + id: 'draw', + title: 'Draw', + extensions: ['.draw'], + entry: 'e.html', + icon: 'images/tab.png' + } + ], + newFiles: [ + { id: 'drawing', title: 'New drawing', extension: '.draw', icon: 'images/wide.png' } + ] + } + }) + const handler = createPluginImageRequestHandler({ + cache: new PluginImageCache(await temp()), + findValidPlugin: () => ({ + pluginKey: 'acme.draw', + rootDir: pluginRoot, + manifest, + consentFingerprint: 'x', + contentHash: 'b'.repeat(64), + isDev: false + }) + }) + const url = (slot: string) => pluginInstalledImageUrl('acme.draw', 'b'.repeat(64), slot) + expect((await handler(new Request(url('editor-0')))).status).toBe(200) + // Not square, so refused like a plugin icon would be. + expect((await handler(new Request(url('new-file-0')))).status).toBe(404) + expect((await handler(new Request(url('editor-1')))).status).toBe(404) + }) + + it('refuses to cache anything that is not a static raster image', async () => { + const cache = new PluginImageCache(await temp()) + await expect( + cache.put(Buffer.from('')) + ).resolves.toBeNull() + }) +}) + +describe('extractCatalogImages', () => { + it('keeps images whose bytes match and drops the rest', async () => { + const cover = png(1600, 1000) + const files: Record = { 'a/cover.png': cover, 'a/wrong.png': png(1600, 1000) } + const image = (path: string, hash: string) => ({ + path, + alt: 'x', + sha256: hash, + width: 1600, + height: 1000 + }) + const release = { + version: '1.0.0', + commit: 'c'.repeat(40), + contentHash: 'f'.repeat(64), + execution: 'data' as const, + minimumOrca: '1.4.0', + types: ['app-theme' as const], + images: { + cover: image('a/cover.png', sha(cover)), + screenshots: [image('a/wrong.png', 'e'.repeat(64)), image('a/missing.png', 'd'.repeat(64))] + } + } + const result = await extractCatalogImages( + { + name: 'Acme', + owner: 'acme', + plugins: [ + { + id: 'acme.demo', + source: { kind: 'git', url: 'https://github.com/acme/demo.git', ref: 'main' }, + categories: [], + releases: [release] + } + ] + }, + async (path) => files[path] ?? null + ) + expect(result.images).toEqual([cover]) + expect(result.marketplace.plugins[0]?.releases?.[0]?.images).toEqual({ + cover: release.images.cover + }) + }) +}) diff --git a/src/main/plugins/plugin-image-protocol.ts b/src/main/plugins/plugin-image-protocol.ts new file mode 100644 index 00000000000..92bc4ae4345 --- /dev/null +++ b/src/main/plugins/plugin-image-protocol.ts @@ -0,0 +1,91 @@ +import { protocol } from 'electron' +import { + PLUGIN_IMAGE_MIME, + readPluginImageHeader +} from '../../shared/plugins/images/plugin-image-format' +import { + PLUGIN_IMAGE_RULES, + pluginImageRuleError +} from '../../shared/plugins/images/plugin-image-fields' +import { + PLUGIN_IMAGE_SCHEME, + parsePluginImageUrl +} from '../../shared/plugins/images/plugin-image-url' +import { readContainedPluginArtifactBytes } from './plugin-artifact-validation' +import type { ValidDiscoveredPlugin } from './plugin-discovery' +import { declaredPluginImages } from './plugin-image-artifact-validation' +import type { PluginImageCache } from './plugin-image-cache' + +export type PluginImageProtocolDependencies = { + cache: PluginImageCache + findValidPlugin: (pluginKey: string) => ValidDiscoveredPlugin | null +} + +type ServedImage = { bytes: Buffer; mimeType: string } + +/** An installed plugin's declared image, re-checked on every read like at install. */ +async function readInstalledImage( + plugin: ValidDiscoveredPlugin, + contentHash: string, + slot: string +): Promise { + if (contentHash !== (plugin.isDev ? 'dev' : plugin.contentHash)) { + return null + } + const image = declaredPluginImages(plugin.manifest).find((candidate) => candidate.slot === slot) + if (!image) { + return null + } + const bytes = await readContainedPluginArtifactBytes( + plugin.rootDir, + image.path, + PLUGIN_IMAGE_RULES[image.kind].maxBytes + ) + const info = readPluginImageHeader(bytes) + if (typeof info === 'string' || pluginImageRuleError(image.kind, info, bytes.byteLength)) { + return null + } + return { bytes, mimeType: PLUGIN_IMAGE_MIME[info.format] } +} + +export function createPluginImageRequestHandler( + dependencies: PluginImageProtocolDependencies +): (request: Request) => Promise { + return async (request) => { + if (request.method !== 'GET' && request.method !== 'HEAD') { + return new Response(null, { status: 405 }) + } + const reference = parsePluginImageUrl(request.url) + try { + let image: ServedImage | null = null + if (reference?.kind === 'sha') { + image = await dependencies.cache.get(reference.sha256) + } else if (reference?.kind === 'installed') { + const plugin = dependencies.findValidPlugin(reference.pluginKey) + image = plugin + ? await readInstalledImage(plugin, reference.contentHash, reference.slot) + : null + } + if (!image) { + return new Response(null, { status: 404 }) + } + return new Response(request.method === 'HEAD' ? null : new Uint8Array(image.bytes), { + headers: { + 'Content-Type': image.mimeType, + 'Content-Length': String(image.bytes.byteLength), + // Why immutable is safe: every URL names exact bytes (a hash or an install hash). + 'Cache-Control': 'private, max-age=31536000, immutable', + 'X-Content-Type-Options': 'nosniff' + } + }) + } catch { + return new Response(null, { status: 404 }) + } + } +} + +export function installPluginImageProtocolHandler( + dependencies: PluginImageProtocolDependencies +): void { + protocol.handle(PLUGIN_IMAGE_SCHEME, createPluginImageRequestHandler(dependencies)) +} diff --git a/src/main/plugins/plugin-image-scheme.ts b/src/main/plugins/plugin-image-scheme.ts new file mode 100644 index 00000000000..0af1cc35514 --- /dev/null +++ b/src/main/plugins/plugin-image-scheme.ts @@ -0,0 +1,8 @@ +import { PLUGIN_IMAGE_SCHEME } from '../../shared/plugins/images/plugin-image-url' + +/** Declared before app ready (Electron freezes privileged schemes then); kept apart from the + * handler so startup does not load the plugin module graph. */ +export const PLUGIN_IMAGE_CUSTOM_SCHEME: Electron.CustomScheme = { + scheme: PLUGIN_IMAGE_SCHEME, + privileges: { standard: true, secure: true, stream: true } +} diff --git a/src/main/plugins/plugin-install-trust.ts b/src/main/plugins/plugin-install-trust.ts deleted file mode 100644 index 33b46614af6..00000000000 --- a/src/main/plugins/plugin-install-trust.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { PluginInstallSource } from '../../shared/plugins/plugin-install-lockfile' -import { - isOfficialOrganizationGitSource, - isOfficialPluginIdentity, - isReservedPluginIdentity -} from '../../shared/plugins/plugin-marketplace' - -export function pluginInstallTrustError( - pluginKey: string, - source: PluginInstallSource -): string | null { - if (source.kind === 'bundled') { - return source.bundleId === pluginKey && isOfficialPluginIdentity(pluginKey) - ? null - : 'bundled plugins must use an official stablyai.orca-* identity' - } - if (!isReservedPluginIdentity(pluginKey)) { - return null - } - if (source.kind === 'local-path') { - return `reserved plugin identity ${pluginKey} cannot be installed from a local path` - } - const url = source.kind === 'git' ? source.url : source.plugin.url - return isOfficialOrganizationGitSource(url) - ? null - : `reserved plugin identity ${pluginKey} must resolve to the stablyai organization` -} diff --git a/src/main/plugins/plugin-instructional-content-integrity.ts b/src/main/plugins/plugin-instructional-content-integrity.ts deleted file mode 100644 index 5ef287c7560..00000000000 --- a/src/main/plugins/plugin-instructional-content-integrity.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { hasInstructionalPluginContributions } from '../../shared/plugins/plugin-consent-fingerprint' -import { hashPluginTree } from './plugin-content-hash' -import { pluginTreeHashMatches } from './plugin-content-integrity' -import type { ValidDiscoveredPlugin } from './plugin-discovery' - -/** Reverify instructional bytes on every read; discovery-time caches cannot prove they still match consent. */ -export async function verifyInstructionalPluginContent( - plugin: ValidDiscoveredPlugin -): Promise { - if (!hasInstructionalPluginContributions(plugin.manifest)) { - return - } - if (!plugin.consentContentHash) { - throw new Error(`plugin ${plugin.pluginKey} has no instructional consent content identity`) - } - const actual = await hashPluginTree(plugin.rootDir) - if (!actual.ok) { - throw new Error( - `plugin ${plugin.pluginKey} instructional content is unreadable: ${actual.error}` - ) - } - if (!pluginTreeHashMatches(actual.hash, plugin.consentContentHash)) { - throw new Error( - `plugin ${plugin.pluginKey} instructional content changed since it was reviewed` - ) - } -} diff --git a/src/main/plugins/plugin-launch-content.test.ts b/src/main/plugins/plugin-launch-content.test.ts index 692a987555c..e23c4efc4d3 100644 --- a/src/main/plugins/plugin-launch-content.test.ts +++ b/src/main/plugins/plugin-launch-content.test.ts @@ -2,13 +2,14 @@ import { cp, mkdtemp, readFile, readdir, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' +import { z } from 'zod' import { isOfficialOrganizationGitSource, - isOfficialPluginIdentity, + hasOfficialPluginPublisher, pluginMarketplaceSchema -} from '../../shared/plugins/plugin-marketplace' +} from '../../shared/plugins/catalog/plugin-marketplace' import { bootstrapBundledPlugins, resolveBundledPluginRoot } from './plugin-bundled-bootstrap' -import { inspectPluginInstallTree } from './plugin-install-staging' +import { inspectPluginInstallTree } from './installation/plugin-install-staging' const launchRoot = join(process.cwd(), 'resources', 'plugins', 'launch') const temporaryRoots: string[] = [] @@ -17,6 +18,13 @@ async function readJson(path: string): Promise { return JSON.parse(await readFile(path, 'utf8')) } +async function readBundledPluginKeys(): Promise { + const index = z + .object({ plugins: z.array(z.object({ pluginKey: z.string() })) }) + .parse(await readJson(join(launchRoot, 'bundled-plugins.json'))) + return index.plugins.map((plugin) => plugin.pluginKey) +} + afterEach(async () => { await Promise.all( temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })) @@ -29,16 +37,15 @@ describe('Phase 1 launch plugin content', () => { await readJson(join(launchRoot, 'orca-marketplace.json')) ) expect(marketplace.plugins.map((plugin) => plugin.id).sort()).toEqual([ - 'stablyai.orca-multipass-recipes', - 'stablyai.orca-navigation-shortcuts', - 'stablyai.orca-portuguese' + 'stablyai.orca-multipass-recipes' ]) expect( marketplace.plugins.filter( (plugin) => - isOfficialPluginIdentity(plugin.id) && isOfficialOrganizationGitSource(plugin.source.url) + hasOfficialPluginPublisher(plugin.id) && + isOfficialOrganizationGitSource(plugin.source.url) ).length - ).toBeGreaterThanOrEqual(2) + ).toBe(marketplace.plugins.length) const localPluginDirectories = (await readdir(launchRoot, { withFileTypes: true })) .filter((entry) => entry.isDirectory()) @@ -62,17 +69,11 @@ describe('Phase 1 launch plugin content', () => { continue } const contributes = inspection.manifest.contributes - if (contributes.languagePacks.length > 0) { - contributionKinds.add('language') - } if (contributes.vmRecipes.length > 0) { contributionKinds.add('vm-recipe') } - if (contributes.commands.length > 0 && contributes.keybindings.length > 0) { - contributionKinds.add('command-keybinding') - } } - expect(contributionKinds).toEqual(new Set(['language', 'vm-recipe', 'command-keybinding'])) + expect(contributionKinds).toEqual(new Set(['vm-recipe'])) }) it('publishes every bundled pack only when its release hash matches exact bytes', async () => { @@ -86,8 +87,8 @@ describe('Phase 1 launch plugin content', () => { }) expect(result.errors).toEqual([]) - expect(result.installed.length).toBeGreaterThanOrEqual(1) - expect(result.installed.every(isOfficialPluginIdentity)).toBe(true) + expect(result.installed).toEqual(await readBundledPluginKeys()) + expect(result.installed.every(hasOfficialPluginPublisher)).toBe(true) }) it('boots release-indexed content from the packaged resources layout', async () => { @@ -108,6 +109,6 @@ describe('Phase 1 launch plugin content', () => { }) expect(result.errors).toEqual([]) - expect(result.installed).toEqual(['stablyai.orca-navigation-shortcuts']) + expect(result.installed).toEqual(await readBundledPluginKeys()) }) }) diff --git a/src/main/plugins/plugin-list-projection.test.ts b/src/main/plugins/plugin-list-projection.test.ts index 36904cfa262..72717b2fe0e 100644 --- a/src/main/plugins/plugin-list-projection.test.ts +++ b/src/main/plugins/plugin-list-projection.test.ts @@ -1,16 +1,39 @@ +import type * as FsPromises from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it } from 'vitest' -import { emptyPluginLockfile } from '../../shared/plugins/plugin-install-lockfile' -import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' +import { describe, expect, it, vi } from 'vitest' +import { emptyPluginLockfile } from '../../shared/plugins/installation/plugin-install-lockfile' +import { pluginManifestSchema } from '../../shared/plugins/manifest/plugin-manifest' import type { InvalidDiscoveredPlugin, ValidDiscoveredPlugin } from './plugin-discovery' import { buildPluginList } from './plugin-list-projection' import type { PluginService } from './plugin-service' +// Why spy on every fs entry point: the list is rebuilt on every plugin change and at startup, +// so it must stay pure projection; per-plugin reads belong behind on-demand IPC. +const fsCalls = vi.hoisted(() => ({ count: 0 })) +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + const counted = + (fn: (...args: Args) => Result) => + (...args: Args): Result => { + fsCalls.count += 1 + return fn(...args) + } + return { + ...actual, + readdir: counted(actual.readdir), + readFile: counted(actual.readFile), + stat: counted(actual.stat), + lstat: counted(actual.lstat), + open: counted(actual.open), + opendir: counted(actual.opendir) + } +}) + const manifest = pluginManifestSchema.parse({ manifestVersion: 1, id: 'demo', - publisher: 'orca-samples', + publisher: 'samples', name: 'Demo', version: '1.0.0', engines: { orca: '>=1.0.0' }, @@ -44,10 +67,50 @@ function serviceWith( } as unknown as PluginService } +describe('buildPluginList contribution icons', () => { + it('projects editor and new-file icons as installed image URLs, and new files without paths', async () => { + const drawManifest = pluginManifestSchema.parse({ + manifestVersion: 2, + execution: 'sandboxed-ui', + id: 'draw', + publisher: 'samples', + name: 'Draw', + version: '1.0.0', + engines: { orca: '>=1.4.245' }, + contributes: { + customEditors: [ + { id: 'draw', title: 'Draw', extensions: ['.draw'], entry: 'e.html', icon: 'tab.png' } + ], + newFiles: [ + { id: 'drawing', title: 'New drawing', extension: '.draw', template: 'empty.draw' } + ] + } + }) + const hash = 'c'.repeat(64) + const [entry] = await buildPluginList( + serviceWith({ + pluginKey: 'samples.draw', + rootDir: join(tmpdir(), 'plugins', 'draw'), + manifest: drawManifest, + consentFingerprint: 'sha256-current', + consentContentHash: hash, + contentHash: hash, + isDev: false + }), + emptyPluginLockfile() + ) + expect(entry?.customEditors?.[0]?.icon).toBe( + `orca-plugin-image://installed/samples.draw/${hash}/editor-0` + ) + // Why no template path: clients ask main for the text, never for a file. + expect(entry?.newFiles).toEqual([{ id: 'drawing', title: 'New drawing', extension: '.draw' }]) + }) +}) + describe('buildPluginList consent identity', () => { it('projects the exact current fingerprint for an optimistic consent write', async () => { const plugin: ValidDiscoveredPlugin = { - pluginKey: 'orca-samples.demo', + pluginKey: 'samples.demo', rootDir: join(tmpdir(), 'plugins', 'demo'), manifest, consentFingerprint: 'sha256-current', @@ -58,13 +121,15 @@ describe('buildPluginList consent identity', () => { expect((await buildPluginList(serviceWith(plugin), emptyPluginLockfile()))[0]).toMatchObject({ pluginKey: plugin.pluginKey, consentFingerprint: 'sha256-current', - status: 'pending' + status: 'pending', + // Why declarative: a capability makes it more than content, with no worker in sight. + execution: 'declarative' }) }) it('projects supervised backoff as restarting instead of running', async () => { const plugin: ValidDiscoveredPlugin = { - pluginKey: 'orca-samples.demo', + pluginKey: 'samples.demo', rootDir: join(tmpdir(), 'plugins', 'demo'), manifest, consentFingerprint: 'sha256-current', @@ -87,7 +152,7 @@ describe('buildPluginList consent identity', () => { it('does not attribute a shadowing dev plugin to the installed source', async () => { const plugin: ValidDiscoveredPlugin = { - pluginKey: 'orca-samples.demo', + pluginKey: 'samples.demo', rootDir: join(tmpdir(), 'development', 'demo'), manifest, consentFingerprint: 'sha256-current', @@ -129,13 +194,13 @@ describe('buildPluginList consent identity', () => { expect(JSON.stringify(projected)).not.toContain(invalid.rootDir) }) - it('projects exact VM lifecycle commands for instructional consent', async () => { + it('projects exact VM lifecycle commands for declarative consent', async () => { const recipeManifest = pluginManifestSchema.parse({ ...manifest, contributes: { vmRecipes: [{ path: 'recipes/cloud.json' }] } }) const plugin: ValidDiscoveredPlugin = { - pluginKey: 'orca-samples.demo', + pluginKey: 'samples.demo', rootDir: join(tmpdir(), 'plugins', 'demo'), manifest: recipeManifest, consentFingerprint: 'sha256-current', @@ -179,12 +244,14 @@ describe('buildPluginList consent identity', () => { const commandManifest = pluginManifestSchema.parse({ ...manifest, contributes: { - commands: [{ id: 'tasks', title: 'Open Tasks', context: 'worktree', action: 'view.tasks' }], + commands: [ + { id: 'tasks', title: 'Open Tasks', context: 'workspace', action: 'view.tasks' } + ], keybindings: [{ command: 'tasks', key: 'mod+alt+t' }] } }) const plugin: ValidDiscoveredPlugin = { - pluginKey: 'orca-samples.demo', + pluginKey: 'samples.demo', rootDir: join(tmpdir(), 'plugins', 'demo'), manifest: commandManifest, consentFingerprint: 'sha256-current', @@ -193,6 +260,9 @@ describe('buildPluginList consent identity', () => { isDev: true } + const [entry] = await buildPluginList(serviceWith(plugin), emptyPluginLockfile()) + // Why: a worker-less commands plugin still runs instructions, so the page must not say content only. + expect(entry).toMatchObject({ hasWorker: false, execution: 'declarative' }) expect( ( await buildPluginList( @@ -202,9 +272,9 @@ describe('buildPluginList consent identity', () => { pluginKey: plugin.pluginKey, id: 'tasks', title: 'Open Tasks', - context: 'worktree', + context: 'workspace', handler: { type: 'built-in', action: 'view.tasks' }, - keybindings: [{ key: 'Mod+Alt+T', when: 'worktree' }] + keybindings: [{ key: 'Mod+Alt+T', when: 'workspace' }] } ] }), @@ -215,10 +285,76 @@ describe('buildPluginList consent identity', () => { { id: 'tasks', title: 'Open Tasks', - context: 'worktree', + context: 'workspace', handler: { type: 'built-in', action: 'view.tasks' }, - keybindings: [{ key: 'Mod+Alt+T', when: 'worktree' }] + keybindings: [{ key: 'Mod+Alt+T', when: 'workspace' }] } ]) }) }) + +describe('buildPluginList I/O', () => { + it('builds the list for installed plugins without reading the plugin directories', async () => { + const plugin: ValidDiscoveredPlugin = { + pluginKey: 'samples.demo', + rootDir: join(tmpdir(), 'plugins', 'samples.demo', 'a'.repeat(64)), + manifest, + consentFingerprint: 'sha256-current', + contentHash: 'a'.repeat(64), + isDev: false + } + const lock = emptyPluginLockfile() + lock.plugins[plugin.pluginKey] = { + pluginKey: plugin.pluginKey, + version: '1.0.0', + source: { kind: 'git', url: 'https://github.com/samples/demo.git', ref: 'main' }, + resolvedCommit: 'c'.repeat(40), + contentHash: 'a'.repeat(64), + consentFingerprint: 'sha256-current', + installedAt: 1 + } + fsCalls.count = 0 + + const [entry] = await buildPluginList(serviceWith(plugin), lock) + + expect(entry?.source?.kind).toBe('git') + expect(fsCalls.count).toBe(0) + }) + + it('reports the catalog ref a marketplace install came from', async () => { + const plugin: ValidDiscoveredPlugin = { + pluginKey: 'samples.demo', + rootDir: join(tmpdir(), 'plugins', 'samples.demo', 'a'.repeat(64)), + manifest, + consentFingerprint: 'sha256-current', + contentHash: 'a'.repeat(64), + isDev: false + } + const lock = emptyPluginLockfile() + lock.plugins[plugin.pluginKey] = { + pluginKey: plugin.pluginKey, + version: '1.0.0', + source: { + kind: 'marketplace', + marketplace: { + url: 'https://github.com/acme/catalog.git', + ref: 'stable', + resolvedCommit: 'f'.repeat(40) + }, + plugin: { url: 'https://github.com/samples/demo.git', ref: 'main' } + }, + resolvedCommit: 'c'.repeat(40), + contentHash: 'a'.repeat(64), + consentFingerprint: 'sha256-current', + installedAt: 1 + } + + const [entry] = await buildPluginList(serviceWith(plugin), lock) + + expect(entry?.source?.marketplace).toEqual({ + reference: 'https://github.com/acme/catalog.git', + ref: 'stable', + resolvedCommit: 'f'.repeat(40) + }) + }) +}) diff --git a/src/main/plugins/plugin-list-projection.ts b/src/main/plugins/plugin-list-projection.ts index 0362e9ff2a6..0a367d9d3c2 100644 --- a/src/main/plugins/plugin-list-projection.ts +++ b/src/main/plugins/plugin-list-projection.ts @@ -1,92 +1,43 @@ +import { PLUGIN_CAPABILITY_DESCRIPTIONS } from '../../shared/plugins/capabilities/plugin-capabilities' +import { needsReconsent } from '../../shared/plugins/approval/plugin-consent-state' +import { pluginPanelTabKey } from '../../shared/plugins/manifest/plugin-manifest' +import type { PluginLockfile } from '../../shared/plugins/installation/plugin-install-lockfile' +import { isInvalidDiscoveredPlugin, type DiscoveredPlugin } from './plugin-discovery' import { - PLUGIN_CAPABILITY_DESCRIPTIONS, - type PluginCapabilityKind -} from '../../shared/plugins/plugin-capabilities' -import { needsReconsent } from '../../shared/plugins/plugin-consent-state' -import { pluginPanelTabKey } from '../../shared/plugins/plugin-manifest' -import type { PluginLockfile } from '../../shared/plugins/plugin-install-lockfile' -import { isInvalidDiscoveredPlugin } from './plugin-discovery' + pluginInstalledImageUrl, + type PluginHostImages +} from '../../shared/plugins/images/plugin-image-url' +import { pluginInstalledHostImages } from './plugin-manifest-images' import type { PluginService } from './plugin-service' -import { listPluginVmRecipeCommands } from '../../shared/plugins/plugin-vm-recipe-artifact' -import type { PluginCommandAliasActionId } from '../../shared/plugins/plugin-command-actions' +import { listPluginVmRecipeCommands } from '../../shared/plugins/artifacts/plugin-vm-recipe-artifact' import { isOfficialMarketplaceGitSource, isOfficialOrganizationGitSource, - isOfficialPluginIdentity -} from '../../shared/plugins/plugin-marketplace' + pluginSourceLabel +} from '../../shared/plugins/catalog/plugin-marketplace' +import { pluginCatalogTypes } from '../../shared/plugins/catalog/plugin-catalog-types' import { mapWithConcurrency } from '../../shared/map-with-concurrency' +import type { PluginHostListEntry, PluginHostStatus } from '../../shared/plugins/plugin-list-entry' +import { customEditorContentKind } from '../../shared/plugins/contributions/plugin-custom-editor-contribution' +import { pluginExecutionProfile } from '../../shared/plugins/contributions/plugin-execution-profile' const PLUGIN_LIST_PROJECTION_CONCURRENCY = 4 -export type PluginListPanelEntry = { - id: string - title: string - icon?: string - tabKey: `plugin:${string}` -} - -export type PluginListStatus = - | 'running' - | 'restarting' - | 'idle' - | 'pending' - | 'disabled' - | 'errored' - | 'invalid' - -export type PluginListEntry = { - pluginKey: string - /** Opaque identity of the exact capabilities and worker tier shown for review. */ - consentFingerprint: string | null - name: string - version: string - publisher: string - description?: string - status: PluginListStatus - needsReconsent: boolean - error?: string - isDev: boolean - official: boolean - bundled: boolean - capabilities: { kind: PluginCapabilityKind; description: string }[] - panels: PluginListPanelEntry[] - commands: { - id: string - title: string - context: 'global' | 'worktree' - handler: { type: 'built-in'; action: PluginCommandAliasActionId } | { type: 'worker' } - keybindings: { key: string; when: 'global' | 'worktree' }[] - }[] - hasWorker: boolean - vmRecipes: { - id: string - name: string - description?: string - commands: { phase: 'create' | 'suspend' | 'resume' | 'destroy'; command: string }[] - }[] - restarts: number - blockedByKillList?: { reason: string; advisoryUrl?: string } - source?: { - kind: 'local-path' | 'git' | 'marketplace' | 'bundled' - reference: string - resolvedCommit: string | null - contentHash: string - marketplace?: { reference: string; resolvedCommit: string } - } -} +const withImages = (images: PluginHostImages | undefined) => (images ? { images } : {}) export async function buildPluginList( service: PluginService, - lock: PluginLockfile -): Promise { + lock: PluginLockfile, + discovered: readonly DiscoveredPlugin[] = service.getDiscovered() +): Promise { const consents = { pluginConsents: service.options.getPluginConsents(), disabledPlugins: service.options.getDisabledPlugins() } return mapWithConcurrency( - service.getDiscovered(), + discovered, PLUGIN_LIST_PROJECTION_CONCURRENCY, - async (plugin, index): Promise => { + async (plugin, index): Promise => { if (isInvalidDiscoveredPlugin(plugin)) { // Invalid dev paths may expose private absolute paths; use opaque identities for clients. const fallbackKey = plugin.pluginKey ?? `invalid-development-plugin-${index + 1}` @@ -104,6 +55,8 @@ export async function buildPluginList( bundled: false, capabilities: [], panels: [], + customEditors: [], + newFiles: [], commands: [], hasWorker: false, vmRecipes: [], @@ -114,7 +67,7 @@ export async function buildPluginList( const worker = service.workerState(plugin.pluginKey) const activationError = service.activationError(plugin.pluginKey) const killListEntry = service.options.getPluginKillListEntry?.(plugin.pluginKey) ?? null - let status: PluginListStatus + let status: PluginHostStatus if (activation === 'disabled') { status = 'disabled' } else if (activation === 'pending') { @@ -135,11 +88,15 @@ export async function buildPluginList( candidateLockEntry.contentHash === plugin.contentHash ? candidateLockEntry : undefined + // Why format 2 only: the image protocol serves no format 1 image, so a URL would 404. + const contributionIcon = (slot: string, path: string | undefined) => + path !== undefined && plugin.manifest.manifestVersion === 2 + ? { icon: pluginInstalledImageUrl(plugin.pluginKey, plugin.contentHash ?? 'dev', slot) } + : {} const bundled = lockEntry?.source.kind === 'bundled' const official = bundled || (lockEntry?.source.kind === 'marketplace' && - isOfficialPluginIdentity(plugin.pluginKey) && isOfficialMarketplaceGitSource(lockEntry.source.marketplace.url) && isOfficialOrganizationGitSource(lockEntry.source.plugin.url)) return { @@ -149,6 +106,14 @@ export async function buildPluginList( version: plugin.manifest.version, publisher: plugin.manifest.publisher, ...(plugin.manifest.description ? { description: plugin.manifest.description } : {}), + execution: pluginExecutionProfile(plugin.manifest), + types: pluginCatalogTypes(plugin.manifest), + ...withImages( + pluginInstalledHostImages(plugin.pluginKey, plugin.contentHash, plugin.manifest) + ), + ...(plugin.manifest.license ? { license: plugin.manifest.license } : {}), + ...(plugin.manifest.homepage ? { homepage: plugin.manifest.homepage } : {}), + ...(plugin.manifest.repository ? { repository: plugin.manifest.repository } : {}), status, needsReconsent: needsReconsent(plugin.pluginKey, plugin.consentFingerprint, consents), ...(status === 'errored' @@ -167,6 +132,21 @@ export async function buildPluginList( ...(panel.icon ? { icon: panel.icon } : {}), tabKey: pluginPanelTabKey(plugin.pluginKey, panel.id) })), + customEditors: plugin.manifest.contributes.customEditors.map((editor, index) => ({ + id: editor.id, + title: editor.title, + extensions: editor.extensions, + filenames: editor.filenames, + priority: editor.priority ?? 'default', + contentKind: customEditorContentKind(editor), + ...contributionIcon(`editor-${index}`, editor.icon) + })), + newFiles: plugin.manifest.contributes.newFiles.map((newFile, index) => ({ + id: newFile.id, + title: newFile.title, + extension: newFile.extension, + ...contributionIcon(`new-file-${index}`, newFile.icon) + })), commands: service.contentPacks.commands.preview(plugin.pluginKey).map((command) => ({ id: command.id, title: command.title, @@ -200,7 +180,7 @@ export async function buildPluginList( : lockEntry.source.kind === 'git' ? lockEntry.source.url : lockEntry.source.kind === 'marketplace' - ? lockEntry.source.plugin.url + ? pluginSourceLabel(lockEntry.source.plugin) : `bundled:${lockEntry.source.bundleId}`, resolvedCommit: lockEntry.resolvedCommit, contentHash: lockEntry.contentHash, @@ -208,6 +188,7 @@ export async function buildPluginList( ? { marketplace: { reference: lockEntry.source.marketplace.url, + ref: lockEntry.source.marketplace.ref, resolvedCommit: lockEntry.source.marketplace.resolvedCommit } } diff --git a/src/main/plugins/plugin-manifest-file.ts b/src/main/plugins/plugin-manifest-file.ts index d137f110fda..996c321e46c 100644 --- a/src/main/plugins/plugin-manifest-file.ts +++ b/src/main/plugins/plugin-manifest-file.ts @@ -1,6 +1,6 @@ import { createReadStream } from 'node:fs' import { join } from 'node:path' -import { PLUGIN_MANIFEST_FILENAME } from '../../shared/plugins/plugin-manifest' +import { PLUGIN_MANIFEST_FILENAME } from '../../shared/plugins/manifest/plugin-manifest' // Bound startup/preview metadata reads independently of artifact payload sizes. export const PLUGIN_MANIFEST_MAX_BYTES = 1024 * 1024 diff --git a/src/main/plugins/plugin-manifest-images.ts b/src/main/plugins/plugin-manifest-images.ts new file mode 100644 index 00000000000..1441c0b0095 --- /dev/null +++ b/src/main/plugins/plugin-manifest-images.ts @@ -0,0 +1,53 @@ +import type { PluginManifest } from '../../shared/plugins/manifest/plugin-manifest' +import { + pluginInstalledImageUrl, + type PluginHostImage, + type PluginHostImages +} from '../../shared/plugins/images/plugin-image-url' + +/** A format 2 manifest's images as the renderer loads them, given how each slot maps to a URL. */ +export function pluginManifestHostImages( + manifest: PluginManifest, + urlFor: (slot: string, path: string) => string | null +): PluginHostImages | undefined { + if (manifest.manifestVersion !== 2) { + return undefined + } + const image = (slot: string, path: string, alt: string): PluginHostImage | null => { + const url = urlFor(slot, path) + return url ? { url, alt } : null + } + const icon = manifest.icon ? image('icon', manifest.icon, manifest.name) : null + const cover = manifest.cover ? image('cover', manifest.cover.path, manifest.cover.alt) : null + const screenshots = (manifest.screenshots ?? []).flatMap((shot, index) => { + const host = image(`screenshot-${index}`, shot.path, shot.alt) + return host + ? [ + { + ...host, + ...(shot.caption ? { caption: shot.caption } : {}), + ...(shot.theme ? { theme: shot.theme } : {}) + } + ] + : [] + }) + if (!icon && !cover && screenshots.length === 0) { + return undefined + } + return { + ...(icon ? { icon } : {}), + ...(cover ? { cover } : {}), + ...(screenshots.length ? { screenshots } : {}) + } +} + +/** URLs for an installed plugin's images; no file is read until the renderer asks for one. */ +export function pluginInstalledHostImages( + pluginKey: string, + contentHash: string | null, + manifest: PluginManifest +): PluginHostImages | undefined { + return pluginManifestHostImages(manifest, (slot) => + pluginInstalledImageUrl(pluginKey, contentHash ?? 'dev', slot) + ) +} diff --git a/src/main/plugins/plugin-marketplace-fetch.ts b/src/main/plugins/plugin-marketplace-fetch.ts deleted file mode 100644 index b89b55fa591..00000000000 --- a/src/main/plugins/plugin-marketplace-fetch.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { tmpdir } from 'node:os' -import { - PLUGIN_MARKETPLACE_FILENAME, - PLUGIN_MARKETPLACE_V2_FILENAME, - pluginMarketplaceSchema, - pluginMarketplaceV2Schema, - type PluginMarketplace -} from '../../shared/plugins/plugin-marketplace' -import { gitExecFileAsyncBuffer } from '../git/runner' -import { readPluginGitCommit } from './plugin-git-repository' -import type { PluginMarketplaceRegisteredSource } from './plugin-marketplace-store' - -const MARKETPLACE_INDEX_MAX_BYTES = 16 * 1024 * 1024 -// Why its own cap: a catalog repository may hold far more than one plugin, and only its index is read. -const MARKETPLACE_DOWNLOAD_MAX_BYTES = 512 * 1024 * 1024 -const MARKETPLACE_GIT_TIMEOUT_MS = 120_000 - -export type PluginMarketplaceFetchResult = { - marketplaceCommit: string - marketplace: PluginMarketplace -} - -/** System Git retains private-repo SSH agent and credential-helper support. */ -export async function fetchPluginMarketplace( - source: PluginMarketplaceRegisteredSource -): Promise { - const { commit, value } = await readPluginGitCommit( - { - url: source.source.url, - ref: source.source.ref, - workingDirectory: tmpdir(), - maxDownloadBytes: MARKETPLACE_DOWNLOAD_MAX_BYTES - }, - readMarketplaceAtCommit - ) - return { marketplaceCommit: commit, marketplace: value } -} - -/** Reads only the catalog index blobs, so the rest of the repository is never written out. - * Prefers the release-pinned v2 catalog; repositories that only publish v1 still load. */ -async function readMarketplaceAtCommit( - repository: string, - commit: string -): Promise { - const options = { cwd: repository, timeout: MARKETPLACE_GIT_TIMEOUT_MS } - const listing = await gitExecFileAsyncBuffer( - [ - 'ls-tree', - '-l', - '-z', - commit, - '--', - PLUGIN_MARKETPLACE_V2_FILENAME, - PLUGIN_MARKETPLACE_FILENAME - ], - { ...options, maxBuffer: 64 * 1024 } - ) - const blobs = new Map() - for (const record of listing.stdout.toString('utf8').split('\0')) { - const match = /^100(?:644|755) blob ([0-9a-f]{40}) +([0-9]+)\t(.+)$/s.exec(record) - if (match) { - blobs.set(match[3]!, { object: match[1]!, size: Number(match[2]) }) - } - } - const filename = blobs.has(PLUGIN_MARKETPLACE_V2_FILENAME) - ? PLUGIN_MARKETPLACE_V2_FILENAME - : PLUGIN_MARKETPLACE_FILENAME - const blob = blobs.get(filename) - if (!blob) { - throw new Error(`${PLUGIN_MARKETPLACE_FILENAME} not found`) - } - if (blob.size > MARKETPLACE_INDEX_MAX_BYTES) { - throw new Error(`${filename} exceeds its size limit`) - } - const bytes = await gitExecFileAsyncBuffer(['cat-file', 'blob', blob.object], { - ...options, - maxBuffer: MARKETPLACE_INDEX_MAX_BYTES - }) - return parsePluginMarketplaceIndex(filename, bytes.stdout) -} - -export function parsePluginMarketplaceIndex(filename: string, bytes: Buffer): PluginMarketplace { - try { - const raw: unknown = JSON.parse(bytes.toString('utf8')) - return filename === PLUGIN_MARKETPLACE_V2_FILENAME - ? pluginMarketplaceV2Schema.parse(raw) - : pluginMarketplaceSchema.parse(raw) - } catch (error) { - throw new Error( - `invalid ${filename}: ${error instanceof Error ? error.message : String(error)}` - ) - } -} diff --git a/src/main/plugins/plugin-marketplace-installer.test.ts b/src/main/plugins/plugin-marketplace-installer.test.ts deleted file mode 100644 index 14f6cd86c98..00000000000 --- a/src/main/plugins/plugin-marketplace-installer.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { PluginCatalogRelease } from '../../shared/plugins/plugin-catalog-release' -import type { PluginMarketplace } from '../../shared/plugins/plugin-marketplace' -import { readPluginLockfile } from './plugin-install' -import { PluginMarketplaceInstaller } from './plugin-marketplace-installer' -import { PluginMarketplaceService } from './plugin-marketplace-service' - -const git = vi.hoisted( - (): { - checkout: ReturnType - version: string - publisher: string - id: string - commit: string - payload: string - v2: boolean - release: PluginCatalogRelease | undefined - } => ({ - checkout: vi.fn(), - version: '1.0.0', - publisher: 'community', - id: 'notes', - commit: 'a'.repeat(40), - payload: 'first', - v2: false, - release: undefined - }) -) - -vi.mock('./plugin-git-repository', () => ({ - checkoutPluginGitSource: git.checkout -})) - -const roots: string[] = [] - -async function tempRoot(): Promise { - const root = await mkdtemp(join(tmpdir(), 'orca-marketplace-installer-')) - roots.push(root) - return root -} - -function marketplace(): PluginMarketplace { - return { - name: 'Community', - owner: 'community', - plugins: [ - { - id: 'community.notes', - source: { - kind: 'git', - url: 'https://github.com/community/notes.git', - ref: 'stable' - }, - categories: ['productivity'], - ...(git.release ? { release: git.release } : {}) - } - ] - } -} - -async function writeCurrentPlugin(destination: string): Promise { - await mkdir(destination, { recursive: true }) - await writeFile( - join(destination, 'orca-plugin.json'), - JSON.stringify({ - ...(git.v2 - ? { - manifestVersion: 2, - execution: 'instructional', - contributes: { commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }] } - } - : { manifestVersion: 1 }), - id: git.id, - publisher: git.publisher, - name: 'Notes', - version: git.version, - engines: { orca: '>=1.0.0' }, - pluginApi: 1, - capabilities: [] - }) - ) - await writeFile(join(destination, 'payload.txt'), git.payload) -} - -async function setup(): Promise<{ - root: string - marketplace: PluginMarketplaceService - installer: PluginMarketplaceInstaller - sourceId: string -}> { - const root = await tempRoot() - const service = new PluginMarketplaceService({ - pluginsDataDir: join(root, 'plugins-data'), - fetcher: async () => ({ marketplaceCommit: 'f'.repeat(40), marketplace: marketplace() }) - }) - const added = await service.addSource({ - kind: 'git', - url: 'https://github.com/community/plugins.git', - ref: 'main' - }) - return { - root, - marketplace: service, - installer: new PluginMarketplaceInstaller({ - marketplace: service, - userDataPath: root, - hostVersion: '1.4.0' - }), - sourceId: added.id - } -} - -beforeEach(() => { - git.version = '1.0.0' - git.publisher = 'community' - git.id = 'notes' - git.commit = 'a'.repeat(40) - git.payload = 'first' - git.v2 = false - git.release = undefined - git.checkout.mockReset() - git.checkout.mockImplementation(async ({ destination }: { destination: string }) => { - await writeCurrentPlugin(destination) - return git.commit - }) -}) - -afterEach(async () => { - await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) -}) - -describe('PluginMarketplaceInstaller', () => { - it('previews exact validated bytes and records marketplace provenance on install', async () => { - const { root, installer, sourceId } = await setup() - - const preview = await installer.preview(sourceId, 'community.notes') - expect(preview).toMatchObject({ - pluginKey: 'community.notes', - resolvedCommit: 'a'.repeat(40), - marketplaceCommit: 'f'.repeat(40), - manifest: { version: '1.0.0' } - }) - const result = await installer.install(preview) - - if (!result.ok) { - throw new Error(result.error) - } - expect(result).toMatchObject({ ok: true, resolvedCommit: 'a'.repeat(40) }) - const lock = await readPluginLockfile(join(root, 'plugins')) - expect(lock.plugins['community.notes']?.source).toEqual({ - kind: 'marketplace', - marketplace: { - url: 'https://github.com/community/plugins.git', - ref: 'main', - resolvedCommit: 'f'.repeat(40) - }, - plugin: { - url: 'https://github.com/community/notes.git', - ref: 'stable' - } - }) - }) - - it('requires a fresh review when the plugin ref moves after preview', async () => { - const { root, installer, sourceId } = await setup() - const preview = await installer.preview(sourceId, 'community.notes') - git.commit = 'b'.repeat(40) - git.version = '2.0.0' - - await expect(installer.install(preview)).resolves.toEqual({ - ok: false, - error: 'plugin source changed after preview; review the update again' - }) - await expect(readFile(join(root, 'plugins', 'plugins.lock.json'))).rejects.toMatchObject({ - code: 'ENOENT' - }) - }) - - it('installs only the exact bytes a catalog release pins', async () => { - git.v2 = true - const unpinnedSetup = await setup() - const unpinned = await unpinnedSetup.installer.preview( - unpinnedSetup.sourceId, - 'community.notes' - ) - git.release = { - version: '1.0.0', - commit: git.commit, - contentHash: unpinned.contentHash, - execution: 'instructional', - minimumOrca: '1.0.0', - types: ['instructions'] - } - const { installer, sourceId } = await setup() - const preview = await installer.preview(sourceId, 'community.notes') - // The pinned commit is fetched directly, so the listed branch is display text only. - expect(git.checkout).toHaveBeenLastCalledWith(expect.objectContaining({ ref: git.commit })) - await expect(installer.install(preview)).resolves.toMatchObject({ ok: true }) - expect(git.checkout).toHaveBeenLastCalledWith(expect.objectContaining({ ref: git.commit })) - git.payload = 'tampered at the same commit' - - await expect(installer.install(preview)).resolves.toEqual({ - ok: false, - error: 'plugin bytes do not match the catalog release' - }) - await expect(installer.preview(sourceId, 'community.notes')).rejects.toThrow( - 'plugin bytes do not match the catalog release' - ) - }) - - it('rejects a source whose manifest identity differs from its listing', async () => { - const { installer, sourceId } = await setup() - git.publisher = 'attacker' - - await expect(installer.preview(sourceId, 'community.notes')).rejects.toThrow( - 'attacker.notes does not match marketplace listing community.notes' - ) - }) - - it('updates from recorded marketplace provenance and rolls back one immutable version', async () => { - const { root, installer, sourceId } = await setup() - const firstPreview = await installer.preview(sourceId, 'community.notes') - const firstInstall = await installer.install(firstPreview) - expect(firstInstall.ok).toBe(true) - if (!firstInstall.ok) { - return - } - - git.commit = 'b'.repeat(40) - git.version = '2.0.0' - git.payload = 'second' - const updatePreview = await installer.previewInstalledUpdate('community.notes') - expect(updatePreview).toMatchObject({ resolvedCommit: 'b'.repeat(40) }) - await expect(installer.install(updatePreview)).resolves.toMatchObject({ - ok: true, - version: '2.0.0' - }) - - await expect(installer.rollback('community.notes')).resolves.toMatchObject({ - ok: true, - version: '1.0.0', - contentHash: firstInstall.contentHash - }) - const lock = await readPluginLockfile(join(root, 'plugins')) - expect(lock.plugins['community.notes']).toMatchObject({ - version: '1.0.0', - resolvedCommit: 'a'.repeat(40) - }) - }) -}) diff --git a/src/main/plugins/plugin-marketplace-installer.ts b/src/main/plugins/plugin-marketplace-installer.ts deleted file mode 100644 index aac1875c91b..00000000000 --- a/src/main/plugins/plugin-marketplace-installer.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import type { PluginManifest } from '../../shared/plugins/plugin-manifest' -import { getUserPluginsDir } from './plugin-discovery' -import { checkoutPluginGitSource } from './plugin-git-repository' -import { - installPluginFromMarketplace, - readPluginLockfile, - rollbackInstalledPlugin, - type PluginInstallResult -} from './plugin-install' -import { inspectPluginInstallTree } from './plugin-install-staging' -import type { - PluginMarketplaceListing, - PluginMarketplaceService -} from './plugin-marketplace-service' -import { marketplaceSourceId } from './plugin-marketplace-store' - -export type PluginMarketplaceInstallPreview = { - marketplaceSourceId: string - marketplaceName: string - marketplaceOwner: string - marketplaceCommit: string - pluginKey: string - source: PluginMarketplaceListing['source'] - resolvedCommit: string - contentHash: string - consentFingerprint: string - manifest: PluginManifest - official: boolean - bundled: boolean - blockedByKillList?: { reason: string; advisoryUrl?: string } -} - -export type PluginMarketplacePreviewIdentity = Pick< - PluginMarketplaceInstallPreview, - 'marketplaceSourceId' | 'marketplaceCommit' | 'pluginKey' | 'resolvedCommit' -> - -export class PluginMarketplaceInstaller { - private readonly marketplace: PluginMarketplaceService - private readonly userDataPath: string - private readonly hostVersion: string - private readonly blockedPluginReason: (pluginKey: string) => string | null - - constructor(options: { - marketplace: PluginMarketplaceService - userDataPath: string - hostVersion: string - blockedPluginReason?: (pluginKey: string) => string | null - }) { - this.marketplace = options.marketplace - this.userDataPath = options.userDataPath - this.hostVersion = options.hostVersion - this.blockedPluginReason = options.blockedPluginReason ?? (() => null) - } - - async preview( - marketplaceSourceId: string, - pluginKey: string - ): Promise { - const listing = await this.requireListing(marketplaceSourceId, pluginKey) - const stagingDirectory = await mkdtemp(join(tmpdir(), 'orca-plugin-marketplace-preview-')) - try { - const resolvedCommit = await checkoutPluginGitSource({ - url: listing.source.url, - // Why: a pinned release names its commit; the listed ref is only what the author calls it. - ref: listing.release?.commit ?? listing.source.ref, - destination: stagingDirectory, - workingDirectory: tmpdir() - }) - const inspection = await inspectPluginInstallTree({ - rootDir: stagingDirectory, - hostVersion: this.hostVersion, - expectedPluginKey: pluginKey, - ...(listing.release ? { release: { record: listing.release, resolvedCommit } } : {}) - }) - if (!inspection.ok) { - throw new Error(inspection.error) - } - return { - marketplaceSourceId, - marketplaceName: listing.marketplaceName, - marketplaceOwner: listing.marketplaceOwner, - marketplaceCommit: listing.marketplaceCommit, - pluginKey, - source: listing.source, - resolvedCommit, - contentHash: inspection.contentHash, - consentFingerprint: inspection.consentFingerprint, - manifest: inspection.manifest, - official: listing.official, - bundled: listing.bundled, - ...(listing.blockedByKillList ? { blockedByKillList: listing.blockedByKillList } : {}) - } - } finally { - await rm(stagingDirectory, { recursive: true, force: true }) - } - } - - async install(preview: PluginMarketplacePreviewIdentity): Promise { - const listing = await this.requireListing(preview.marketplaceSourceId, preview.pluginKey) - const blockedReason = - listing.blockedByKillList?.reason ?? this.blockedPluginReason(preview.pluginKey) - if (blockedReason) { - return { ok: false, error: `plugin is blocked by Orca's safety list: ${blockedReason}` } - } - if (listing.marketplaceCommit !== preview.marketplaceCommit) { - return { ok: false, error: 'marketplace changed after preview; review the plugin again' } - } - const sourceState = (await this.marketplace.listSources()).find( - (source) => source.id === preview.marketplaceSourceId - ) - if (!sourceState) { - return { ok: false, error: 'marketplace source is no longer configured' } - } - return installPluginFromMarketplace({ - pluginsDir: getUserPluginsDir(this.userDataPath), - hostVersion: this.hostVersion, - expectedPluginKey: preview.pluginKey, - expectedResolvedCommit: preview.resolvedCommit, - marketplace: { - url: sourceState.source.url, - ref: sourceState.source.ref, - resolvedCommit: preview.marketplaceCommit - }, - plugin: { url: listing.source.url, ref: listing.source.ref }, - ...(listing.release ? { release: listing.release } : {}), - blockedPluginReason: this.blockedPluginReason - }) - } - - async previewInstalledUpdate(pluginKey: string): Promise { - const lock = await readPluginLockfile(getUserPluginsDir(this.userDataPath)) - const entry = lock.plugins[pluginKey] - if (!entry || entry.source.kind !== 'marketplace') { - throw new Error(`plugin ${pluginKey} was not installed from a marketplace`) - } - const sourceId = marketplaceSourceId({ - kind: 'git', - url: entry.source.marketplace.url, - ref: entry.source.marketplace.ref - }) - return this.preview(sourceId, pluginKey) - } - - async rollback(pluginKey: string): Promise { - return rollbackInstalledPlugin({ - pluginsDir: getUserPluginsDir(this.userDataPath), - pluginKey, - hostVersion: this.hostVersion, - blockedPluginReason: this.blockedPluginReason - }) - } - - private async requireListing( - marketplaceSourceId: string, - pluginKey: string - ): Promise { - const listing = await this.marketplace.findPlugin(marketplaceSourceId, pluginKey) - if (!listing) { - throw new Error(`plugin ${pluginKey} is not listed by marketplace ${marketplaceSourceId}`) - } - return listing - } -} diff --git a/src/main/plugins/plugin-marketplace-projection.ts b/src/main/plugins/plugin-marketplace-projection.ts deleted file mode 100644 index b5779a8314c..00000000000 --- a/src/main/plugins/plugin-marketplace-projection.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { - PluginMarketplaceEntry, - PluginMarketplaceGitSource -} from '../../shared/plugins/plugin-marketplace' -import type { PluginCatalogRelease } from '../../shared/plugins/plugin-catalog-release' - -export type PluginMarketplaceSourceState = { - id: string - source: PluginMarketplaceGitSource - addedAt: number - marketplace: { - name: string - owner: string - resolvedCommit: string - fetchedAt: number - } | null - stale: boolean - official: boolean - error?: string -} - -export type PluginMarketplaceListing = { - marketplaceSourceId: string - marketplaceName: string - marketplaceOwner: string - marketplaceCommit: string - pluginKey: string - source: PluginMarketplaceEntry['source'] - description?: string - categories: string[] - official: boolean - bundled: boolean - blockedByKillList?: { reason: string; advisoryUrl?: string } - /** Exact release the catalog vouches for; absent for v1 catalogs. */ - release?: PluginCatalogRelease -} diff --git a/src/main/plugins/plugin-new-file-template.test.ts b/src/main/plugins/plugin-new-file-template.test.ts new file mode 100644 index 00000000000..880edf2eae7 --- /dev/null +++ b/src/main/plugins/plugin-new-file-template.test.ts @@ -0,0 +1,63 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { pluginManifestSchema } from '../../shared/plugins/manifest/plugin-manifest' +import { PLUGIN_MANIFEST_V2_SINCE } from '../../shared/plugins/manifest/plugin-schema-field-docs' +import type { ValidDiscoveredPlugin } from './plugin-discovery' +import { readApprovedPluginNewFileTemplate } from './plugin-new-file-template' + +const roots: string[] = [] +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function drawPlugin(): Promise { + const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-new-file-')) + roots.push(rootDir) + await writeFile(join(rootDir, 'empty.draw'), '{"elements":[]}\n') + return { + pluginKey: 'acme.draw', + rootDir, + consentFingerprint: 'x', + contentHash: 'a'.repeat(64), + isDev: false, + manifest: pluginManifestSchema.parse({ + manifestVersion: 2, + execution: 'data', + id: 'draw', + publisher: 'acme', + name: 'Draw', + version: '1.0.0', + engines: { orca: `>=${PLUGIN_MANIFEST_V2_SINCE}` }, + contributes: { + newFiles: [ + { id: 'scene', title: 'New scene', extension: '.draw', template: 'empty.draw' }, + { id: 'blank', title: 'New blank', extension: '.draw' } + ] + } + }) + } +} + +describe('readApprovedPluginNewFileTemplate', () => { + it("reads an approved plugin's template, or empty text without one", async () => { + const plugin = await drawPlugin() + const service = { findApprovedPlugin: () => plugin } + await expect( + readApprovedPluginNewFileTemplate(service, plugin.pluginKey, 'scene') + ).resolves.toBe('{"elements":[]}\n') + await expect( + readApprovedPluginNewFileTemplate(service, plugin.pluginKey, 'blank') + ).resolves.toBe('') + await expect( + readApprovedPluginNewFileTemplate(service, plugin.pluginKey, 'missing') + ).resolves.toBeNull() + }) + + it('hands nothing out for a plugin that is off or awaiting review', async () => { + await expect( + readApprovedPluginNewFileTemplate({ findApprovedPlugin: () => null }, 'acme.draw', 'scene') + ).resolves.toBeNull() + }) +}) diff --git a/src/main/plugins/plugin-new-file-template.ts b/src/main/plugins/plugin-new-file-template.ts new file mode 100644 index 00000000000..914d9cbff18 --- /dev/null +++ b/src/main/plugins/plugin-new-file-template.ts @@ -0,0 +1,19 @@ +import { readPluginNewFileTemplate } from './plugin-artifact-validation' +import type { PluginService } from './plugin-service' + +/** A new file's starting text: its template, or empty. Null when the plugin is off or lacks it, + * so a stale menu item never writes a file for a plugin the user turned off. */ +export async function readApprovedPluginNewFileTemplate( + service: Pick, + pluginKey: string, + newFileId: string +): Promise { + const plugin = service.findApprovedPlugin(pluginKey) + const newFile = plugin?.manifest.contributes.newFiles.find((entry) => entry.id === newFileId) + if (!plugin || !newFile) { + return null + } + return newFile.template === undefined + ? '' + : readPluginNewFileTemplate(plugin.rootDir, newFile.template) +} diff --git a/src/main/plugins/plugin-secrets-store.ts b/src/main/plugins/plugin-secrets-store.ts index 9be9e9a6cef..a0a0b6c48d2 100644 --- a/src/main/plugins/plugin-secrets-store.ts +++ b/src/main/plugins/plugin-secrets-store.ts @@ -5,7 +5,7 @@ import { isUnreadableError, writeSecureFile } from '../../shared/secure-file' import { PLUGIN_STORAGE_KEY_LIMIT, PLUGIN_STORAGE_TOTAL_MAX_BYTES -} from '../../shared/plugins/plugin-host-api' +} from '../../shared/plugins/workers/plugin-host-api' import { pluginDataDir } from './plugin-storage-store' // SafeStorage vault writes fail without OS encryption; never fall back to plaintext. diff --git a/src/main/plugins/plugin-service-integrity.test.ts b/src/main/plugins/plugin-service-integrity.test.ts index 9de8d2a0d8b..efc0cbcdffe 100644 --- a/src/main/plugins/plugin-service-integrity.test.ts +++ b/src/main/plugins/plugin-service-integrity.test.ts @@ -2,12 +2,15 @@ import { mkdtemp, mkdir, rename, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { basename, join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' -import { pluginManifestSchema, type PluginManifest } from '../../shared/plugins/plugin-manifest' +import { fingerprintPluginConsent } from '../../shared/plugins/approval/plugin-consent-fingerprint' +import { + pluginManifestSchema, + type PluginManifest +} from '../../shared/plugins/manifest/plugin-manifest' import { hashPluginTree } from './plugin-content-hash' import { PluginContentVerifier } from './plugin-content-integrity' import { PluginService } from './plugin-service' -import type { PluginWorkerFactory } from './plugin-worker-manager' +import type { PluginWorkerFactory } from './workers/plugin-worker-manager' const roots: string[] = [] @@ -20,14 +23,14 @@ async function createInstalledPlugin(options: { worker: boolean }): Promise<{ }> { const userDataPath = await mkdtemp(join(tmpdir(), 'orca-plugin-service-integrity-')) roots.push(userDataPath) - const pluginKey = 'orca-samples.demo' + const pluginKey = 'samples.demo' const pluginDir = join(userDataPath, 'plugins', pluginKey) const stagingDir = join(pluginDir, 'staging') await mkdir(stagingDir, { recursive: true }) const manifest = pluginManifestSchema.parse({ manifestVersion: 1, id: 'demo', - publisher: 'orca-samples', + publisher: 'samples', name: 'Demo', version: '1.0.0', engines: { orca: '>=1.0.0' }, diff --git a/src/main/plugins/plugin-service-options.ts b/src/main/plugins/plugin-service-options.ts index f6df4ba8edf..2dcdd883a2f 100644 --- a/src/main/plugins/plugin-service-options.ts +++ b/src/main/plugins/plugin-service-options.ts @@ -1,6 +1,6 @@ -import type { PluginWorkerFactory } from './plugin-worker-manager' +import type { PluginWorkerFactory } from './workers/plugin-worker-manager' import type { KeybindingOverrides } from '../../shared/keybindings' -import type { PluginKillListEntry } from '../../shared/plugins/plugin-kill-list' +import type { PluginKillListEntry } from '../../shared/plugins/catalog/plugin-kill-list' export type PluginServiceOptions = { userDataPath: string diff --git a/src/main/plugins/plugin-service-reconciliation.test.ts b/src/main/plugins/plugin-service-reconciliation.test.ts index 3b64e1ab556..790b0d44bfb 100644 --- a/src/main/plugins/plugin-service-reconciliation.test.ts +++ b/src/main/plugins/plugin-service-reconciliation.test.ts @@ -3,22 +3,26 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { KeybindingOverrides } from '../../shared/keybindings' -import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' -import { pluginManifestSchema, type PluginManifest } from '../../shared/plugins/plugin-manifest' -import type { PluginWorkerHandle } from './plugin-host-process' +import { fingerprintPluginConsent } from '../../shared/plugins/approval/plugin-consent-fingerprint' +import { devFolderConsentIdentity } from './plugin-discovery' +import { + pluginManifestSchema, + type PluginManifest +} from '../../shared/plugins/manifest/plugin-manifest' +import type { PluginWorkerHandle } from './workers/plugin-host-process' import { PluginService } from './plugin-service' -import type { PluginWorkerFactory } from './plugin-worker-manager' +import type { PluginWorkerFactory } from './workers/plugin-worker-manager' import { hashPluginTree } from './plugin-content-hash' const roots: string[] = [] const services: PluginService[] = [] -const pluginKey = 'orca-samples.demo' +const pluginKey = 'samples.demo' function manifest(options: { main?: string; capabilities?: PluginManifest['capabilities'] } = {}) { return pluginManifestSchema.parse({ manifestVersion: 1, id: 'demo', - publisher: 'orca-samples', + publisher: 'samples', name: 'Demo', version: '1.0.0', engines: { orca: '>=1.0.0' }, @@ -56,12 +60,12 @@ function testWorker(): PluginWorkerHandle & { dispose: ReturnType } } -function createHarness(root: string) { +async function createHarness(root: string) { let enabled = true let disabled: string[] = [] let devPaths = [root] let killed = false - const consent = fingerprintPluginConsent(manifest()) + const consent = fingerprintPluginConsent(manifest(), await devFolderConsentIdentity(root)) const workers: ReturnType[] = [] const factory = vi.fn(async () => { const handle = testWorker() @@ -118,7 +122,7 @@ describe('PluginService worker reconciliation', () => { pluginManifestSchema.parse({ manifestVersion: 1, id, - publisher: 'orca-samples', + publisher: 'samples', name: id, version: '1.0.0', engines: { orca: '>=1.0.0' }, @@ -128,9 +132,9 @@ describe('PluginService worker reconciliation', () => { panels: [{ id: 'panel', title: 'Panel', entry: 'panel.html' }], commands: [{ id: 'run', title: 'Run' }], keybindings: [{ command: 'run', key: 'Mod+Alt+T' }], - events: [{ on: 'worktree.created' }] + events: [{ on: 'workspace.created' }] }, - capabilities: [{ kind: 'events:subscribe' }] + capabilities: [{ kind: 'workspace:read' }, { kind: 'agent:read' }] }) const firstManifest = conflictingManifest('first') const secondManifest = conflictingManifest('second') @@ -141,6 +145,14 @@ describe('PluginService worker reconciliation', () => { if (!firstHash.ok || !secondHash.ok) { throw new Error('could not hash conflict fixtures') } + const firstConsent = fingerprintPluginConsent( + firstManifest, + await devFolderConsentIdentity(firstRoot, firstHash.hash) + ) + const secondConsent = fingerprintPluginConsent( + secondManifest, + await devFolderConsentIdentity(secondRoot, secondHash.hash) + ) let keybindings: KeybindingOverrides = {} const factory = vi.fn(async () => testWorker()) const service = new PluginService({ @@ -149,8 +161,8 @@ describe('PluginService worker reconciliation', () => { isPluginSystemEnabled: () => true, getDisabledPlugins: () => [], getPluginConsents: () => ({ - 'orca-samples.first': fingerprintPluginConsent(firstManifest, firstHash.hash), - 'orca-samples.second': fingerprintPluginConsent(secondManifest, secondHash.hash) + 'samples.first': firstConsent, + 'samples.second': secondConsent }), getDevPluginPaths: () => [firstRoot, secondRoot], getKeybindings: () => keybindings, @@ -160,26 +172,27 @@ describe('PluginService worker reconciliation', () => { await service.initialize() - expect(service.activationError('orca-samples.first')).toContain('conflicts') - expect(service.getGrantedCapabilities('orca-samples.first')).toBeNull() - await expect(service.invokeCommand('orca-samples.first', 'run')).rejects.toThrow('not enabled') - await expect(service.panels.readEntry('orca-samples.first', 'panel')).resolves.toBeNull() - service.emitEvent('worktree.created', { - worktreeId: 'worktree-1', + expect(service.activationError('samples.first')).toContain('conflicts') + expect(service.getGrantedCapabilities('samples.first')).toBeNull() + await expect(service.invokeCommand('samples.first', 'run')).rejects.toThrow('not enabled') + await expect(service.panels.readEntry('samples.first', 'panel')).resolves.toBeNull() + service.emitEvent('workspace.created', { + workspaceId: 'worktree-1', + kind: 'worktree', path: '/repo', branch: 'feature' }) await new Promise((resolve) => setTimeout(resolve, 0)) expect(factory).not.toHaveBeenCalled() - keybindings = { 'plugin:orca-samples.first/run': ['Mod+Shift+T'] } + keybindings = { 'plugin:samples.first/run': ['Mod+Shift+T'] } await service.reconcileActivationState() - expect(service.activationError('orca-samples.first')).toBeNull() - await expect(service.panels.readEntry('orca-samples.first', 'panel')).resolves.toMatchObject({ + expect(service.activationError('samples.first')).toBeNull() + await expect(service.panels.readEntry('samples.first', 'panel')).resolves.toMatchObject({ html: expect.stringContaining('

Panel

') }) - await expect(service.invokeCommand('orca-samples.first', 'run')).resolves.toBeNull() + await expect(service.invokeCommand('samples.first', 'run')).resolves.toBeNull() expect(factory).toHaveBeenCalledOnce() }) @@ -187,7 +200,7 @@ describe('PluginService worker reconciliation', () => { const aliasManifest = pluginManifestSchema.parse({ manifestVersion: 1, id: 'demo', - publisher: 'orca-samples', + publisher: 'samples', name: 'Demo', version: '1.0.0', engines: { orca: '>=1.0.0' }, @@ -198,13 +211,17 @@ describe('PluginService worker reconciliation', () => { capabilities: [] }) const root = await pluginRoot(aliasManifest) + const aliasConsent = fingerprintPluginConsent( + aliasManifest, + await devFolderConsentIdentity(root) + ) const factory = vi.fn() const service = new PluginService({ userDataPath: root, hostVersion: '1.4.0', isPluginSystemEnabled: () => true, getDisabledPlugins: () => [], - getPluginConsents: () => ({ [pluginKey]: fingerprintPluginConsent(aliasManifest) }), + getPluginConsents: () => ({ [pluginKey]: aliasConsent }), getDevPluginPaths: () => [root], workerFactory: factory }) @@ -220,7 +237,7 @@ describe('PluginService worker reconciliation', () => { it('denies every authority boundary immediately when the feature flag turns off', async () => { const root = await pluginRoot() - const harness = createHarness(root) + const harness = await createHarness(root) await activate(harness.service) const plugin = harness.service.findValidPlugin(pluginKey)! const opened = await harness.service.panels.open('renderer:1', pluginKey, 'panel') @@ -248,7 +265,7 @@ describe('PluginService worker reconciliation', () => { it('deactivates a worker when its plugin becomes disabled', async () => { const root = await pluginRoot() - const harness = createHarness(root) + const harness = await createHarness(root) await activate(harness.service) harness.setDisabled([pluginKey]) @@ -260,7 +277,7 @@ describe('PluginService worker reconciliation', () => { it('immediately revokes every authority surface and stops a killed plugin', async () => { const root = await pluginRoot() - const harness = createHarness(root) + const harness = await createHarness(root) await activate(harness.service) expect(await harness.service.panels.open('renderer:1', pluginKey, 'panel')).not.toBeNull() @@ -270,8 +287,9 @@ describe('PluginService worker reconciliation', () => { expect(harness.service.activationError(pluginKey)).toContain('Security incident') await expect(harness.service.invokeCommand(pluginKey, 'run')).rejects.toThrow('not enabled') await expect(harness.service.panels.readEntry(pluginKey, 'panel')).resolves.toBeNull() - harness.service.emitEvent('worktree.created', { - worktreeId: 'worktree-1', + harness.service.emitEvent('workspace.created', { + workspaceId: 'worktree-1', + kind: 'worktree', path: '/repo', branch: 'feature' }) @@ -285,11 +303,11 @@ describe('PluginService worker reconciliation', () => { it('deactivates a worker when changed capabilities make consent pending', async () => { const root = await pluginRoot() - const harness = createHarness(root) + const harness = await createHarness(root) await activate(harness.service) await writeFile( join(root, 'orca-plugin.json'), - JSON.stringify(manifest({ capabilities: [{ kind: 'storage' }] })) + JSON.stringify(manifest({ capabilities: [{ kind: 'storage:own' }] })) ) await harness.service.refresh() @@ -302,7 +320,7 @@ describe('PluginService worker reconciliation', () => { it('restarts a running worker once on its updated spec', async () => { const root = await pluginRoot() - const harness = createHarness(root) + const harness = await createHarness(root) await activate(harness.service) await writeFile( join(root, 'orca-plugin.json'), @@ -334,7 +352,7 @@ describe('PluginService worker reconciliation', () => { workers.push(handle) return handle }) - const consent = fingerprintPluginConsent(manifest()) + const consent = fingerprintPluginConsent(manifest(), await devFolderConsentIdentity(root)) const service = new PluginService({ userDataPath: root, hostVersion: '1.4.0', @@ -362,10 +380,10 @@ describe('PluginService worker reconciliation', () => { await refreshing }) - it('deactivates removed and replaced dev paths without eager activation', async () => { + it('deactivates removed dev paths and makes a replacement folder wait for its own review', async () => { const firstRoot = await pluginRoot() const secondRoot = await pluginRoot() - const harness = createHarness(firstRoot) + const harness = await createHarness(firstRoot) await activate(harness.service) harness.setDevPaths([]) @@ -373,17 +391,19 @@ describe('PluginService worker reconciliation', () => { expect(harness.workers[0]!.dispose).toHaveBeenCalledOnce() expect(harness.service.findValidPlugin(pluginKey)).toBeNull() + // Same key and capabilities, different folder: the first folder's approval must not carry over. harness.setDevPaths([secondRoot]) await harness.service.refresh() + const replacement = harness.service.findValidPlugin(pluginKey) + expect(replacement && harness.service.activationState(replacement)).toBe('pending') + await expect(harness.service.invokeCommand(pluginKey, 'run')).rejects.toThrow('not enabled') expect(harness.factory).toHaveBeenCalledTimes(1) - await harness.service.invokeCommand(pluginKey, 'run') - expect(harness.factory.mock.calls[1]?.[0].rootDir).toBe(secondRoot) }) it('starts and stops housekeeping on feature-flag transitions', async () => { vi.useFakeTimers() const root = await pluginRoot() - const harness = createHarness(root) + const harness = await createHarness(root) await harness.service.initialize() expect(vi.getTimerCount()).toBe(1) @@ -400,7 +420,7 @@ describe('PluginService worker reconciliation', () => { it('serializes activation reconciliation so the latest disabled state wins', async () => { const root = await pluginRoot() - const harness = createHarness(root) + const harness = await createHarness(root) await harness.service.initialize() const originalReconcile = harness.service.contentPacks.reconcile.bind( diff --git a/src/main/plugins/plugin-service.ts b/src/main/plugins/plugin-service.ts index b3bf45c985f..c5f327719e7 100644 --- a/src/main/plugins/plugin-service.ts +++ b/src/main/plugins/plugin-service.ts @@ -1,14 +1,17 @@ -import type { PluginEventName } from '../../shared/plugins/plugin-manifest' +import type { PluginEventName } from '../../shared/plugins/manifest/plugin-manifest' import { capabilityKinds, type PluginCapabilityKind -} from '../../shared/plugins/plugin-capabilities' -import { - getPluginActivationState, - type PluginConsentLists -} from '../../shared/plugins/plugin-consent-state' -import type { PluginPanelActionOutcome } from '../../shared/plugins/plugin-panel-bridge' -import { createPluginExtensionRegistry } from '../../shared/plugins/plugin-extension-registry' +} from '../../shared/plugins/capabilities/plugin-capabilities' +import type { + PluginActivationState, + PluginConsentLists +} from '../../shared/plugins/approval/plugin-consent-state' +import type { + PluginHostCallOutcome, + PluginSurface +} from '../../shared/plugins/frames/plugin-protocol' +import { createPluginExtensionRegistry } from '../../shared/plugins/contributions/plugin-extension-registry' import { discoverPlugins, getPluginsDataDir, @@ -16,33 +19,38 @@ import { type DiscoveredPlugin, type ValidDiscoveredPlugin } from './plugin-discovery' -import { PluginEventBus } from './plugin-event-bus' import { PluginAuditLog } from './plugin-audit-log' -import { executePluginHostCallRequest } from './plugin-host-call-adapter' +import { executePluginHostCallRequest } from './workers/plugin-host-call-adapter' import { PluginContentVerifier } from './plugin-content-integrity' -import { bindPluginHostServices, type PluginRuntimeDelegate } from './plugin-host-service-bindings' -import { PluginPanelController } from './plugin-panel-controller' -import { PluginWorkerController } from './plugin-worker-controller' +import { + bindPluginHostServices, + type PluginRuntimeDelegate +} from './workers/plugin-host-service-bindings' +import { PluginPanelController } from './panels/plugin-panel-controller' +import { PluginWorkerController } from './workers/plugin-worker-controller' import { PluginServiceHousekeeping } from './plugin-service-housekeeping' -import { collectApprovedWorkerSpecs, isApprovedWorkerSpec } from './plugin-worker-reconciliation' -import type { PluginRunState } from './plugin-supervisor' -import { isPluginApproved, snapshotPluginConsentLists } from './plugin-activation-policy' -import { PluginContentPackRegistry } from './plugin-content-pack-registry' +import { + collectApprovedWorkerSpecs, + isApprovedWorkerSpec +} from './workers/plugin-worker-reconciliation' +import type { PluginRunState } from './workers/plugin-supervisor' +import { + isPluginApproved, + livePluginActivationState, + pluginPolicyBlockError, + snapshotPluginConsentLists +} from './plugin-activation-policy' +import { PluginContentPackRegistry } from './contributions/plugin-content-pack-registry' import type { PluginServiceOptions } from './plugin-service-options' -import type { PluginChangeEvent } from '../../shared/plugins/plugin-change-event' +import type { PluginChangeEvent } from '../../shared/plugins/events/plugin-change-event' import { waitForPluginRefreshSettlement } from './plugin-refresh-settlement' import { assertPluginWorkerCommand } from './plugin-command-invocation' import { deliverPluginEvent } from './plugin-event-delivery' -import { PluginInstallationState } from './plugin-installation-state' - -export type { PluginRuntimeDelegate } from './plugin-host-service-bindings' -export type { PluginLogLine } from './plugin-log-buffer' -export type { PluginServiceOptions } from './plugin-service-options' +import { PluginInstallationState } from './installation/plugin-installation-state' export class PluginService { readonly options: PluginServiceOptions private readonly registry = createPluginExtensionRegistry() - private readonly eventBus = new PluginEventBus() private readonly audit: PluginAuditLog private readonly workerController: PluginWorkerController private readonly contentVerifier = new PluginContentVerifier() @@ -67,15 +75,13 @@ export class PluginService { Boolean(this.options.getPluginKillListEntry?.(pluginKey)) ) this.audit = new PluginAuditLog(getPluginsDataDir(options.userDataPath)) - const resolveApproved = (pluginKey: string, allowRefreshPause = false) => { - const plugin = this.findValidPlugin(pluginKey) - return plugin && this.canStartPluginWork(plugin, allowRefreshPause) ? plugin : null - } + const resolveApproved = (pluginKey: string, allowRefreshPause = false) => + this.findApprovedPlugin(pluginKey, allowRefreshPause) this.panels = new PluginPanelController({ resolveApprovedPlugin: resolveApproved, contentVerifier: this.contentVerifier, - executeHostCall: (pluginKey, method, params) => - this.executeHostCall(pluginKey, method, params, { viaPanel: true }), + executeHostCall: (pluginKey, method, params, surface) => + this.executeHostCall(pluginKey, method, params, surface), log: (pluginKey) => this.installed.captureLog(pluginKey, 'error') }) this.workerController = new PluginWorkerController({ @@ -90,10 +96,9 @@ export class PluginService { isApprovalKept: (spec) => isApprovedWorkerSpec(spec, resolveApproved(spec.pluginKey, true)), invokeCommand: (pluginKey, commandId, args) => this.invokeCommand(pluginKey, commandId, args), executeHostCall: (pluginKey, method, params) => - this.executeHostCall(pluginKey, method, params, { viaPanel: false }), + this.executeHostCall(pluginKey, method, params, 'worker'), log: (pluginKey) => this.installed.logs.capture(pluginKey), - onStateChanged: () => this.notifyChanged(false), - onWorkerGone: (pluginKey) => this.eventBus.clear(pluginKey) + onStateChanged: () => this.notifyChanged(false) }) } @@ -128,11 +133,14 @@ export class PluginService { const enabled = this.options.isPluginSystemEnabled() const devPaths = this.options.getDevPluginPaths() const consentLists = snapshotPluginConsentLists(this.options) - const refresh = this.refreshChain.then(() => - this.performRefresh(enabled, devPaths, consentLists) - ) - this.refreshChain = refresh.catch(() => undefined) - return refresh + return this.enqueue(() => this.performRefresh(enabled, devPaths, consentLists)) + } + + /** Serializes discovery, removal, and reconciliation so none sees another's partial state. */ + private enqueue(work: () => Promise): Promise { + const queued = this.refreshChain.then(work) + this.refreshChain = queued.catch(() => undefined) + return queued } private async performRefresh( @@ -194,15 +202,14 @@ export class PluginService { return this.installed.findValid(pluginKey) } - activationState(plugin: ValidDiscoveredPlugin): ReturnType { - // Feature-off revokes authority immediately, before asynchronous reconciliation. - if (!this.options.isPluginSystemEnabled()) { - return 'disabled' - } - return getPluginActivationState(plugin.pluginKey, plugin.consentFingerprint, { - pluginConsents: this.options.getPluginConsents(), - disabledPlugins: this.options.getDisabledPlugins() - }) + /** Enabled, consented and not being removed: what may open frames or hand out its files. */ + findApprovedPlugin(pluginKey: string, allowRefreshPause = false): ValidDiscoveredPlugin | null { + const plugin = this.findValidPlugin(pluginKey) + return plugin && this.canStartPluginWork(plugin, allowRefreshPause) ? plugin : null + } + + activationState(plugin: ValidDiscoveredPlugin): PluginActivationState { + return livePluginActivationState(this.options, plugin) } private canStartPluginWork(plugin: ValidDiscoveredPlugin, allowRefreshPause = false): boolean { @@ -214,7 +221,7 @@ export class PluginService { (allowRefreshPause || this.contentPacksReady) && this.activationState(plugin) === 'approved' && !this.contentPacks.error(plugin.pluginKey) && - !this.options.getPluginKillListEntry?.(plugin.pluginKey) + !pluginPolicyBlockError(this.options, plugin.pluginKey) ) } @@ -223,9 +230,8 @@ export class PluginService { } activationError(pluginKey: string): string | null { - const blocked = this.options.getPluginKillListEntry?.(pluginKey) return ( - (blocked ? `Blocked by Orca's plugin safety list: ${blocked.reason}` : null) ?? + pluginPolicyBlockError(this.options, pluginKey) ?? this.contentPacks.error(pluginKey) ?? this.workerController.activationError(pluginKey) ) @@ -243,19 +249,18 @@ export class PluginService { pluginKey: string, method: string, params: unknown, - options: { viaPanel: boolean } - ): Promise { + surface: PluginSurface + ): Promise { return executePluginHostCallRequest({ pluginKey, request: { method, params }, - viaPanel: options.viaPanel, + surface, resolvePolicy: (boundPluginKey) => ({ grantedCapabilities: this.getGrantedCapabilities(boundPluginKey), services: this.runtimeDelegate ? bindPluginHostServices({ delegate: this.runtimeDelegate, - pluginsDataDir: getPluginsDataDir(this.options.userDataPath), - subscribeEvents: (key, events) => this.eventBus.subscribe(key, events) + pluginsDataDir: getPluginsDataDir(this.options.userDataPath) }) : null, audit: this.audit @@ -284,17 +289,15 @@ export class PluginService { event, payload, plugins: this.installed.discovered, - eventBus: this.eventBus, workerController: this.workerController, isRuntimeApproved: (plugin) => this.canStartPluginWork(plugin), + grantedCapabilities: (pluginKey) => this.getGrantedCapabilities(pluginKey), logWarning: (pluginKey) => this.installed.captureLog(pluginKey, 'warn') }) } removePlugin(pluginKey: string, remove: () => Promise): Promise { - const removal = this.refreshChain.then(() => this.installed.remove(pluginKey, remove)) - this.refreshChain = removal.catch(() => undefined) - return removal + return this.enqueue(() => this.installed.remove(pluginKey, remove)) } async deactivatePlugin(pluginKey: string): Promise { @@ -304,9 +307,7 @@ export class PluginService { /** Reconcile consent/enablement without rereading files or starting workers. */ async reconcileActivationState(): Promise { - const reconcile = this.refreshChain.then(() => this.performActivationStateReconciliation()) - this.refreshChain = reconcile.catch(() => undefined) - return reconcile + return this.enqueue(() => this.performActivationStateReconciliation()) } private async performActivationStateReconciliation(): Promise { diff --git a/src/main/plugins/plugin-startup-budget.test.ts b/src/main/plugins/plugin-startup-budget.test.ts index 10289bd0d28..b78486dd07f 100644 --- a/src/main/plugins/plugin-startup-budget.test.ts +++ b/src/main/plugins/plugin-startup-budget.test.ts @@ -3,15 +3,16 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { performance } from 'node:perf_hooks' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' -import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' +import { fingerprintPluginConsent } from '../../shared/plugins/approval/plugin-consent-fingerprint' import { PLUGIN_MANIFEST_FILENAME, pluginManifestSchema, qualifiedPluginKey, type PluginManifest -} from '../../shared/plugins/plugin-manifest' -import { PluginService, type PluginServiceOptions } from './plugin-service' -import type { PluginWorkerFactory } from './plugin-worker-manager' +} from '../../shared/plugins/manifest/plugin-manifest' +import { PluginService } from './plugin-service' +import type { PluginServiceOptions } from './plugin-service-options' +import type { PluginWorkerFactory } from './workers/plugin-worker-manager' const PLUGIN_COUNT = 20 const SAMPLE_COUNT = 20 diff --git a/src/main/plugins/plugin-storage-store.test.ts b/src/main/plugins/plugin-storage-store.test.ts index 579fd3f079c..44d54b249b2 100644 --- a/src/main/plugins/plugin-storage-store.test.ts +++ b/src/main/plugins/plugin-storage-store.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { PLUGIN_STORAGE_VALUE_MAX_BYTES } from '../../shared/plugins/plugin-host-api' +import { PLUGIN_STORAGE_VALUE_MAX_BYTES } from '../../shared/plugins/workers/plugin-host-api' import { PluginKvStore } from './plugin-storage-store' const roots: string[] = [] @@ -15,7 +15,7 @@ describe('PluginKvStore limits', () => { it('enforces the value cap in UTF-8 bytes rather than JavaScript code units', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-plugin-storage-')) roots.push(root) - const store = new PluginKvStore(root, 'orca-samples.demo', 'storage.json') + const store = new PluginKvStore(root, 'samples.demo', 'storage.json') const value = '😀'.repeat(Math.ceil(PLUGIN_STORAGE_VALUE_MAX_BYTES / 4) + 1) expect(store.set('large', value)).toMatchObject({ diff --git a/src/main/plugins/plugin-storage-store.ts b/src/main/plugins/plugin-storage-store.ts index de6ccc20c70..b0010391510 100644 --- a/src/main/plugins/plugin-storage-store.ts +++ b/src/main/plugins/plugin-storage-store.ts @@ -1,12 +1,13 @@ import { existsSync, readFileSync, statSync } from 'node:fs' import { join } from 'node:path' import { isUnreadableError, writeSecureFile } from '../../shared/secure-file' -import { isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest' +import { isQualifiedPluginKey } from '../../shared/plugins/manifest/plugin-manifest' import { PLUGIN_STORAGE_KEY_LIMIT, PLUGIN_STORAGE_TOTAL_MAX_BYTES, - PLUGIN_STORAGE_VALUE_MAX_BYTES -} from '../../shared/plugins/plugin-host-api' + PLUGIN_STORAGE_VALUE_MAX_BYTES, + type PluginJsonValue +} from '../../shared/plugins/workers/plugin-host-api' // Keep plugin data/settings in separate qualified-key files (adapted from community PR #5801). @@ -33,7 +34,7 @@ export class PluginKvStore { } /** Null means unreadable, never empty. */ - private read(): Record | null { + private read(): Record | null { try { if (!existsSync(this.filePath)) { return {} @@ -41,9 +42,9 @@ export class PluginKvStore { if (statSync(this.filePath).size > PLUGIN_STORAGE_TOTAL_MAX_BYTES) { return {} } - const parsed: unknown = JSON.parse(readFileSync(this.filePath, 'utf8')) + const parsed: PluginJsonValue = JSON.parse(readFileSync(this.filePath, 'utf8')) if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - return parsed as Record + return parsed } } catch (error) { // Read denial cannot prove corruption; rewriting an empty store would discard existing keys. @@ -55,11 +56,11 @@ export class PluginKvStore { return {} } - get(key: string): unknown { + get(key: string): PluginJsonValue | undefined { return this.read()?.[key] } - getAll(): Record { + getAll(): Record { return this.read() ?? {} } @@ -67,7 +68,7 @@ export class PluginKvStore { return Object.keys(this.read() ?? {}) } - set(key: string, value: unknown): PluginKvWriteResult { + set(key: string, value: PluginJsonValue): PluginKvWriteResult { let serialized: string try { serialized = JSON.stringify(value) diff --git a/src/main/plugins/plugin-uninstall-lifecycle.test.ts b/src/main/plugins/plugin-uninstall-lifecycle.test.ts index 6fa0f988462..65acea26e52 100644 --- a/src/main/plugins/plugin-uninstall-lifecycle.test.ts +++ b/src/main/plugins/plugin-uninstall-lifecycle.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { join } from 'node:path' import { getUserPluginsDir } from './plugin-discovery' -import { installPluginFromLocalPath } from './plugin-install' +import { installPluginFromLocalPath } from './installation/plugin-install' import { createUninstallFixture, holdNextUninstallRefresh, @@ -21,7 +21,7 @@ it('blocks fresh work while removal is pending and restores admission after fail const { service, install } = await createUninstallFixture() const key = await install('failure', { panels: [{ id: 'panel', title: 'Panel', entry: 'panel.html' }], - events: [{ on: 'worktree.created' }] + events: [{ on: 'workspace.created' }] }) const entered = uninstallGate() const release = uninstallGate() @@ -35,12 +35,13 @@ it('blocks fresh work while removal is pending and restores admission after fail try { await entered.promise const workers = uninstallWorkerPorts.length - expect(service.getGrantedCapabilities(key)).toEqual(['events:subscribe']) + expect(service.getGrantedCapabilities(key)).toEqual(['workspace:read', 'agent:read']) expect(service.getLogs(key)).toHaveLength(200) await expect(service.invokeCommand(key, 'run')).rejects.toThrow('not enabled') await expect(service.panels.readEntry(key, 'panel')).resolves.toBeNull() - service.emitEvent('worktree.created', { - worktreeId: 'fixture', + service.emitEvent('workspace.created', { + workspaceId: 'fixture', + kind: 'worktree', path: 'synthetic-path', branch: 'main' }) @@ -59,7 +60,7 @@ it('publishes retirement before a held subsequent refresh can finish', async () const { service, install } = await createUninstallFixture() const key = await install('refresh-gap', { panels: [{ id: 'panel', title: 'Panel', entry: 'panel.html' }], - events: [{ on: 'worktree.created' }] + events: [{ on: 'workspace.created' }] }) const child = latestUninstallWorker() const held = holdNextUninstallRefresh(service) @@ -71,8 +72,9 @@ it('publishes retirement before a held subsequent refresh can finish', async () const workers = uninstallWorkerPorts.length await expect(service.invokeCommand(key, 'run')).rejects.toThrow('not enabled') await expect(service.panels.readEntry(key, 'panel')).resolves.toBeNull() - service.emitEvent('worktree.created', { - worktreeId: 'fixture', + service.emitEvent('workspace.created', { + workspaceId: 'fixture', + kind: 'worktree', path: 'synthetic-path', branch: 'main' }) diff --git a/src/main/plugins/plugin-uninstall-log-retention.test.ts b/src/main/plugins/plugin-uninstall-log-retention.test.ts index f1ae7489d21..9d4bb35b0b1 100644 --- a/src/main/plugins/plugin-uninstall-log-retention.test.ts +++ b/src/main/plugins/plugin-uninstall-log-retention.test.ts @@ -2,8 +2,8 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { mkdir, rm, stat, symlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { getPluginsDataDir, getUserPluginsDir } from './plugin-discovery' -import { readPluginLockfile } from './plugin-install' -import type { PluginManifest } from '../../shared/plugins/plugin-manifest' +import { readPluginLockfile } from './installation/plugin-install' +import type { PluginManifest } from '../../shared/plugins/manifest/plugin-manifest' import { createUninstallFixture, latestPluginLog, @@ -99,7 +99,7 @@ it.each(['panel', 'event'] as const)( const contributes: Partial = kind === 'panel' ? { panels: [{ id: 'panel', title: 'Panel', entry: 'panel.html' }] } - : { events: [{ on: 'worktree.created' }] } + : { events: [{ on: 'workspace.created' }] } const key = await install(`late-${kind}`, contributes) const held = uninstallGate() if (kind === 'event') { @@ -111,8 +111,9 @@ it.each(['panel', 'event'] as const)( .mockImplementationOnce(() => held.promise) const panelResult = kind === 'panel' ? service.panels.readEntry(key, 'panel') : null if (kind === 'event') { - service.emitEvent('worktree.created', { - worktreeId: 'fixture', + service.emitEvent('workspace.created', { + workspaceId: 'fixture', + kind: 'worktree', path: 'synthetic-path', branch: 'main' }) diff --git a/src/main/plugins/plugin-uninstall-ordering.test.ts b/src/main/plugins/plugin-uninstall-ordering.test.ts index 76331decfbc..1056c193c21 100644 --- a/src/main/plugins/plugin-uninstall-ordering.test.ts +++ b/src/main/plugins/plugin-uninstall-ordering.test.ts @@ -6,7 +6,7 @@ import { installPluginFromLocalPath, readPluginLockfile, removeInstalledPlugin -} from './plugin-install' +} from './installation/plugin-install' import { createUninstallFixture, holdNextUninstallRefresh, diff --git a/src/main/plugins/plugin-uninstall-worker-fixture.ts b/src/main/plugins/plugin-uninstall-worker-fixture.ts index 493871a9782..259db475425 100644 --- a/src/main/plugins/plugin-uninstall-worker-fixture.ts +++ b/src/main/plugins/plugin-uninstall-worker-fixture.ts @@ -4,7 +4,7 @@ import { createPluginWorkerRuntime, type PluginWorkerOrcaApi, type PluginWorkerRuntime -} from './plugin-host-runtime' +} from './workers/plugin-host-runtime' export class UninstallWorkerPort extends EventEmitter { connected = true diff --git a/src/main/plugins/plugin-vm-recipe-identities.test.ts b/src/main/plugins/plugin-vm-recipe-identities.test.ts new file mode 100644 index 00000000000..5e4173b63da --- /dev/null +++ b/src/main/plugins/plugin-vm-recipe-identities.test.ts @@ -0,0 +1,91 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + pluginManifestSchema, + type PluginManifest +} from '../../shared/plugins/manifest/plugin-manifest' +import { pluginUpdateAccessGrowth } from '../../shared/plugins/approval/plugin-update-access-growth' +import { readPluginVmRecipeIdentities } from './plugin-vm-recipe-identities' + +const roots: string[] = [] +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +const recipe = { + schemaVersion: 1, + id: 'multipass-ubuntu-lts', + name: 'Multipass Ubuntu LTS', + create: 'multipass launch 24.04 --name "$ORCA_VM_NAME"', + destroy: 'multipass delete "$ORCA_VM_NAME" --purge' +} + +function manifest(version: string, path: string): PluginManifest { + return pluginManifestSchema.parse({ + manifestVersion: 1, + id: 'orca-multipass-recipes', + publisher: 'stablyai', + name: 'Multipass VM Recipes', + version, + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + capabilities: [], + contributes: { vmRecipes: [{ path }] } + }) +} + +async function pluginTree(path: string, contents: string): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-vm-recipe-identities-')) + roots.push(root) + await mkdir(join(root, 'recipes'), { recursive: true }) + await writeFile(join(root, path), contents) + return root +} + +describe('readPluginVmRecipeIdentities', () => { + it('treats a moved, reworded recipe with the same commands as nothing new', async () => { + const v1 = manifest('1.0.0', 'recipes/ubuntu-lts.json') + const v2 = manifest('2.0.0', 'recipes/ubuntu.json') + const installed = await readPluginVmRecipeIdentities( + await pluginTree('recipes/ubuntu-lts.json', JSON.stringify(recipe)), + v1 + ) + const next = await readPluginVmRecipeIdentities( + await pluginTree( + 'recipes/ubuntu.json', + JSON.stringify({ ...recipe, name: 'Ubuntu' }, null, 2) + ), + v2 + ) + expect(next).toEqual(installed) + expect(pluginUpdateAccessGrowth(v1, v2, { installed, next }).contributions).toEqual([]) + }) + + it('reports a changed command as new', async () => { + const v1 = manifest('1.0.0', 'recipes/ubuntu-lts.json') + const installed = await readPluginVmRecipeIdentities( + await pluginTree('recipes/ubuntu-lts.json', JSON.stringify(recipe)), + v1 + ) + const next = await readPluginVmRecipeIdentities( + await pluginTree( + 'recipes/ubuntu-lts.json', + JSON.stringify({ ...recipe, create: 'curl https://example.com | sh' }) + ), + v1 + ) + expect(pluginUpdateAccessGrowth(v1, v1, { installed, next }).contributions).toEqual([ + 'vmRecipes' + ]) + }) + + it('returns null when a recipe file is missing', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-vm-recipe-identities-')) + roots.push(root) + await expect( + readPluginVmRecipeIdentities(root, manifest('1.0.0', 'recipes/ubuntu-lts.json')) + ).resolves.toBeNull() + }) +}) diff --git a/src/main/plugins/plugin-vm-recipe-identities.ts b/src/main/plugins/plugin-vm-recipe-identities.ts new file mode 100644 index 00000000000..cc8df9c6489 --- /dev/null +++ b/src/main/plugins/plugin-vm-recipe-identities.ts @@ -0,0 +1,36 @@ +import type { PluginManifest } from '../../shared/plugins/manifest/plugin-manifest' +import type { PluginVmRecipeIdentities } from '../../shared/plugins/approval/plugin-update-access-growth' +import { + listPluginVmRecipeCommands, + parsePluginVmRecipeArtifact +} from '../../shared/plugins/artifacts/plugin-vm-recipe-artifact' +import { + PLUGIN_VM_RECIPE_MAX_BYTES, + readContainedPluginArtifactText +} from './plugin-artifact-validation' + +/** What each declared recipe can run, so an update compares commands rather than file paths. + * Null when any file cannot be read, which makes the update count its recipes as new. */ +export async function readPluginVmRecipeIdentities( + rootDir: string, + manifest: Pick +): Promise { + try { + const identities: string[] = [] + for (const contribution of manifest.contributes.vmRecipes ?? []) { + const recipe = parsePluginVmRecipeArtifact( + await readContainedPluginArtifactText( + rootDir, + contribution.path, + PLUGIN_VM_RECIPE_MAX_BYTES + ) + ) + identities.push( + JSON.stringify([recipe.id, recipe.checkoutMode ?? null, listPluginVmRecipeCommands(recipe)]) + ) + } + return identities + } catch { + return null + } +} diff --git a/src/main/plugins/plugin-vm-recipe-registry.test.ts b/src/main/plugins/plugin-vm-recipe-registry.test.ts index b9eb2b55cdf..2db2e55843c 100644 --- a/src/main/plugins/plugin-vm-recipe-registry.test.ts +++ b/src/main/plugins/plugin-vm-recipe-registry.test.ts @@ -2,8 +2,8 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' -import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' +import { fingerprintPluginConsent } from '../../shared/plugins/approval/plugin-consent-fingerprint' +import { pluginManifestSchema } from '../../shared/plugins/manifest/plugin-manifest' import { hashPluginTree } from './plugin-content-hash' import type { ValidDiscoveredPlugin } from './plugin-discovery' import { PluginVmRecipeRegistry } from './plugin-vm-recipe-registry' @@ -29,7 +29,7 @@ async function recipePlugin( const manifest = pluginManifestSchema.parse({ manifestVersion: 1, id, - publisher: 'orca-samples', + publisher: 'samples', name: id, version: '1.0.0', engines: { orca: '>=1.0.0' }, @@ -42,7 +42,7 @@ async function recipePlugin( throw new Error(content.error) } return { - pluginKey: `orca-samples.${id}`, + pluginKey: `samples.${id}`, rootDir, manifest, consentFingerprint: fingerprintPluginConsent(manifest, content.hash), diff --git a/src/main/plugins/plugin-vm-recipe-registry.ts b/src/main/plugins/plugin-vm-recipe-registry.ts index d9f05d5f358..6459e2df4eb 100644 --- a/src/main/plugins/plugin-vm-recipe-registry.ts +++ b/src/main/plugins/plugin-vm-recipe-registry.ts @@ -1,5 +1,5 @@ import type { OrcaVmRecipe } from '../../shared/orca-yaml-hook-types' -import { parsePluginVmRecipeArtifact } from '../../shared/plugins/plugin-vm-recipe-artifact' +import { parsePluginVmRecipeArtifact } from '../../shared/plugins/artifacts/plugin-vm-recipe-artifact' import { PLUGIN_VM_RECIPE_MAX_BYTES, readContainedPluginArtifactText @@ -10,7 +10,7 @@ import { type DiscoveredPlugin, type ValidDiscoveredPlugin } from './plugin-discovery' -import { verifyInstructionalPluginContent } from './plugin-instructional-content-integrity' +import { verifyDeclarativePluginContent } from './plugin-declarative-content-integrity' const VM_RECIPE_LOAD_CONCURRENCY = 4 @@ -74,8 +74,9 @@ export class PluginVmRecipeRegistry { seen.add(recipe.id) registrations.push({ pluginKey: plugin.pluginKey, recipe }) } - // Reverify after reading so activated commands still match the reviewed tree. - await verifyInstructionalPluginContent(plugin) + // Verify after reading so the in-memory commands shown/activated are + // bound to the exact tree identity the user reviewed. + await verifyDeclarativePluginContent(plugin) return { pluginKey: plugin.pluginKey, approved, registrations } } catch (error) { return { diff --git a/src/main/plugins/plugin-host-call-adapter.ts b/src/main/plugins/workers/plugin-host-call-adapter.ts similarity index 83% rename from src/main/plugins/plugin-host-call-adapter.ts rename to src/main/plugins/workers/plugin-host-call-adapter.ts index 27af4dc723d..b7030ee453a 100644 --- a/src/main/plugins/plugin-host-call-adapter.ts +++ b/src/main/plugins/workers/plugin-host-call-adapter.ts @@ -1,6 +1,9 @@ import { z } from 'zod' -import { isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest' -import type { PluginPanelActionOutcome } from '../../shared/plugins/plugin-panel-bridge' +import { isQualifiedPluginKey } from '../../../shared/plugins/manifest/plugin-manifest' +import type { + PluginHostCallOutcome, + PluginSurface +} from '../../../shared/plugins/frames/plugin-protocol' import { executePluginHostCall, type ExecutePluginHostCallInput } from './plugin-host-methods' const pluginHostCallRequestSchema = z @@ -24,9 +27,9 @@ export async function executePluginHostCallRequest(input: { /** Qualified identity already authenticated by the owning transport. */ pluginKey: string request: unknown - viaPanel: boolean + surface: PluginSurface resolvePolicy: ResolvePluginHostCallPolicy -}): Promise { +}): Promise { if (!isQualifiedPluginKey(input.pluginKey)) { return { ok: false, code: 'invalid_request', error: 'invalid qualified plugin key' } } @@ -44,7 +47,7 @@ export async function executePluginHostCallRequest(input: { pluginId: input.pluginKey, method: parsed.data.method, params: parsed.data.params, - viaPanel: input.viaPanel, + surface: input.surface, ...policy }) } diff --git a/src/main/plugins/plugin-host-entry.ts b/src/main/plugins/workers/plugin-host-entry.ts similarity index 68% rename from src/main/plugins/plugin-host-entry.ts rename to src/main/plugins/workers/plugin-host-entry.ts index 4f9feb4ef1f..02bd44aaa42 100644 --- a/src/main/plugins/plugin-host-entry.ts +++ b/src/main/plugins/workers/plugin-host-entry.ts @@ -1,8 +1,9 @@ // ELECTRON_RUN_AS_NODE entry: imports must stay transitively Electron-free. import { createPluginWorkerRuntime } from './plugin-host-runtime' -import type { PluginWorkerChildMessage } from '../../shared/plugins/plugin-host-protocol' +import { pluginWorkerFatal } from '../../../shared/plugins/workers/plugin-host-protocol' +import type { PluginEnvelope } from '../../../shared/plugins/frames/plugin-protocol' -function sendToParent(message: PluginWorkerChildMessage): void { +function sendToParent(message: PluginEnvelope): void { process.send?.(message) } @@ -15,10 +16,9 @@ process.on('message', (raw: unknown) => { // Escaped third-party errors must terminate the worker so supervision can restart it. function dieFatally(error: unknown): void { try { - sendToParent({ - type: 'fatal', - error: error instanceof Error ? (error.stack ?? error.message) : String(error) - }) + sendToParent( + pluginWorkerFatal(error instanceof Error ? (error.stack ?? error.message) : String(error)) + ) } catch { // Channel already gone; nothing left to report to. } diff --git a/src/main/plugins/workers/plugin-host-method-bindings.ts b/src/main/plugins/workers/plugin-host-method-bindings.ts new file mode 100644 index 00000000000..bedd08f3c6d --- /dev/null +++ b/src/main/plugins/workers/plugin-host-method-bindings.ts @@ -0,0 +1,194 @@ +import type { z } from 'zod' +import { + isPluginHostMethodName, + PLUGIN_HOST_API, + PLUGIN_HOST_METHOD_NAMES, + PLUGIN_TERMINAL_ID_MAX_LENGTH, + PLUGIN_WORKSPACE_LABEL_MAX_LENGTH, + PLUGIN_WORKSPACE_TERMINAL_LIMIT, + type PluginHostMethodName, + type PluginHostMethodSpec, + type PluginJsonValue +} from '../../../shared/plugins/workers/plugin-host-api' +import { shortPluginBranchName } from '../../../shared/plugins/events/plugin-events' + +export type PluginWorktreeContext = { + worktreeId: string + branch: string + displayName: string +} + +export type PluginHostServices = { + resolveActiveWorktreeContext(): Promise + listWorktreeTerminals(worktreeId: string): Promise<{ id: string }[]> + sendTerminalText( + terminalId: string, + action: { text: string; enter: boolean } + ): Promise<{ accepted: boolean }> + dispatchPluginNotification(input: { + pluginId: string + title: string + body?: string + }): Promise<{ delivered: boolean }> + storage: { + get(pluginId: string, key: string): PluginJsonValue | undefined + set( + pluginId: string, + key: string, + value: PluginJsonValue + ): { ok: true } | { ok: false; error: string } + delete(pluginId: string, key: string): void + keys(pluginId: string): string[] + } + secrets: { + get( + pluginId: string, + key: string + ): { ok: true; value: string | null } | { ok: false; error: string } + set(pluginId: string, key: string, value: string): { ok: true } | { ok: false; error: string } + delete(pluginId: string, key: string): void + } + settings: { + getAll(pluginId: string): Record + set( + pluginId: string, + key: string, + value: PluginJsonValue + ): { ok: true } | { ok: false; error: string } + } +} + +type PluginHostMethodContext = { pluginId: string; services: PluginHostServices } + +export type PreparedPluginHostCall = + | { ok: false; issue: string } + | { + ok: true + params: unknown + /** Runs the handler; `value` is null when the result failed its schema. */ + execute: (ctx: PluginHostMethodContext) => Promise<{ value: unknown } | null> + } + +export type BoundPluginHostMethod = { + spec: PluginHostMethodSpec + prepare: (params: unknown) => PreparedPluginHostCall +} + +function bind

( + spec: PluginHostMethodSpec, + handler: (params: z.output

, ctx: PluginHostMethodContext) => Promise> +): BoundPluginHostMethod { + return { + spec, + prepare(raw) { + const parsed = spec.params.safeParse(raw) + if (!parsed.success) { + const issue = parsed.error.issues[0] + const path = issue?.path.join('.') || '(root)' + return { ok: false, issue: `${path}: ${issue?.message ?? 'invalid params'}` } + } + return { + ok: true, + params: parsed.data, + async execute(ctx) { + const result = spec.result.safeParse(await handler(parsed.data, ctx)) + return result.success ? { value: result.data } : null + } + } + } + } +} + +const api = PLUGIN_HOST_API +const OK = { ok: true } as const + +const HANDLERS: Record = { + 'workspace.readContext': bind(api['workspace.readContext'], async (_params, { services }) => { + const context = await services.resolveActiveWorktreeContext() + if (!context) { + return null + } + const terminals = await services.listWorktreeTerminals(context.worktreeId) + // Worktree IDs embed provider paths; explicitly project safe public fields. + return { + branch: shortPluginBranchName(context.branch).slice(0, PLUGIN_WORKSPACE_LABEL_MAX_LENGTH), + displayName: context.displayName.slice(0, PLUGIN_WORKSPACE_LABEL_MAX_LENGTH), + terminals: terminals + .filter( + (terminal) => + terminal.id.length > 0 && terminal.id.length <= PLUGIN_TERMINAL_ID_MAX_LENGTH + ) + .slice(0, PLUGIN_WORKSPACE_TERMINAL_LIMIT) + .map((terminal) => ({ id: terminal.id })) + } + }), + 'terminal.sendText': bind(api['terminal.sendText'], async (params, { services }) => { + const context = await services.resolveActiveWorktreeContext() + if (!context) { + throw new Error('no active worktree is available for terminal input') + } + // Handles outlive focus changes; revalidate current workspace membership before terminal input. + const terminals = await services.listWorktreeTerminals(context.worktreeId) + if (!terminals.some((terminal) => terminal.id === params.terminalId)) { + throw new Error('terminal is outside the active worktree') + } + const { text, enter } = params + const result = await services.sendTerminalText(params.terminalId, { text, enter }) + return { accepted: result.accepted } + }), + 'notifications.show': bind(api['notifications.show'], async (params, { pluginId, services }) => + services.dispatchPluginNotification({ pluginId, title: params.title, body: params.body }) + ), + 'storage.get': bind(api['storage.get'], async ({ key }, { pluginId, services }) => ({ + value: services.storage.get(pluginId, key) ?? null + })), + 'storage.set': bind(api['storage.set'], async ({ key, value }, { pluginId, services }) => + okOrThrow(services.storage.set(pluginId, key, value)) + ), + 'storage.delete': bind(api['storage.delete'], async ({ key }, { pluginId, services }) => { + services.storage.delete(pluginId, key) + return OK + }), + 'storage.keys': bind(api['storage.keys'], async (_params, { pluginId, services }) => ({ + keys: services.storage.keys(pluginId) + })), + 'secrets.get': bind(api['secrets.get'], async ({ key }, { pluginId, services }) => { + const result = services.secrets.get(pluginId, key) + if (!result.ok) { + throw new Error(result.error) + } + return { value: result.value } + }), + 'secrets.set': bind(api['secrets.set'], async ({ key, value }, { pluginId, services }) => + okOrThrow(services.secrets.set(pluginId, key, value)) + ), + 'secrets.delete': bind(api['secrets.delete'], async ({ key }, { pluginId, services }) => { + services.secrets.delete(pluginId, key) + return OK + }), + 'settings.get': bind(api['settings.get'], async (_params, { pluginId, services }) => ({ + settings: services.settings.getAll(pluginId) + })), + 'settings.set': bind(api['settings.set'], async ({ key, value }, { pluginId, services }) => + okOrThrow(services.settings.set(pluginId, key, value)) + ) +} + +function okOrThrow(result: { ok: true } | { ok: false; error: string }): typeof OK { + if (!result.ok) { + throw new Error(result.error) + } + return OK +} + +// Why: adding a facade schema without a binding must fail at module load, +// before a plugin can observe transport-specific behavior. +for (const name of PLUGIN_HOST_METHOD_NAMES) { + if (HANDLERS[name].spec !== PLUGIN_HOST_API[name]) { + throw new Error(`plugin host API binding for ${name} uses another method's spec`) + } +} + +export function getBoundPluginHostMethod(name: string): BoundPluginHostMethod | null { + return isPluginHostMethodName(name) ? HANDLERS[name] : null +} diff --git a/src/main/plugins/plugin-host-methods.test.ts b/src/main/plugins/workers/plugin-host-methods.test.ts similarity index 75% rename from src/main/plugins/plugin-host-methods.test.ts rename to src/main/plugins/workers/plugin-host-methods.test.ts index 7f196c9eb81..47d508ce6b2 100644 --- a/src/main/plugins/plugin-host-methods.test.ts +++ b/src/main/plugins/workers/plugin-host-methods.test.ts @@ -1,7 +1,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { PLUGIN_WORKSPACE_TERMINAL_LIMIT } from '../../shared/plugins/plugin-host-api' +import { PLUGIN_WORKSPACE_TERMINAL_LIMIT } from '../../../shared/plugins/workers/plugin-host-api' import { bindPluginHostServices, type PluginRuntimeDelegate } from './plugin-host-service-bindings' import { executePluginHostCall, type PluginHostServices } from './plugin-host-methods' @@ -25,8 +25,7 @@ function createServices(storageSet: PluginHostServices['storage']['set']): Plugi settings: { getAll: vi.fn().mockReturnValue({}), set: vi.fn().mockReturnValue({ ok: true }) - }, - subscribeEvents: vi.fn().mockReturnValue([]) + } } } @@ -34,11 +33,11 @@ describe('executePluginHostCall mutation auditing', () => { it('rejects prototype-sensitive storage keys before any host service call', async () => { const storageSet = vi.fn().mockReturnValue({ ok: true }) const outcome = await executePluginHostCall({ - pluginId: 'orca-samples.demo', + pluginId: 'samples.demo', method: 'storage.set', params: { key: '__proto__', value: 42 }, - viaPanel: false, - grantedCapabilities: ['storage'], + surface: 'worker', + grantedCapabilities: ['storage:own'], services: createServices(storageSet), audit: { record: vi.fn().mockResolvedValue(undefined) } }) @@ -50,11 +49,11 @@ describe('executePluginHostCall mutation auditing', () => { it('rejects non-JSON storage values before any host service call', async () => { const storageSet = vi.fn().mockReturnValue({ ok: true }) const outcome = await executePluginHostCall({ - pluginId: 'orca-samples.demo', + pluginId: 'samples.demo', method: 'storage.set', params: { key: 'created', value: new Date() }, - viaPanel: false, - grantedCapabilities: ['storage'], + surface: 'worker', + grantedCapabilities: ['storage:own'], services: createServices(storageSet), audit: { record: vi.fn().mockResolvedValue(undefined) } }) @@ -66,11 +65,11 @@ describe('executePluginHostCall mutation auditing', () => { it('fails closed before a mutation when the audit intent cannot be recorded', async () => { const storageSet = vi.fn().mockReturnValue({ ok: true }) const outcome = await executePluginHostCall({ - pluginId: 'orca-samples.demo', + pluginId: 'samples.demo', method: 'storage.set', params: { key: 'answer', value: 42 }, - viaPanel: false, - grantedCapabilities: ['storage'], + surface: 'worker', + grantedCapabilities: ['storage:own'], services: createServices(storageSet), audit: { record: vi.fn().mockRejectedValue(new Error('disk full')) } }) @@ -90,11 +89,11 @@ describe('executePluginHostCall mutation auditing', () => { }) const outcome = await executePluginHostCall({ - pluginId: 'orca-samples.demo', + pluginId: 'samples.demo', method: 'storage.set', params: { key: 'answer', value: 42 }, - viaPanel: false, - grantedCapabilities: ['storage'], + surface: 'worker', + grantedCapabilities: ['storage:own'], services: createServices(storageSet), audit: { record } }) @@ -106,11 +105,11 @@ describe('executePluginHostCall mutation auditing', () => { it('refuses mutations when no audit writer is configured', async () => { const storageSet = vi.fn().mockReturnValue({ ok: true }) const outcome = await executePluginHostCall({ - pluginId: 'orca-samples.demo', + pluginId: 'samples.demo', method: 'storage.set', params: { key: 'answer', value: 42 }, - viaPanel: false, - grantedCapabilities: ['storage'], + surface: 'worker', + grantedCapabilities: ['storage:own'], services: createServices(storageSet) }) @@ -140,8 +139,7 @@ function createTerminalHarness(terminalHandles: string[]): { delegate, services: bindPluginHostServices({ delegate, - pluginsDataDir: join(tmpdir(), 'plugin-host-methods-test'), - subscribeEvents: vi.fn().mockReturnValue([]) + pluginsDataDir: join(tmpdir(), 'plugin-host-methods-test') }) } } @@ -151,16 +149,67 @@ async function sendTerminalText( terminalId: string ): ReturnType { return executePluginHostCall({ - pluginId: 'orca-samples.demo', + pluginId: 'samples.demo', method: 'terminal.sendText', params: { terminalId, text: 'echo hi', enter: true }, - viaPanel: true, + surface: 'worker', grantedCapabilities: ['terminal:send'], services, audit: { record: vi.fn().mockResolvedValue(undefined) } }) } +describe('terminal.sendText panel surface', () => { + it('refuses a sandboxed panel even with terminal:send granted', async () => { + const { delegate, services } = createTerminalHarness(['term-1']) + const outcome = await executePluginHostCall({ + pluginId: 'orca-samples.demo', + method: 'terminal.sendText', + params: { terminalId: 'term-1', text: 'echo hi', enter: true }, + surface: 'panel', + grantedCapabilities: ['terminal:send'], + services, + audit: { record: vi.fn().mockResolvedValue(undefined) } + }) + expect(outcome).toMatchObject({ ok: false, code: 'not_permitted' }) + expect(delegate.sendTerminal).not.toHaveBeenCalled() + }) +}) + +describe('host API surfaces and strict params', () => { + const call = (method: string, params: unknown, surface: 'worker' | 'panel' | 'editor') => + executePluginHostCall({ + pluginId: 'samples.demo', + method, + params, + surface, + grantedCapabilities: ['storage:own', 'notifications:show'], + services: createServices(vi.fn().mockReturnValue({ ok: true })), + audit: { record: vi.fn().mockResolvedValue(undefined) } + }) + + it('lets each surface call only the methods that list it', async () => { + await expect(call('storage.keys', {}, 'editor')).resolves.toMatchObject({ + ok: false, + code: 'not_permitted' + }) + await expect(call('storage.keys', {}, 'worker')).resolves.toEqual({ + ok: true, + value: { keys: [] } + }) + await expect(call('notifications.show', { title: 'Hi' }, 'editor')).resolves.toEqual({ + ok: true, + value: { delivered: true } + }) + }) + + it('refuses a param this host does not know instead of dropping it', async () => { + const outcome = await call('notifications.show', { title: 'Hi', silent: true }, 'worker') + expect(outcome).toMatchObject({ ok: false, code: 'invalid_params' }) + expect(!outcome.ok && outcome.error).toContain('silent') + }) +}) + describe('terminal.sendText explicit worktree routing', () => { it('performs one bounded list and zero sends when the terminal is outside the worktree', async () => { const { delegate, services } = createTerminalHarness(['terminal:local:other']) @@ -216,10 +265,10 @@ describe('terminal.sendText explicit worktree routing', () => { const { delegate, services } = createTerminalHarness(handles) const outcome = await executePluginHostCall({ - pluginId: 'orca-samples.demo', + pluginId: 'samples.demo', method: 'workspace.readContext', params: {}, - viaPanel: true, + surface: 'panel', grantedCapabilities: ['workspace:read'], services }) diff --git a/src/main/plugins/plugin-host-methods.ts b/src/main/plugins/workers/plugin-host-methods.ts similarity index 68% rename from src/main/plugins/plugin-host-methods.ts rename to src/main/plugins/workers/plugin-host-methods.ts index a4fd6a8c444..d1e4c098b82 100644 --- a/src/main/plugins/plugin-host-methods.ts +++ b/src/main/plugins/workers/plugin-host-methods.ts @@ -1,11 +1,21 @@ import { getBoundPluginHostMethod, type PluginHostServices } from './plugin-host-method-bindings' -import { isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest' -import { gatePluginHostCall as decidePluginHostCall } from '../../shared/plugins/plugin-capability-gate' -import type { PluginCapabilityKind } from '../../shared/plugins/plugin-capabilities' -import type { PluginPanelActionOutcome } from '../../shared/plugins/plugin-panel-bridge' -import type { PluginAuditLog } from './plugin-audit-log' +import { isQualifiedPluginKey } from '../../../shared/plugins/manifest/plugin-manifest' +import { gatePluginHostCall as decidePluginHostCall } from '../../../shared/plugins/capabilities/plugin-capability-gate' +import type { PluginCapabilityKind } from '../../../shared/plugins/capabilities/plugin-capabilities' +import { isPluginJsonObject } from '../../../shared/plugins/manifest/plugin-json-object' +import type { + PluginHostCallOutcome, + PluginSurface +} from '../../../shared/plugins/frames/plugin-protocol' +import type { PluginAuditLog } from '../plugin-audit-log' -// All plugin transports share these Electron-free host-policy checks and runtime bindings. +/** + * Host API 1 handler bindings — the one place plugin-originated calls + * (panel bridge, worker hostCall, serve RPC relay) execute. Handlers + * delegate to runtime services through the structural `PluginHostServices` + * interface, so this module stays electron-free and the relay conformance + * suite can run the identical chokepoint against a fake service set. + */ export type { PluginHostServices } from './plugin-host-method-bindings' @@ -14,7 +24,7 @@ export type ExecutePluginHostCallInput = { pluginId: string method: string params: unknown - viaPanel: boolean + surface: PluginSurface /** Consented capability kinds; null = unknown/disabled/consent-stale. */ grantedCapabilities: readonly PluginCapabilityKind[] | null services: PluginHostServices | null @@ -23,12 +33,12 @@ export type ExecutePluginHostCallInput = { export async function executePluginHostCall( input: ExecutePluginHostCallInput -): Promise { +): Promise { if (!isQualifiedPluginKey(input.pluginId)) { return { ok: false, code: 'invalid_request', error: 'invalid qualified plugin key' } } const gate = decidePluginHostCall( - { grantedCapabilities: input.grantedCapabilities, viaPanel: input.viaPanel }, + { grantedCapabilities: input.grantedCapabilities, surface: input.surface }, input.method ) if (!gate.granted) { @@ -38,17 +48,12 @@ export async function executePluginHostCall( if (!bound) { return { ok: false, code: 'unknown_method', error: `unknown host method: ${input.method}` } } - const parsedParams = bound.spec.params.safeParse(input.params) - if (!parsedParams.success) { - const issue = parsedParams.error.issues[0] - const path = issue?.path.join('.') || '(root)' - return { - ok: false, - code: 'invalid_params', - error: `${path}: ${issue?.message ?? 'invalid params'}` - } + const prepared = bound.prepare(input.params) + if (!prepared.ok) { + return { ok: false, code: 'invalid_params', error: prepared.issue } } - if (!input.services) { + const services = input.services + if (!services) { return { ok: false, code: 'unavailable', error: 'runtime is not available' } } const auditMutation = async (outcome: 'attempt' | 'ok' | 'error'): Promise => { @@ -57,7 +62,7 @@ export async function executePluginHostCall( ts: Date.now(), actor: `plugin:${input.pluginId}`, method: input.method, - summary: summarizeParams(input.method, parsedParams.data), + summary: summarizeParams(input.method, prepared.params), outcome }) } @@ -82,12 +87,8 @@ export async function executePluginHostCall( } } try { - const value = await bound.handler(parsedParams.data, { - pluginId: input.pluginId, - services: input.services - }) - const validated = bound.spec.result.safeParse(value) - if (!validated.success) { + const validated = await prepared.execute({ pluginId: input.pluginId, services }) + if (!validated) { await auditMutation('error').catch(() => undefined) // Malformed host results must not leak unvalidated shapes through any plugin transport. return { @@ -97,7 +98,7 @@ export async function executePluginHostCall( } } await auditMutation('ok').catch(() => undefined) - return { ok: true, value: validated.data } + return { ok: true, value: validated.value } } catch (error) { await auditMutation('error').catch(() => undefined) return { @@ -109,10 +110,7 @@ export async function executePluginHostCall( } function summarizeParams(method: string, params: unknown): string { - const record = (typeof params === 'object' && params !== null ? params : {}) as Record< - string, - unknown - > + const record = isPluginJsonObject(params) ? params : {} switch (method) { case 'terminal.sendText': { const text = typeof record.text === 'string' ? record.text : '' diff --git a/src/main/plugins/plugin-host-process.test.ts b/src/main/plugins/workers/plugin-host-process.test.ts similarity index 52% rename from src/main/plugins/plugin-host-process.test.ts rename to src/main/plugins/workers/plugin-host-process.test.ts index 2c4716f8e87..235a6537e05 100644 --- a/src/main/plugins/plugin-host-process.test.ts +++ b/src/main/plugins/workers/plugin-host-process.test.ts @@ -18,7 +18,7 @@ class FakeChild extends EventEmitter { function start(child: FakeChild, options: { eventTimeoutMs?: number } = {}) { processMocks.fork.mockReturnValue(child) return startPluginWorker({ - pluginId: 'orca-samples.demo', + pluginId: 'samples.demo', rootDir: '/plugin', mainEntry: 'worker.js', entryPath: '/host.js', @@ -29,6 +29,13 @@ function start(child: FakeChild, options: { eventTimeoutMs?: number } = {}) { }) } +const ready = (commands: string[]) => ({ + orca: 1, + kind: 'evt', + event: 'ready', + payload: { commands } +}) + beforeEach(() => { processMocks.fork.mockReset() }) @@ -41,7 +48,7 @@ describe('startPluginWorker', () => { it('does not inherit Orca execArgv', async () => { const child = new FakeChild() const pending = start(child) - child.emit('message', { type: 'ready', commands: [] }) + child.emit('message', ready([])) await pending expect(processMocks.fork).toHaveBeenCalledWith( @@ -54,7 +61,7 @@ describe('startPluginWorker', () => { it('replays an exit that happened before handle registration', async () => { const child = new FakeChild() const pending = start(child) - child.emit('message', { type: 'ready', commands: ['run'] }) + child.emit('message', ready(['run'])) const handle = await pending child.emit('exit', 23) const onExit = vi.fn() @@ -68,7 +75,7 @@ describe('startPluginWorker', () => { it('kills a live worker that disconnects its IPC channel', async () => { const child = new FakeChild() const pending = start(child) - child.emit('message', { type: 'ready', commands: ['run'] }) + child.emit('message', ready(['run'])) const handle = await pending const command = handle.invokeCommand('run') child.connected = false @@ -82,17 +89,18 @@ describe('startPluginWorker', () => { it('counts delivered events as in flight until their acknowledgement', async () => { const child = new FakeChild() const pending = start(child) - child.emit('message', { type: 'ready', commands: [] }) + child.emit('message', ready([])) const handle = await pending - handle.deliverEvent('worktree.created', { - worktreeId: 'worktree-1', + handle.deliverEvent('workspace.created', { + workspaceId: 'worktree-1', + kind: 'worktree', path: '/repo', branch: 'feature' }) expect(handle.inFlightCount()).toBe(1) - child.emit('message', { type: 'eventAck', eventId: 0 }) + child.emit('message', { orca: 1, kind: 'res', id: 0, result: null }) expect(handle.inFlightCount()).toBe(0) }) @@ -100,11 +108,12 @@ describe('startPluginWorker', () => { vi.useFakeTimers() const child = new FakeChild() const pending = start(child, { eventTimeoutMs: 25 }) - child.emit('message', { type: 'ready', commands: [] }) + child.emit('message', ready([])) const handle = await pending - handle.deliverEvent('worktree.created', { - worktreeId: 'worktree-1', + handle.deliverEvent('workspace.created', { + workspaceId: 'worktree-1', + kind: 'worktree', path: '/repo', branch: 'feature' }) @@ -117,12 +126,12 @@ describe('startPluginWorker', () => { it('kills a worker that exceeds the pending event cap', async () => { const child = new FakeChild() const pending = start(child) - child.emit('message', { type: 'ready', commands: [] }) + child.emit('message', ready([])) const handle = await pending for (let index = 0; index < 65; index += 1) { handle.deliverEvent('agent.status.changed', { - worktreeId: null, + workspaceId: null, paneKey: `pane-${index}`, state: 'working', receivedAt: Date.now() @@ -132,4 +141,66 @@ describe('startPluginWorker', () => { expect(handle.inFlightCount()).toBe(64) expect(child.kill).toHaveBeenCalledWith('SIGKILL') }) + + it('relays worker host calls on the envelope, and drops the answer to a cancelled one', async () => { + const child = new FakeChild() + let finish!: (outcome: { ok: true; value: unknown }) => void + const executeHostCall = vi.fn( + () => new Promise<{ ok: true; value: unknown }>((resolve) => (finish = resolve)) + ) + processMocks.fork.mockReturnValue(child) + const pending = startPluginWorker({ + pluginId: 'samples.demo', + rootDir: '/plugin', + mainEntry: 'worker.js', + entryPath: '/host.js', + grantedCapabilities: [], + executeHostCall, + log: vi.fn() + }) + child.emit('message', ready([])) + await pending + + child.emit('message', { orca: 1, kind: 'req', id: 3, method: 'storage.keys', params: {} }) + expect(executeHostCall).toHaveBeenCalledWith('storage.keys', {}) + finish({ ok: true, value: { keys: [] } }) + await vi.waitFor(() => + expect(child.send).toHaveBeenLastCalledWith({ + orca: 1, + kind: 'res', + id: 3, + result: { keys: [] } + }) + ) + + child.emit('message', { orca: 1, kind: 'req', id: 4, method: 'storage.keys', params: {} }) + child.emit('message', { orca: 1, kind: 'cancel', id: 4 }) + const sends = child.send.mock.calls.length + finish({ ok: true, value: { keys: [] } }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(child.send.mock.calls.length).toBe(sends) + }) + + it('cancels a command in the worker when it times out', async () => { + vi.useFakeTimers() + const child = new FakeChild() + processMocks.fork.mockReturnValue(child) + const pending = startPluginWorker({ + pluginId: 'samples.demo', + rootDir: '/plugin', + mainEntry: 'worker.js', + entryPath: '/host.js', + grantedCapabilities: [], + executeHostCall: async () => ({ ok: true, value: null }), + log: vi.fn(), + invokeTimeoutMs: 25 + }) + child.emit('message', ready(['run'])) + const handle = await pending + const command = handle.invokeCommand('run') + const rejected = expect(command).rejects.toThrow('timed out') + await vi.advanceTimersByTimeAsync(25) + await rejected + expect(child.send).toHaveBeenLastCalledWith({ orca: 1, kind: 'cancel', id: 0 }) + }) }) diff --git a/src/main/plugins/plugin-host-process.ts b/src/main/plugins/workers/plugin-host-process.ts similarity index 73% rename from src/main/plugins/plugin-host-process.ts rename to src/main/plugins/workers/plugin-host-process.ts index 8d05613db4e..4987a383d10 100644 --- a/src/main/plugins/plugin-host-process.ts +++ b/src/main/plugins/workers/plugin-host-process.ts @@ -4,12 +4,21 @@ import { join } from 'node:path' import { PLUGIN_WORKER_INVOKE_TIMEOUT_MS, PLUGIN_WORKER_READY_TIMEOUT_MS, - pluginWorkerChildMessageSchema, - type PluginWorkerParentMessage -} from '../../shared/plugins/plugin-host-protocol' -import type { PluginCapabilityKind } from '../../shared/plugins/plugin-capabilities' -import type { PluginEventName } from '../../shared/plugins/plugin-manifest' -import type { PluginPanelActionOutcome } from '../../shared/plugins/plugin-panel-bridge' + readPluginWorkerEvent, + WORKER_EVENTS, + WORKER_METHODS +} from '../../../shared/plugins/workers/plugin-host-protocol' +import type { PluginCapabilityKind } from '../../../shared/plugins/capabilities/plugin-capabilities' +import type { PluginEventName } from '../../../shared/plugins/manifest/plugin-manifest' +import { + pluginCancel, + pluginEvent, + pluginOutcomeResponse, + pluginRequest, + readPluginEnvelope, + type PluginEnvelope, + type PluginHostCallOutcome +} from '../../../shared/plugins/frames/plugin-protocol' import { buildPluginWorkerEnv } from './plugin-worker-env' import { pipePluginWorkerOutput } from './plugin-worker-output-buffer' @@ -22,7 +31,7 @@ export type PluginWorkerLogSink = (level: 'info' | 'warn' | 'error', line: strin export type PluginWorkerHostCallExecutor = ( method: string, params: unknown -) => Promise +) => Promise export type PluginWorkerHandle = { /** Command ids the worker registered on activate (⊆ manifest commands). */ @@ -92,14 +101,14 @@ export async function startPluginWorker( const pendingCommands = new Map() const pendingEvents = new Map>() const exitCallbacks: ((code: number | null) => void)[] = [] - let nextCallId = 0 - let nextEventId = 0 + // One id space for host → worker requests: commands and event deliveries share `res`. + let nextId = 0 let exited = false let exitCode: number | null = null let disposed = false let lastActivityAt = Date.now() - function sendToChild(message: PluginWorkerParentMessage): void { + function sendToChild(message: PluginEnvelope): void { if (child.connected) { child.send(message) } @@ -160,83 +169,71 @@ export async function startPluginWorker( rejectAllPending(failure.message) }) child.on('exit', (code) => fail(new Error(`${tag} worker exited before ready (code ${code})`))) + // A host call the worker cancels leaves this set, so its late answer is dropped. + const inFlightHostCalls = new Set() child.on('message', (raw) => { - const parsed = pluginWorkerChildMessageSchema.safeParse(raw) - if (!parsed.success) { + const message = readPluginEnvelope(raw) + if (!message) { log('warn', 'ignoring malformed worker message') return } - const message = parsed.data - switch (message.type) { - case 'ready': { - if (!settled) { - settled = true - clearTimeout(timer) - options.signal?.removeEventListener('abort', onAbort) - resolve(message.commands) - } - return - } - case 'commandResult': { - const entry = pendingCommands.get(message.callId) - if (!entry) { + switch (message.kind) { + case 'res': { + lastActivityAt = Date.now() + clearTimeout(pendingEvents.get(message.id)) + const entry = pendingCommands.get(message.id) + if (pendingEvents.delete(message.id) || !entry) { return } clearTimeout(entry.timer) - pendingCommands.delete(message.callId) - lastActivityAt = Date.now() - if (message.ok) { - entry.resolve(message.value) + pendingCommands.delete(message.id) + if (message.error) { + entry.reject(new Error(message.error.message || 'plugin command failed')) } else { - entry.reject(new Error(message.error ?? 'plugin command failed')) + entry.resolve(message.result) } return } - case 'eventAck': { - const timer = pendingEvents.get(message.eventId) - if (timer) { - clearTimeout(timer) - pendingEvents.delete(message.eventId) - } - lastActivityAt = Date.now() - return - } - case 'hostCall': { + case 'req': { lastActivityAt = Date.now() + const { id } = message + inFlightHostCalls.add(id) void options.executeHostCall(message.method, message.params).then((outcome) => { lastActivityAt = Date.now() - sendToChild( - outcome.ok - ? { type: 'hostResult', callId: message.callId, ok: true, value: outcome.value } - : { - type: 'hostResult', - callId: message.callId, - ok: false, - errorCode: outcome.code, - error: outcome.error - } - ) + if (inFlightHostCalls.delete(id)) { + sendToChild(pluginOutcomeResponse(id, outcome)) + } }) return } - case 'log': { - log(message.level, message.message) + case 'cancel': + inFlightHostCalls.delete(message.id) return - } - case 'fatal': { - fail(new Error(`${tag} worker crashed: ${message.error}`)) - rejectAllPending(`${tag} worker crashed: ${message.error}`) - child.kill('SIGKILL') - } + case 'evt': + break + } + const event = readPluginWorkerEvent(message) + if (event?.event === WORKER_EVENTS.ready && !settled) { + settled = true + clearTimeout(timer) + options.signal?.removeEventListener('abort', onAbort) + resolve(event.payload.commands) + } else if (event?.event === WORKER_EVENTS.log) { + log(event.payload.level, event.payload.message) + } else if (event?.event === WORKER_EVENTS.fatal) { + fail(new Error(`${tag} worker crashed: ${event.payload.error}`)) + rejectAllPending(`${tag} worker crashed: ${event.payload.error}`) + child.kill('SIGKILL') } }) - sendToChild({ - type: 'init', - pluginId, - pluginRoot: rootDir, - mainEntry, - grantedCapabilities: [...options.grantedCapabilities] - }) + sendToChild( + pluginEvent(WORKER_EVENTS.init, { + pluginId, + pluginRoot: rootDir, + mainEntry, + grantedCapabilities: [...options.grantedCapabilities] + }) + ) if (options.signal?.aborted) { onAbort() } @@ -248,14 +245,16 @@ export async function startPluginWorker( if (exited || disposed) { return Promise.reject(new Error(`${tag} worker is not running`)) } - const callId = nextCallId++ + const callId = nextId++ return new Promise((resolve, reject) => { const timer = setTimeout(() => { pendingCommands.delete(callId) + // Tell the handler too, so it stops instead of running on unobserved. + sendToChild(pluginCancel(callId)) reject(new Error(`${tag} ${commandId} timed out after ${invokeTimeoutMs}ms`)) }, invokeTimeoutMs) pendingCommands.set(callId, { resolve, reject, timer }) - sendToChild({ type: 'invokeCommand', callId, commandId, args }) + sendToChild(pluginRequest(callId, WORKER_METHODS.invokeCommand, { commandId, args })) }) }, deliverEvent(event, payload) { @@ -268,14 +267,14 @@ export async function startPluginWorker( return } lastActivityAt = Date.now() - const eventId = nextEventId++ + const eventId = nextId++ const timer = setTimeout(() => { pendingEvents.delete(eventId) log('error', `${tag} ${event} did not finish within ${eventTimeoutMs}ms`) child.kill('SIGKILL') }, eventTimeoutMs) pendingEvents.set(eventId, timer) - sendToChild({ type: 'deliverEvent', eventId, event, payload }) + sendToChild(pluginRequest(eventId, WORKER_METHODS.deliverEvent, { event, payload })) }, lastActivityAt: () => lastActivityAt, inFlightCount: () => pendingCommands.size + pendingEvents.size, @@ -287,7 +286,7 @@ export async function startPluginWorker( if (exited) { return } - sendToChild({ type: 'shutdown' }) + sendToChild(pluginEvent(WORKER_EVENTS.shutdown)) await new Promise((resolve) => { const killTimer = setTimeout(() => { child.kill('SIGKILL') diff --git a/src/main/plugins/workers/plugin-host-runtime.test.ts b/src/main/plugins/workers/plugin-host-runtime.test.ts new file mode 100644 index 00000000000..03b9a8693b7 --- /dev/null +++ b/src/main/plugins/workers/plugin-host-runtime.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { createPluginWorkerRuntime, type PluginWorkerOrcaApi } from './plugin-host-runtime' + +const init = (overrides: { pluginRoot?: string; mainEntry?: string } = {}) => ({ + orca: 1, + kind: 'evt', + event: 'init', + payload: { + pluginId: 'samples.demo', + pluginRoot: '/plugin', + mainEntry: 'worker.js', + grantedCapabilities: [], + ...overrides + } +}) + +describe('plugin worker shutdown', () => { + it('normalizes either manifest separator before importing the worker', async () => { + const importModule = vi.fn(async () => ({ default: vi.fn() })) + const runtime = createPluginWorkerRuntime({ send: vi.fn(), importModule }) + + await runtime.handleMessage( + init({ pluginRoot: join('plugin-root'), mainEntry: 'nested\\worker.js' }) + ) + + expect(importModule).toHaveBeenCalledWith( + pathToFileURL(join('plugin-root', 'nested', 'worker.js')).href + ) + }) + + it('awaits an optional deactivate export before exiting', async () => { + let finishDeactivate!: () => void + const deactivate = vi.fn( + () => + new Promise((resolve) => { + finishDeactivate = resolve + }) + ) + const send = vi.fn() + const exit = vi.fn() + const runtime = createPluginWorkerRuntime({ + send, + exit, + importModule: async () => ({ default: vi.fn(), deactivate }) + }) + await runtime.handleMessage(init()) + + const shutdown = runtime.handleMessage({ orca: 1, kind: 'evt', event: 'shutdown' }) + await Promise.resolve() + expect(deactivate).toHaveBeenCalledOnce() + expect(exit).not.toHaveBeenCalled() + finishDeactivate() + await shutdown + + expect(exit).toHaveBeenCalledWith(0) + }) + + it('exits immediately when the plugin has no deactivate export', async () => { + const exit = vi.fn() + const runtime = createPluginWorkerRuntime({ + send: vi.fn(), + exit, + importModule: async () => ({ default: vi.fn() }) + }) + await runtime.handleMessage(init()) + + await runtime.handleMessage({ orca: 1, kind: 'evt', event: 'shutdown' }) + + expect(exit).toHaveBeenCalledWith(0) + }) +}) + +describe('worker orca API parity with frames', () => { + async function activateWith(activate: (orca: PluginWorkerOrcaApi) => void) { + const send = vi.fn() + const runtime = createPluginWorkerRuntime({ + send, + exit: vi.fn(), + importModule: async () => ({ default: activate }) + }) + await runtime.handleMessage(init()) + return { send, runtime } + } + + it('exposes api and a synchronous has() for worker methods', async () => { + let orca!: PluginWorkerOrcaApi + await activateWith((api) => (orca = api)) + expect(orca.api).toEqual({ major: 1, minor: 0 }) + expect(orca.host.has('storage.get')).toBe(true) + expect(orca.host.has('editor.applyEdit')).toBe(false) + expect(Object.isFrozen(orca)).toBe(true) + }) + + it('maps host errors to code, retryable and retryAfterMs, unknown codes to action_failed', async () => { + let orca!: PluginWorkerOrcaApi + const { send, runtime } = await activateWith((api) => (orca = api)) + const limited = orca.host.call('storage.keys') + const legacy = orca.host.call('storage.keys') + const [first, second] = send.mock.calls + .map(([message]) => message) + .filter((message) => message.kind === 'req') + await runtime.handleMessage({ + orca: 1, + kind: 'res', + id: first.id, + error: { code: 'rate_limited', message: 'slow down', retryable: true, retryAfterMs: 500 } + }) + await runtime.handleMessage({ + orca: 1, + kind: 'res', + id: second.id, + error: { code: 'brand_new_code', message: 'newer host', retryable: false } + }) + await expect(limited).rejects.toMatchObject({ + code: 'rate_limited', + message: 'slow down', + retryable: true, + retryAfterMs: 500 + }) + await expect(legacy).rejects.toMatchObject({ code: 'action_failed', retryable: false }) + }) + + it('cancels a host call when its signal aborts', async () => { + let orca!: PluginWorkerOrcaApi + const { send } = await activateWith((api) => (orca = api)) + const controller = new AbortController() + const call = orca.host.call('storage.keys', {}, { signal: controller.signal }) + controller.abort() + await expect(call).rejects.toMatchObject({ code: 'cancelled' }) + expect(send).toHaveBeenLastCalledWith({ orca: 1, kind: 'cancel', id: 0 }) + }) + + it('hands command handlers a signal the host aborts, and disposes registrations', async () => { + const seen: { signal?: AbortSignal } = {} + let finish!: () => void + const { send, runtime } = await activateWith((orca) => { + orca.commands.register('run', (_args, context) => { + seen.signal = context.signal + return new Promise((resolve) => (finish = resolve)) + }) + orca.events.on('workspace.created', vi.fn()).dispose() + }) + expect(send).toHaveBeenLastCalledWith({ + orca: 1, + kind: 'evt', + event: 'ready', + payload: { commands: ['run'] } + }) + const running = runtime.handleMessage({ + orca: 1, + kind: 'req', + id: 9, + method: 'command.invoke', + params: { commandId: 'run' } + }) + await runtime.handleMessage({ orca: 1, kind: 'cancel', id: 9 }) + expect(seen.signal?.aborted).toBe(true) + finish() + await running + expect(send).toHaveBeenLastCalledWith({ orca: 1, kind: 'res', id: 9, result: undefined }) + }) + + it('gives format 1 handlers worktreeId alongside workspaceId', async () => { + const legacy = vi.fn() + const current = vi.fn() + const { runtime } = await activateWith((orca) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: format 1 workers pass the old event name the runtime aliases. + orca.events.on('worktree.created' as 'workspace.created', legacy) + orca.events.on('workspace.created', current) + }) + await runtime.handleMessage({ + orca: 1, + kind: 'req', + id: 3, + method: 'event.deliver', + params: { event: 'workspace.created', payload: { workspaceId: 'w1' } } + }) + expect(legacy).toHaveBeenCalledWith({ workspaceId: 'w1', worktreeId: 'w1' }) + expect(current).toHaveBeenCalledWith({ workspaceId: 'w1' }) + }) +}) diff --git a/src/main/plugins/workers/plugin-host-runtime.ts b/src/main/plugins/workers/plugin-host-runtime.ts new file mode 100644 index 00000000000..e056b107ab6 --- /dev/null +++ b/src/main/plugins/workers/plugin-host-runtime.ts @@ -0,0 +1,313 @@ +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { + pluginWorkerDeliverParamsSchema, + pluginWorkerInitSchema, + pluginWorkerInvokeParamsSchema, + pluginWorkerFatal, + pluginWorkerLog, + WORKER_EVENTS, + WORKER_METHODS, + type PluginWorkerInit +} from '../../../shared/plugins/workers/plugin-host-protocol' +import { + pluginCancel, + pluginError, + pluginEvent, + pluginFailure, + pluginRequest, + pluginResult, + normalizePluginErrorCode, + readPluginEnvelope, + type PluginEnvelope, + type PluginErrorCode, + type PluginRequest, + type PluginResponse +} from '../../../shared/plugins/frames/plugin-protocol' +import type { PluginEventName } from '../../../shared/plugins/manifest/plugin-manifest' +import { PLUGIN_V1_EVENT_ALIASES } from '../../../shared/plugins/contributions/plugin-event-contribution' +import { + PLUGIN_HOST_API_MINOR, + pluginHostMethodsFor +} from '../../../shared/plugins/workers/plugin-host-api' +import { PLUGIN_HOST_API_MAJOR } from '../../../shared/plugins/workers/plugin-host-api-major' + +/** A failed host call or command: the same `{ code, message, retryable }` frames get. */ +// Why: format 1 handlers read `worktreeId`; newer payloads name it `workspaceId`. +function withV1WorktreeId(payload: unknown): unknown { + if (typeof payload !== 'object' || payload === null || !('workspaceId' in payload)) { + return payload + } + return 'worktreeId' in payload ? payload : { ...payload, worktreeId: payload.workspaceId } +} + +export type PluginHostCallError = Error & { + code: PluginErrorCode + retryable: boolean + retryAfterMs?: number +} + +export type PluginDisposable = { dispose(): void } + +/** API surface handed to a plugin's `activate(orca)` export (host API 1). */ +export type PluginWorkerOrcaApi = { + api: { readonly major: number; readonly minor: number } + commands: { + register( + commandId: string, + handler: (args: unknown, context: { signal: AbortSignal }) => unknown + ): PluginDisposable + } + events: { + on( + event: PluginEventName, + handler: (payload: unknown) => void | Promise + ): PluginDisposable + } + host: { + call(method: string, params?: unknown, options?: { signal?: AbortSignal }): Promise + /** Whether this host implements `method` for workers. */ + has(method: string): boolean + } + /** Consented capability kinds (informational — the host re-gates). */ + grantedCapabilities: readonly string[] + log(message: string): void +} + +export type PluginWorkerRuntimeOptions = { + send: (message: PluginEnvelope) => void + importModule?: (specifier: string) => Promise + exit?: (code: number) => void +} + +export type PluginWorkerRuntime = { + handleMessage(raw: unknown): Promise +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? (error.stack ?? error.message) : String(error) +} + +function hostCallError(error: PluginResponse['error']): PluginHostCallError { + return Object.assign(new Error(error?.message ?? 'host call failed'), { + code: normalizePluginErrorCode(error?.code), + retryable: error?.retryable === true, + ...(error?.retryAfterMs === undefined ? {} : { retryAfterMs: error.retryAfterMs }) + }) +} + +const CANCELLED = pluginError('cancelled', 'cancelled') + +const WORKER_METHOD_NAMES: ReadonlySet = new Set(pluginHostMethodsFor('worker')) + +export function createPluginWorkerRuntime( + options: PluginWorkerRuntimeOptions +): PluginWorkerRuntime { + const send = options.send + const importModule = options.importModule ?? ((specifier: string) => import(specifier)) + const exit = options.exit ?? ((code: number) => process.exit(code)) + const commandHandlers = new Map< + string, + (args: unknown, context: { signal: AbortSignal }) => unknown + >() + const eventHandlers = new Map void | Promise>>() + const pendingHostCalls = new Map void>() + const runningCommands = new Map() + let nextHostCallId = 0 + let initialized = false + let shuttingDown = false + let deactivate: (() => unknown) | null = null + + function hostCall(method: string, params: unknown, signal?: AbortSignal): Promise { + if (signal?.aborted) { + return Promise.reject(hostCallError(CANCELLED)) + } + const id = nextHostCallId++ + return new Promise((resolve, reject) => { + const abort = (): void => { + pendingHostCalls.delete(id) + send(pluginCancel(id)) + reject(hostCallError(CANCELLED)) + } + signal?.addEventListener('abort', abort, { once: true }) + pendingHostCalls.set(id, (response) => { + signal?.removeEventListener('abort', abort) + if (response.error) { + reject(hostCallError(response.error)) + } else { + resolve(response.result) + } + }) + send(pluginRequest(id, method, params)) + }) + } + + async function handleInit(input: PluginWorkerInit): Promise { + if (initialized) { + send(pluginWorkerLog('warn', 'ignoring duplicate init message')) + return + } + initialized = true + // File URLs support Windows ESM imports; split either separator for cross-platform manifests. + const entryUrl = pathToFileURL(join(input.pluginRoot, ...input.mainEntry.split(/[\\/]/))).href + const module: unknown = await importModule(entryUrl) + const activate = readExport(module, 'default') + if (typeof activate !== 'function') { + throw new Error(`plugin entry ${input.mainEntry} has no default-exported activate function`) + } + const deactivateExport = readExport(module, 'deactivate') + if (deactivateExport !== undefined && typeof deactivateExport !== 'function') { + throw new Error(`plugin entry ${input.mainEntry} has a non-function deactivate export`) + } + deactivate = deactivateExport === undefined ? null : () => deactivateExport() + const orca: PluginWorkerOrcaApi = Object.freeze({ + api: Object.freeze({ major: PLUGIN_HOST_API_MAJOR, minor: PLUGIN_HOST_API_MINOR }), + commands: Object.freeze({ + register( + commandId: string, + handler: Parameters[1] + ) { + commandHandlers.set(commandId, handler) + return { + dispose: () => { + commandHandlers.delete(commandId) + } + } + } + }), + events: Object.freeze({ + on(event: PluginEventName, handler: (payload: unknown) => void | Promise) { + // Why: format 1 workers still listen under the old worktree.* names. + const name = PLUGIN_V1_EVENT_ALIASES.get(event) ?? event + const handlers = eventHandlers.get(name) ?? new Set() + eventHandlers.set(name, handlers) + const v1Payload = name !== event || event === 'agent.status.changed' + const entry = (payload: unknown): void | Promise => + handler(v1Payload ? withV1WorktreeId(payload) : payload) + handlers.add(entry) + return { + dispose: () => { + handlers.delete(entry) + } + } + } + }), + host: Object.freeze({ + call: (method: string, params?: unknown, callOptions?: { signal?: AbortSignal }) => + hostCall(method, params, callOptions?.signal), + has: (method: string) => WORKER_METHOD_NAMES.has(method) + }), + grantedCapabilities: Object.freeze([...input.grantedCapabilities]), + log(message: string) { + send(pluginWorkerLog('info', String(message))) + } + }) + await activate(orca) + send(pluginEvent(WORKER_EVENTS.ready, { commands: [...commandHandlers.keys()] })) + } + + async function handleRequest(request: PluginRequest): Promise { + if (request.method === WORKER_METHODS.invokeCommand) { + const parsed = pluginWorkerInvokeParamsSchema.safeParse(request.params) + const handler = parsed.success ? commandHandlers.get(parsed.data.commandId) : undefined + if (!parsed.success || !handler) { + const commandId = parsed.success ? parsed.data.commandId : '(invalid)' + send( + pluginFailure( + request.id, + pluginError('unknown_method', `no handler registered for command ${commandId}`) + ) + ) + return + } + const controller = new AbortController() + runningCommands.set(request.id, controller) + try { + const value = await handler(parsed.data.args, { signal: controller.signal }) + send(pluginResult(request.id, value)) + } catch (error) { + send(pluginFailure(request.id, pluginError('action_failed', toErrorMessage(error)))) + } finally { + runningCommands.delete(request.id) + } + return + } + if (request.method === WORKER_METHODS.deliverEvent) { + const parsed = pluginWorkerDeliverParamsSchema.safeParse(request.params) + for (const handler of parsed.success ? (eventHandlers.get(parsed.data.event) ?? []) : []) { + try { + await handler(parsed.success ? parsed.data.payload : undefined) + } catch (error) { + send(pluginWorkerLog('error', toErrorMessage(error))) + } + } + send(pluginResult(request.id, null)) + return + } + send( + pluginFailure(request.id, pluginError('unknown_method', `unknown request ${request.method}`)) + ) + } + + async function shutdown(): Promise { + if (shuttingDown) { + return + } + shuttingDown = true + try { + await deactivate?.() + } catch (error) { + send(pluginWorkerLog('error', toErrorMessage(error))) + } + exit(0) + } + + return { + async handleMessage(raw) { + const message = readPluginEnvelope(raw) + if (!message) { + send(pluginWorkerLog('warn', 'ignoring malformed parent message')) + return + } + try { + switch (message.kind) { + case 'evt': { + if (message.event === WORKER_EVENTS.shutdown) { + await shutdown() + return + } + const init = + message.event === WORKER_EVENTS.init + ? pluginWorkerInitSchema.safeParse(message.payload) + : null + if (init?.success) { + await handleInit(init.data) + } else { + send(pluginWorkerLog('warn', 'ignoring malformed parent message')) + } + return + } + case 'req': + await handleRequest(message) + return + case 'res': { + const settle = pendingHostCalls.get(message.id) + pendingHostCalls.delete(message.id) + settle?.(message) + return + } + case 'cancel': + runningCommands.get(message.id)?.abort() + } + } catch (error) { + // Failed activation cannot become ready; report and exit without waiting for the parent timeout. + send(pluginWorkerFatal(toErrorMessage(error))) + exit(1) + } + } + } +} + +function readExport(module: unknown, name: 'default' | 'deactivate'): unknown { + return typeof module === 'object' && module !== null && name in module ? module[name] : undefined +} diff --git a/src/main/plugins/plugin-host-service-bindings.ts b/src/main/plugins/workers/plugin-host-service-bindings.ts similarity index 84% rename from src/main/plugins/plugin-host-service-bindings.ts rename to src/main/plugins/workers/plugin-host-service-bindings.ts index 682e511c885..53f5b5cd2b0 100644 --- a/src/main/plugins/plugin-host-service-bindings.ts +++ b/src/main/plugins/workers/plugin-host-service-bindings.ts @@ -1,9 +1,8 @@ -import type { PluginEventName } from '../../shared/plugins/plugin-manifest' -import { PLUGIN_WORKSPACE_TERMINAL_LIMIT } from '../../shared/plugins/plugin-host-api' +import { PLUGIN_WORKSPACE_TERMINAL_LIMIT } from '../../../shared/plugins/workers/plugin-host-api' import type { PluginHostServices } from './plugin-host-methods' -import { PluginSecretsStore } from './plugin-secrets-store' -import { PluginKvStore } from './plugin-storage-store' -import type { TerminalInputKind } from '../../shared/terminal-input-kind' +import { PluginSecretsStore } from '../plugin-secrets-store' +import { PluginKvStore } from '../plugin-storage-store' +import type { TerminalInputKind } from '../../../shared/terminal-input-kind' export type PluginRuntimeDelegate = { resolveActiveWorktreeContext(): Promise<{ @@ -32,9 +31,8 @@ export type PluginRuntimeDelegate = { export function bindPluginHostServices(input: { delegate: PluginRuntimeDelegate pluginsDataDir: string - subscribeEvents: (pluginKey: string, events: PluginEventName[]) => PluginEventName[] }): PluginHostServices { - const { delegate, pluginsDataDir, subscribeEvents } = input + const { delegate, pluginsDataDir } = input return { resolveActiveWorktreeContext: async () => { const context = await delegate.resolveActiveWorktreeContext() @@ -80,7 +78,6 @@ export function bindPluginHostServices(input: { getAll: (key) => new PluginKvStore(pluginsDataDir, key, 'settings.json').getAll(), set: (key, itemKey, value) => new PluginKvStore(pluginsDataDir, key, 'settings.json').set(itemKey, value) - }, - subscribeEvents + } } } diff --git a/src/main/plugins/plugin-supervisor.ts b/src/main/plugins/workers/plugin-supervisor.ts similarity index 100% rename from src/main/plugins/plugin-supervisor.ts rename to src/main/plugins/workers/plugin-supervisor.ts diff --git a/src/main/plugins/plugin-worker-controller.test.ts b/src/main/plugins/workers/plugin-worker-controller.test.ts similarity index 94% rename from src/main/plugins/plugin-worker-controller.test.ts rename to src/main/plugins/workers/plugin-worker-controller.test.ts index c1a6b89a30d..53621f74a0d 100644 --- a/src/main/plugins/plugin-worker-controller.test.ts +++ b/src/main/plugins/workers/plugin-worker-controller.test.ts @@ -2,16 +2,16 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { createPluginExtensionRegistry } from '../../shared/plugins/plugin-extension-registry' -import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' -import { PluginContentVerifier } from './plugin-content-integrity' -import type { ValidDiscoveredPlugin } from './plugin-discovery' +import { createPluginExtensionRegistry } from '../../../shared/plugins/contributions/plugin-extension-registry' +import { pluginManifestSchema } from '../../../shared/plugins/manifest/plugin-manifest' +import { PluginContentVerifier } from '../plugin-content-integrity' +import type { ValidDiscoveredPlugin } from '../plugin-discovery' import type { PluginWorkerHandle } from './plugin-host-process' import { PluginWorkerController } from './plugin-worker-controller' import type { PluginWorkerFactory } from './plugin-worker-manager' -import { PluginLogBuffer } from './plugin-log-buffer' +import { PluginLogBuffer } from '../plugin-log-buffer' import { buildPluginWorkerSpawnSpec } from './plugin-worker-spawn-spec' -import { hashPluginTree } from './plugin-content-hash' +import { hashPluginTree } from '../plugin-content-hash' const roots: string[] = [] @@ -24,12 +24,12 @@ async function plugin(): Promise { roots.push(rootDir) await writeFile(join(rootDir, 'main.mjs'), 'export default function activate() {}') return { - pluginKey: 'orca-samples.demo', + pluginKey: 'samples.demo', rootDir, manifest: pluginManifestSchema.parse({ manifestVersion: 1, id: 'demo', - publisher: 'orca-samples', + publisher: 'samples', name: 'Demo', version: '1.0.0', engines: { orca: '>=1.0.0' }, @@ -82,8 +82,7 @@ function controller(options: { invokeCommand: vi.fn(async () => null), executeHostCall: vi.fn(async () => ({ ok: true as const, value: null })), log: (key) => options.logs?.capture(key) ?? vi.fn(), - onStateChanged: vi.fn(), - onWorkerGone: vi.fn() + onStateChanged: vi.fn() }) } @@ -243,7 +242,7 @@ describe('PluginWorkerController activation authority', () => { it('restarts a running worker once when its reviewed bytes change', async () => { const current = await plugin() - const idle = { ...(await plugin()), pluginKey: 'orca-samples.idle' } + const idle = { ...(await plugin()), pluginKey: 'samples.idle' } const factory = vi.fn(async () => worker(['run'])) const subject = controller({ factory, verify: async () => undefined, isApproved: () => true }) await subject.ensure(current) diff --git a/src/main/plugins/plugin-worker-controller.ts b/src/main/plugins/workers/plugin-worker-controller.ts similarity index 89% rename from src/main/plugins/plugin-worker-controller.ts rename to src/main/plugins/workers/plugin-worker-controller.ts index 577fb74b130..301cc1e9adb 100644 --- a/src/main/plugins/plugin-worker-controller.ts +++ b/src/main/plugins/workers/plugin-worker-controller.ts @@ -1,13 +1,12 @@ -import type { PluginCapabilityKind } from '../../shared/plugins/plugin-capabilities' -import type { PluginEventName } from '../../shared/plugins/plugin-manifest' -import type { PluginPanelActionOutcome } from '../../shared/plugins/plugin-panel-bridge' +import type { PluginCapabilityKind } from '../../../shared/plugins/capabilities/plugin-capabilities' +import type { PluginHostCallOutcome } from '../../../shared/plugins/frames/plugin-protocol' import { PLUGIN_COMMAND_EXTENSION_POINT, type PluginExtensionRegistry -} from '../../shared/plugins/plugin-extension-registry' -import type { ValidDiscoveredPlugin } from './plugin-discovery' -import { resolveContainedPluginArtifact } from './plugin-artifact-validation' -import type { PluginContentVerifier } from './plugin-content-integrity' +} from '../../../shared/plugins/contributions/plugin-extension-registry' +import type { ValidDiscoveredPlugin } from '../plugin-discovery' +import { resolveContainedPluginArtifact } from '../plugin-artifact-validation' +import type { PluginContentVerifier } from '../plugin-content-integrity' import { PluginWorkerManager, type PluginWorkerFactory, @@ -33,10 +32,9 @@ export type PluginWorkerControllerOptions = { pluginKey: string, method: string, params: unknown - ) => Promise + ) => Promise log: (pluginKey: string) => (level: 'info' | 'warn' | 'error', line: string) => void onStateChanged: (pluginKey: string) => void - onWorkerGone: (pluginKey: string) => void } export class PluginWorkerController { @@ -53,8 +51,7 @@ export class PluginWorkerController { workerFactory: options.workerFactory, executeHostCall: options.executeHostCall, log: options.log, - onWorkerStateChange: options.onStateChanged, - onWorkerGone: options.onWorkerGone + onWorkerStateChange: options.onStateChanged }) } @@ -174,10 +171,6 @@ export class PluginWorkerController { this.manager.reapIdle() } - deliverEventIfRunning(pluginKey: string, event: PluginEventName, payload: unknown): void { - this.manager.deliverEventIfRunning(pluginKey, event, payload) - } - dispose(): Promise { this.reconcileGeneration += 1 return this.manager.disposeAll() diff --git a/src/main/plugins/plugin-worker-env.test.ts b/src/main/plugins/workers/plugin-worker-env.test.ts similarity index 100% rename from src/main/plugins/plugin-worker-env.test.ts rename to src/main/plugins/workers/plugin-worker-env.test.ts diff --git a/src/main/plugins/plugin-worker-env.ts b/src/main/plugins/workers/plugin-worker-env.ts similarity index 100% rename from src/main/plugins/plugin-worker-env.ts rename to src/main/plugins/workers/plugin-worker-env.ts diff --git a/src/main/plugins/plugin-worker-generation-retention.ts b/src/main/plugins/workers/plugin-worker-generation-retention.ts similarity index 100% rename from src/main/plugins/plugin-worker-generation-retention.ts rename to src/main/plugins/workers/plugin-worker-generation-retention.ts diff --git a/src/main/plugins/plugin-worker-lifecycle.ts b/src/main/plugins/workers/plugin-worker-lifecycle.ts similarity index 100% rename from src/main/plugins/plugin-worker-lifecycle.ts rename to src/main/plugins/workers/plugin-worker-lifecycle.ts diff --git a/src/main/plugins/plugin-worker-manager.test.ts b/src/main/plugins/workers/plugin-worker-manager.test.ts similarity index 99% rename from src/main/plugins/plugin-worker-manager.test.ts rename to src/main/plugins/workers/plugin-worker-manager.test.ts index bfa0426e5a7..77ad829d845 100644 --- a/src/main/plugins/plugin-worker-manager.test.ts +++ b/src/main/plugins/workers/plugin-worker-manager.test.ts @@ -50,8 +50,7 @@ function manager( idleReapMs: options.idleReapMs, executeHostCall: async () => ({ ok: true, value: null }), log: () => vi.fn(), - onWorkerStateChange: vi.fn(), - onWorkerGone: vi.fn() + onWorkerStateChange: vi.fn() }) } diff --git a/src/main/plugins/plugin-worker-manager.ts b/src/main/plugins/workers/plugin-worker-manager.ts similarity index 95% rename from src/main/plugins/plugin-worker-manager.ts rename to src/main/plugins/workers/plugin-worker-manager.ts index 52c9811988d..0516b449997 100644 --- a/src/main/plugins/plugin-worker-manager.ts +++ b/src/main/plugins/workers/plugin-worker-manager.ts @@ -1,8 +1,7 @@ import { PLUGIN_WORKER_IDLE_REAP_MS, PLUGIN_WORKER_MAX_ACTIVE_DEFAULT -} from '../../shared/plugins/plugin-host-protocol' -import type { PluginEventName } from '../../shared/plugins/plugin-manifest' +} from '../../../shared/plugins/workers/plugin-host-protocol' import { PluginSupervisor, type PluginRestartDecision, @@ -39,7 +38,6 @@ export type PluginWorkerManagerOptions = { ) => ReturnType log: (pluginKey: string) => (level: 'info' | 'warn' | 'error', line: string) => void onWorkerStateChange: (pluginKey: string) => void - onWorkerGone: (pluginKey: string) => void } type ActivationRecord = { @@ -198,7 +196,6 @@ export class PluginWorkerManager { context: string, error?: unknown ): PluginRestartDecision { - this.options.onWorkerGone(pluginKey) const decision = this.supervisor.markExited(pluginKey, { crashed: true }) this.options.onWorkerStateChange(pluginKey) if (decision.restart) { @@ -221,10 +218,6 @@ export class PluginWorkerManager { return true } - deliverEventIfRunning(pluginKey: string, event: PluginEventName, payload: unknown): void { - this.workers.get(pluginKey)?.handle.deliverEvent(event, payload) - } - async deactivate(pluginKey: string): Promise { this.nextGeneration(pluginKey) const activation = this.activations.get(pluginKey) @@ -233,7 +226,6 @@ export class PluginWorkerManager { if (record) { this.workers.delete(pluginKey) } - this.options.onWorkerGone(pluginKey) this.supervisor.reset(pluginKey) this.known.delete(pluginKey) await Promise.all([ @@ -255,7 +247,6 @@ export class PluginWorkerManager { this.nextGeneration(pluginKey) this.known.delete(pluginKey) this.workers.delete(pluginKey) - this.options.onWorkerGone(pluginKey) this.supervisor.markExited(pluginKey, { crashed: false }) this.options.log(pluginKey)('info', 'worker reaped after idle period') this.options.onWorkerStateChange(pluginKey) @@ -276,7 +267,6 @@ export class PluginWorkerManager { const pluginKeys = new Set([...this.activations.keys(), ...this.workers.keys()]) for (const key of pluginKeys) { this.nextGeneration(key) - this.options.onWorkerGone(key) } const activations = [...this.activations.values()] for (const activation of activations) { diff --git a/src/main/plugins/plugin-worker-output-buffer.test.ts b/src/main/plugins/workers/plugin-worker-output-buffer.test.ts similarity index 100% rename from src/main/plugins/plugin-worker-output-buffer.test.ts rename to src/main/plugins/workers/plugin-worker-output-buffer.test.ts diff --git a/src/main/plugins/plugin-worker-output-buffer.ts b/src/main/plugins/workers/plugin-worker-output-buffer.ts similarity index 96% rename from src/main/plugins/plugin-worker-output-buffer.ts rename to src/main/plugins/workers/plugin-worker-output-buffer.ts index 6a0cb2a078b..395e2266d53 100644 --- a/src/main/plugins/plugin-worker-output-buffer.ts +++ b/src/main/plugins/workers/plugin-worker-output-buffer.ts @@ -1,5 +1,5 @@ import type { Readable } from 'node:stream' -import { ownRetainedString } from '../../shared/own-retained-string' +import { ownRetainedString } from '../../../shared/own-retained-string' type PluginWorkerOutputSink = (level: 'info' | 'warn' | 'error', line: string) => void diff --git a/src/main/plugins/plugin-worker-output-retention.test.ts b/src/main/plugins/workers/plugin-worker-output-retention.test.ts similarity index 97% rename from src/main/plugins/plugin-worker-output-retention.test.ts rename to src/main/plugins/workers/plugin-worker-output-retention.test.ts index dab4f76afdb..8e475d4e766 100644 --- a/src/main/plugins/plugin-worker-output-retention.test.ts +++ b/src/main/plugins/workers/plugin-worker-output-retention.test.ts @@ -1,7 +1,7 @@ import { once } from 'node:events' import { PassThrough } from 'node:stream' import { describe, expect, it } from 'vitest' -import { PLUGIN_LOG_KEY_LIMIT, PluginLogBuffer } from './plugin-log-buffer' +import { PLUGIN_LOG_KEY_LIMIT, PluginLogBuffer } from '../plugin-log-buffer' import { pipePluginWorkerOutput } from './plugin-worker-output-buffer' async function heapAfterGc(): Promise { diff --git a/src/main/plugins/plugin-worker-reconciliation.ts b/src/main/plugins/workers/plugin-worker-reconciliation.ts similarity index 81% rename from src/main/plugins/plugin-worker-reconciliation.ts rename to src/main/plugins/workers/plugin-worker-reconciliation.ts index c6933fbf82f..f433ceab92a 100644 --- a/src/main/plugins/plugin-worker-reconciliation.ts +++ b/src/main/plugins/workers/plugin-worker-reconciliation.ts @@ -1,6 +1,6 @@ -import { capabilityKinds } from '../../shared/plugins/plugin-capabilities' -import type { DiscoveredPlugin, ValidDiscoveredPlugin } from './plugin-discovery' -import { isInvalidDiscoveredPlugin } from './plugin-discovery' +import { capabilityKinds } from '../../../shared/plugins/capabilities/plugin-capabilities' +import type { DiscoveredPlugin, ValidDiscoveredPlugin } from '../plugin-discovery' +import { isInvalidDiscoveredPlugin } from '../plugin-discovery' import type { PluginWorkerSpawnSpec } from './plugin-worker-manager' import { buildPluginWorkerSpawnSpec, pluginWorkerSpawnSpecsEqual } from './plugin-worker-spawn-spec' diff --git a/src/main/plugins/plugin-worker-refresh-admission.test.ts b/src/main/plugins/workers/plugin-worker-refresh-admission.test.ts similarity index 93% rename from src/main/plugins/plugin-worker-refresh-admission.test.ts rename to src/main/plugins/workers/plugin-worker-refresh-admission.test.ts index 3cbd67ac0f6..8471dbf56ba 100644 --- a/src/main/plugins/plugin-worker-refresh-admission.test.ts +++ b/src/main/plugins/workers/plugin-worker-refresh-admission.test.ts @@ -2,11 +2,12 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' -import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' -import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' -import { hashPluginTree } from './plugin-content-hash' +import { fingerprintPluginConsent } from '../../../shared/plugins/approval/plugin-consent-fingerprint' +import { devFolderConsentIdentity } from '../plugin-discovery' +import { pluginManifestSchema } from '../../../shared/plugins/manifest/plugin-manifest' +import { hashPluginTree } from '../plugin-content-hash' import type { PluginWorkerHandle } from './plugin-host-process' -import { PluginService } from './plugin-service' +import { PluginService } from '../plugin-service' import type { PluginWorkerFactory } from './plugin-worker-manager' const roots: string[] = [] @@ -59,7 +60,7 @@ async function harness() { if (!hash.ok) { throw new Error(hash.error) } - let consent = fingerprintPluginConsent(manifest, hash.hash) + let consent = fingerprintPluginConsent(manifest, await devFolderConsentIdentity(root, hash.hash)) let disabled: string[] = [] let devPaths = [root] const first = worker() diff --git a/src/main/plugins/plugin-worker-restart-loop.ts b/src/main/plugins/workers/plugin-worker-restart-loop.ts similarity index 100% rename from src/main/plugins/plugin-worker-restart-loop.ts rename to src/main/plugins/workers/plugin-worker-restart-loop.ts diff --git a/src/main/plugins/plugin-worker-reviewed-content.test.ts b/src/main/plugins/workers/plugin-worker-reviewed-content.test.ts similarity index 93% rename from src/main/plugins/plugin-worker-reviewed-content.test.ts rename to src/main/plugins/workers/plugin-worker-reviewed-content.test.ts index 171dc0c81ba..34573b3f157 100644 --- a/src/main/plugins/plugin-worker-reviewed-content.test.ts +++ b/src/main/plugins/workers/plugin-worker-reviewed-content.test.ts @@ -2,8 +2,8 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' -import { hashPluginTree } from './plugin-content-hash' -import { PluginContentVerifier } from './plugin-content-integrity' +import { hashPluginTree } from '../plugin-content-hash' +import { PluginContentVerifier } from '../plugin-content-integrity' import type { PluginWorkerHandle } from './plugin-host-process' import { PluginWorkerManager, type PluginWorkerFactory } from './plugin-worker-manager' @@ -44,8 +44,7 @@ it('refuses a crash restart when reviewed bytes changed after the first start', workerFactory: factory, executeHostCall: async () => ({ ok: true, value: null }), log: () => (_level, line) => lines.push(line), - onWorkerStateChange: vi.fn(), - onWorkerGone: vi.fn() + onWorkerStateChange: vi.fn() }) const verifier = new PluginContentVerifier() const spec = { diff --git a/src/main/plugins/plugin-worker-slot-pool.ts b/src/main/plugins/workers/plugin-worker-slot-pool.ts similarity index 100% rename from src/main/plugins/plugin-worker-slot-pool.ts rename to src/main/plugins/workers/plugin-worker-slot-pool.ts diff --git a/src/main/plugins/plugin-worker-spawn-spec.ts b/src/main/plugins/workers/plugin-worker-spawn-spec.ts similarity index 89% rename from src/main/plugins/plugin-worker-spawn-spec.ts rename to src/main/plugins/workers/plugin-worker-spawn-spec.ts index 4a0fc68661e..a336aafd1e4 100644 --- a/src/main/plugins/plugin-worker-spawn-spec.ts +++ b/src/main/plugins/workers/plugin-worker-spawn-spec.ts @@ -1,5 +1,5 @@ -import type { PluginCapabilityKind } from '../../shared/plugins/plugin-capabilities' -import type { ValidDiscoveredPlugin } from './plugin-discovery' +import type { PluginCapabilityKind } from '../../../shared/plugins/capabilities/plugin-capabilities' +import type { ValidDiscoveredPlugin } from '../plugin-discovery' import type { PluginWorkerSpawnSpec } from './plugin-worker-startup' export function buildPluginWorkerSpawnSpec( diff --git a/src/main/plugins/plugin-worker-startup.ts b/src/main/plugins/workers/plugin-worker-startup.ts similarity index 97% rename from src/main/plugins/plugin-worker-startup.ts rename to src/main/plugins/workers/plugin-worker-startup.ts index fd188410dee..16022e5bf49 100644 --- a/src/main/plugins/plugin-worker-startup.ts +++ b/src/main/plugins/workers/plugin-worker-startup.ts @@ -1,4 +1,4 @@ -import type { PluginCapabilityKind } from '../../shared/plugins/plugin-capabilities' +import type { PluginCapabilityKind } from '../../../shared/plugins/capabilities/plugin-capabilities' import { startPluginWorker, type PluginWorkerHandle, diff --git a/src/main/plugins/plugin-worker-supervision.integration.test.ts b/src/main/plugins/workers/plugin-worker-supervision.integration.test.ts similarity index 96% rename from src/main/plugins/plugin-worker-supervision.integration.test.ts rename to src/main/plugins/workers/plugin-worker-supervision.integration.test.ts index c624fc5da7c..6d4e9c2eab3 100644 --- a/src/main/plugins/plugin-worker-supervision.integration.test.ts +++ b/src/main/plugins/workers/plugin-worker-supervision.integration.test.ts @@ -56,7 +56,7 @@ beforeAll(async () => { bundleRoot = await mkdtemp(join(tmpdir(), 'orca-plugin-host-bundle-')) hostEntryPath = join(bundleRoot, 'plugin-host-entry.cjs') await build({ - entryPoints: [join(process.cwd(), 'src', 'main', 'plugins', 'plugin-host-entry.ts')], + entryPoints: [join(process.cwd(), 'src', 'main', 'plugins', 'workers', 'plugin-host-entry.ts')], outfile: hostEntryPath, bundle: true, platform: 'node', @@ -85,7 +85,7 @@ async function createPluginSpec( pluginRoots.push(rootDir) await writeFile(join(rootDir, 'main.mjs'), source) return { - pluginKey: 'orca-samples.supervision', + pluginKey: 'samples.supervision', rootDir, mainEntry: 'main.mjs', grantedCapabilities: [] @@ -109,8 +109,7 @@ describe('real plugin worker supervision', () => { entryPath: hostEntryPath, executeHostCall: async () => ({ ok: true, value: null }), log: () => vi.fn(), - onWorkerStateChange: notifications.notify, - onWorkerGone: vi.fn() + onWorkerStateChange: notifications.notify }) managers.push(manager) @@ -133,8 +132,7 @@ describe('real plugin worker supervision', () => { entryPath: hostEntryPath, executeHostCall: async () => ({ ok: true, value: null }), log: (_pluginKey) => (level, line) => logs.push({ at: performance.now(), level, line }), - onWorkerStateChange: notifications.notify, - onWorkerGone: vi.fn() + onWorkerStateChange: notifications.notify }) managers.push(manager) diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-live-update.ts b/src/main/runtime/agent-state-rules/agent-state-rules-live-update.ts index 82d9dc14d3f..3a554b1fcdb 100644 --- a/src/main/runtime/agent-state-rules/agent-state-rules-live-update.ts +++ b/src/main/runtime/agent-state-rules/agent-state-rules-live-update.ts @@ -5,7 +5,7 @@ import { readFetchResponseTextWithinLimit } from '../../../shared/fetch-response import type { GlobalSettings } from '../../../shared/global-settings-types' import { getVersionChannel, MAIN_RELEASE_REPO } from '../../../shared/release-channel' import { getMainHttpClient } from '../../network/http-client' -import { writePluginFileAtomically } from '../../plugins/plugin-atomic-file-write' +import { writePluginFileAtomically } from '../../plugins/installation/plugin-atomic-file-write' import { activateAgentStateRules, BUNDLED_AGENT_STATE_RULES, diff --git a/src/main/runtime/files/file-mutations.ts b/src/main/runtime/files/file-mutations.ts index 06be66f38e9..433fd0bfaf2 100644 --- a/src/main/runtime/files/file-mutations.ts +++ b/src/main/runtime/files/file-mutations.ts @@ -9,6 +9,8 @@ import type { RuntimeFileCommandHost } from './file-command-host' import { resolveRuntimeAuthorizedPath } from './chat-scratch-file-root' import { constants, copyFile, mkdir, rm } from 'node:fs/promises' import { dirname } from 'node:path' +import type { FileBytesWritePrecondition } from '../../../shared/file-bytes-write' +import { replaceLocalFileBytesIfUnchanged } from '../../ipc/filesystem/local-disk-bytes-replace' import type { FileWriteDiskPrecondition, FileWriteResult @@ -171,6 +173,41 @@ export class RuntimeFileMutations { return { ok: true } } + /** Atomically replaces a file with an uploaded temp file unless its bytes changed meanwhile. */ + async replaceFileExplorerFileWithUpload( + worktreeSelector: string, + tempRelativePath: string, + finalRelativePath: string, + precondition: FileBytesWritePrecondition, + expectedSshConnectionGeneration?: number, + expectedSshTargetId?: string, + expectedExecutionHostId?: string + ): Promise { + const [tempTarget, finalTarget] = await resolveFileExplorerPaths(this.host, worktreeSelector, [ + tempRelativePath, + finalRelativePath + ]) + assertRuntimeFileMutationExpectation( + tempTarget.executionHostId, + expectedExecutionHostId, + expectedSshTargetId, + expectedSshConnectionGeneration + ) + requireLocalRuntimeFileRoute(tempTarget) + const store = this.host.requireStore() + const tempPath = await resolveRuntimeAuthorizedPath(tempTarget.path, store) + const finalPath = await resolveRuntimeAuthorizedPath(finalTarget.path, store) + try { + return await replaceLocalFileBytesIfUnchanged( + finalPath, + (replacement) => copyFile(tempPath, replacement), + precondition + ) + } finally { + await rm(tempPath, { force: true }) + } + } + async renameFileExplorerPath( worktreeSelector: string, oldRelativePath: string, diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index aff72b2ad3e..92fe646c064 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -34,6 +34,7 @@ export class RuntimeFileCommands { readonly createFileExplorerDir: RuntimeFileMutations['createFileExplorerDir'] readonly createFileExplorerDirNoClobber: RuntimeFileMutations['createFileExplorerDirNoClobber'] readonly commitFileExplorerUpload: RuntimeFileMutations['commitFileExplorerUpload'] + readonly replaceFileExplorerFileWithUpload: RuntimeFileMutations['replaceFileExplorerFileWithUpload'] readonly renameFileExplorerPath: RuntimeFileMutations['renameFileExplorerPath'] readonly copyFileExplorerPath: RuntimeFileMutations['copyFileExplorerPath'] readonly deleteFileExplorerPath: RuntimeFileMutations['deleteFileExplorerPath'] @@ -77,6 +78,9 @@ export class RuntimeFileCommands { this.mutations ) this.commitFileExplorerUpload = this.mutations.commitFileExplorerUpload.bind(this.mutations) + this.replaceFileExplorerFileWithUpload = this.mutations.replaceFileExplorerFileWithUpload.bind( + this.mutations + ) this.renameFileExplorerPath = this.mutations.renameFileExplorerPath.bind(this.mutations) this.copyFileExplorerPath = this.mutations.copyFileExplorerPath.bind(this.mutations) this.deleteFileExplorerPath = this.mutations.deleteFileExplorerPath.bind(this.mutations) diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 76a94e14d26..a18e493bbc1 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -586,6 +586,7 @@ describe('client UI RPC methods', () => { ['browserImportHintHidden', { browserImportHintHidden: true }], ['mobileEmulatorTabIntroDismissed', { mobileEmulatorTabIntroDismissed: true }], ['mobileEmulatorAgentSetupDismissed', { mobileEmulatorAgentSetupDismissed: true }], + ['pluginsAlphaNoticeDismissed', { pluginsAlphaNoticeDismissed: true }], ['alwaysShowDefaultBranchWorkspace', { alwaysShowDefaultBranchWorkspace: false }] ])('accepts %s, which the renderer persists through ui.set', async (_label, payload) => { const runtime = { diff --git a/src/main/runtime/rpc/methods/files-mutation-methods.ts b/src/main/runtime/rpc/methods/files-mutation-methods.ts index 96ae1ea7866..d74a25c4c52 100644 --- a/src/main/runtime/rpc/methods/files-mutation-methods.ts +++ b/src/main/runtime/rpc/methods/files-mutation-methods.ts @@ -1,6 +1,8 @@ import { defineMethod } from '../core' +import { FILE_REPLACE_WITH_UPLOAD_METHOD } from '../../../../shared/file-bytes-write' import { FileCommitUpload, + FileReplaceWithUpload, FileCopy, FileDelete, FileMutationOpen, @@ -132,6 +134,19 @@ export const FILE_MUTATION_METHODS = [ ...sshMutationArguments(params) ) }), + defineMethod({ + name: FILE_REPLACE_WITH_UPLOAD_METHOD, + permission: 'workspace', + params: FileReplaceWithUpload, + handler: async (params, { runtime }) => + runtime.replaceFileExplorerFileWithUpload( + params.worktree, + params.tempRelativePath, + params.finalRelativePath, + params.bytesPrecondition, + ...sshMutationArguments(params) + ) + }), defineMethod({ name: 'files.rename', permission: 'workspace', diff --git a/src/main/runtime/rpc/methods/plugins.test.ts b/src/main/runtime/rpc/methods/plugins.test.ts index e44570bab56..902c23810b4 100644 --- a/src/main/runtime/rpc/methods/plugins.test.ts +++ b/src/main/runtime/rpc/methods/plugins.test.ts @@ -28,7 +28,7 @@ describe('plugin panel serve RPC identity', () => { expect( schema.safeParse({ - pluginId: 'orca-samples.other', + pluginId: 'samples.other', unexpected: 'x'.repeat(100_000) }).success ).toBe(true) @@ -49,13 +49,13 @@ describe('plugin panel serve RPC identity', () => { await expect( method('plugins.readPanelEntry').handler( - { pluginKey: 'orca-samples.demo', panelId: 'dashboard' }, + { pluginKey: 'samples.demo', panelId: 'dashboard' }, rpcContext ) ).resolves.toEqual({ html: '

panel

', sessionToken: SESSION_TOKEN }) expect(service.panels.open).toHaveBeenCalledWith( 'runtime:connection-one', - 'orca-samples.demo', + 'samples.demo', 'dashboard' ) diff --git a/src/main/runtime/rpc/methods/plugins.ts b/src/main/runtime/rpc/methods/plugins.ts index 5c4fe6fc27a..f0be67bddc6 100644 --- a/src/main/runtime/rpc/methods/plugins.ts +++ b/src/main/runtime/rpc/methods/plugins.ts @@ -1,12 +1,12 @@ import { defineMethod, type RpcContext } from '../core' -import type { PluginPanelEntry } from '../../../../shared/plugins/plugin-panel-bridge' +import type { PluginFrameEntry } from '../../../../shared/plugins/frames/plugin-frame-bridge' import { listPluginsForClients } from '../../../plugins/plugin-client-list' -import type { PluginListEntry } from '../../../plugins/plugin-list-projection' +import type { PluginHostListEntry } from '../../../../shared/plugins/plugin-list-entry' import type { PluginService } from '../../../plugins/plugin-service' import { pluginConsentRequestSchema, type PluginConsentRequest -} from '../../../../shared/plugins/plugin-consent-request' +} from '../../../../shared/plugins/approval/plugin-consent-request' import { PluginInvokeCommandParams, PluginReadPanelEntryParams, @@ -42,7 +42,7 @@ function requirePluginService(): PluginService { return pluginServiceForRpc } -async function listForRpc(): Promise { +async function listForRpc(): Promise { return listPluginsForClients(requirePluginService()) } @@ -111,7 +111,7 @@ export const PLUGIN_METHODS = [ name: 'plugins.readPanelEntry', permission: 'workspace', params: PluginReadPanelEntryParams, - handler: async (params, context): Promise => { + handler: async (params, context): Promise => { const service = requirePluginService() await service.whenReady() const ownerKey = bindRpcPanelOwner(service, context) diff --git a/src/main/runtime/runtime-file-command-surface.ts b/src/main/runtime/runtime-file-command-surface.ts index 31ced80ddff..7fc6cd7ce45 100644 --- a/src/main/runtime/runtime-file-command-surface.ts +++ b/src/main/runtime/runtime-file-command-surface.ts @@ -25,6 +25,7 @@ type RuntimeFileCommandName = | 'createFileExplorerDir' | 'createFileExplorerDirNoClobber' | 'commitFileExplorerUpload' + | 'replaceFileExplorerFileWithUpload' | 'renameFileExplorerPath' | 'copyFileExplorerPath' | 'deleteFileExplorerPath' @@ -66,6 +67,7 @@ export function installRuntimeFileCommandSurface( createFileExplorerDir: commands.createFileExplorerDir.bind(commands), createFileExplorerDirNoClobber: commands.createFileExplorerDirNoClobber.bind(commands), commitFileExplorerUpload: commands.commitFileExplorerUpload.bind(commands), + replaceFileExplorerFileWithUpload: commands.replaceFileExplorerFileWithUpload.bind(commands), renameFileExplorerPath: commands.renameFileExplorerPath.bind(commands), copyFileExplorerPath: commands.copyFileExplorerPath.bind(commands), deleteFileExplorerPath: commands.deleteFileExplorerPath.bind(commands), diff --git a/src/main/startup/main-process-ipc-bootstrap.ts b/src/main/startup/main-process-ipc-bootstrap.ts index 77e3e103f01..b2f1a3b0e1a 100644 --- a/src/main/startup/main-process-ipc-bootstrap.ts +++ b/src/main/startup/main-process-ipc-bootstrap.ts @@ -80,6 +80,7 @@ export function registerMainProcessIpcHandlers(): void { quitAfterRendererStoppedAllSessions(event.sender.id) ) ipcMain.handle('ui:consumePendingSkillShare', () => state.skillShareDeepLinks.consume()) + ipcMain.handle('ui:consumePendingPluginInstallLink', () => state.pluginInstallLinks.consume()) // Pull delivers cold-start Open With and proves the listener is ready for later pushes. ipcMain.handle('ui:consumePendingMarkdownFileOpens', async () => { state.osDocumentOpenListenerReady = true diff --git a/src/main/startup/main-process-plugins.ts b/src/main/startup/main-process-plugins.ts index 56a74f558b9..e119fbe9197 100644 --- a/src/main/startup/main-process-plugins.ts +++ b/src/main/startup/main-process-plugins.ts @@ -1,21 +1,29 @@ -import { app, BrowserWindow } from 'electron' +import { join } from 'node:path' +import { PluginImageCache } from '../plugins/plugin-image-cache' +import { installPluginImageProtocolHandler } from '../plugins/plugin-image-protocol' +import { pluginHostVersion } from '../../shared/plugins/installation/plugin-host-version' +import { app, BrowserWindow, net } from 'electron' import { performance } from 'node:perf_hooks' import { PluginService } from '../plugins/plugin-service' -import { PluginKillListService } from '../plugins/plugin-kill-list-service' -import { PluginMarketplaceService } from '../plugins/plugin-marketplace-service' -import { PluginMarketplaceInstaller } from '../plugins/plugin-marketplace-installer' +import { PluginKillListService } from '../plugins/catalog/plugin-kill-list-service' +import { PluginMarketplaceService } from '../plugins/catalog/plugin-marketplace-service' +import { PluginMarketplaceInstaller } from '../plugins/catalog/plugin-marketplace-installer' +import { PluginAutoUpdater } from '../plugins/catalog/plugin-auto-updater' +import { PluginReleaseFirstSeenStore } from '../plugins/catalog/plugin-release-first-seen-store' +import { isPluginAutoUpdateOn } from '../../shared/plugins/catalog/plugin-update-policy' import { PluginBundledBootstrapCoordinator } from '../plugins/plugin-bundled-bootstrap-coordinator' import { getPluginsDataDir } from '../plugins/plugin-discovery' import { resolveBundledPluginRoot } from '../plugins/plugin-bundled-bootstrap' -import { resolvePluginHostEntryPath } from '../plugins/plugin-host-process' +import { resolvePluginHostEntryPath } from '../plugins/workers/plugin-host-process' import { applyPluginConsent, applyPluginEnablement } from '../plugins/plugin-enablement' import { setPluginServiceForRpc } from '../runtime/rpc/methods/plugins' import { normalizePluginConsents, normalizePluginIdList -} from '../../shared/plugins/plugin-consent-state' +} from '../../shared/plugins/approval/plugin-consent-state' import { projectPluginAgentStatusChangedPayload } from '../plugins/plugin-agent-status-event' import { setMainPluginLanguagePacks, setMainUiLanguage } from '../i18n/main-i18n' +import { isUsablePluginLanguagePack } from '../../shared/plugins/languages/plugin-language-pack-coverage' import { rebuildAppMenu } from '../menu/register-app-menu' import { rebuildSystemTrayMenu } from '../tray/system-tray' import { logStartupMilestone } from './startup-diagnostics' @@ -24,6 +32,10 @@ import { emitPluginWorktreeLifecycle } from './main-process-pty-startup' import { mainProcessState as state } from './main-process-state' import type { OrcaRuntimeService } from '../runtime/orca-runtime' +const PLUGIN_AUTO_UPDATE_STARTUP_DELAY_MS = 60_000 +// Why: the official catalog fetch must not compete with first paint; opening the marketplace seeds sooner. +const PLUGIN_MARKETPLACE_SEED_STARTUP_DELAY_MS = 15_000 + export async function initializeMainProcessPlugins(runtime: OrcaRuntimeService): Promise { const store = state.store const keybindings = state.keybindings @@ -35,9 +47,14 @@ export async function initializeMainProcessPlugins(runtime: OrcaRuntimeService): pluginsDataDir: getPluginsDataDir(app.getPath('userData')) }) await state.pluginKillListService.initialize() + const pluginImageCache = new PluginImageCache( + join(getPluginsDataDir(app.getPath('userData')), 'image-cache') + ) state.pluginMarketplaceService = new PluginMarketplaceService({ + imageCache: pluginImageCache, pluginsDataDir: getPluginsDataDir(app.getPath('userData')), - getKillListEntry: (pluginKey) => state.pluginKillListService?.find(pluginKey) ?? null + getKillListEntry: (pluginKey) => state.pluginKillListService?.find(pluginKey) ?? null, + hostVersion: pluginHostVersion(app.getVersion()) }) const requestOfficialMarketplaceSeed = (): void => { if (store.getSettings().pluginSystemEnabled !== true) { @@ -52,13 +69,15 @@ export async function initializeMainProcessPlugins(runtime: OrcaRuntimeService): state.pluginMarketplaceInstaller = new PluginMarketplaceInstaller({ marketplace: state.pluginMarketplaceService, userDataPath: app.getPath('userData'), - hostVersion: app.getVersion(), - blockedPluginReason: (pluginKey) => state.pluginKillListService?.reason(pluginKey) ?? null + hostVersion: pluginHostVersion(app.getVersion()), + blockedPluginReason: (pluginKey) => state.pluginKillListService?.reason(pluginKey) ?? null, + imageCache: pluginImageCache }) state.pluginService = new PluginService({ userDataPath: app.getPath('userData'), - hostVersion: app.getVersion(), - // Disabled discovery must run no plugin code. + hostVersion: pluginHostVersion(app.getVersion()), + // Feature flag: with the setting off, discovery returns nothing and no + // plugin code path runs at all. isPluginSystemEnabled: () => state.store?.getSettings().pluginSystemEnabled === true, getDisabledPlugins: () => normalizePluginIdList(state.store?.getSettings().disabledPlugins), getPluginConsents: () => normalizePluginConsents(state.store?.getSettings().pluginConsents), @@ -67,6 +86,58 @@ export async function initializeMainProcessPlugins(runtime: OrcaRuntimeService): getPluginKillListEntry: (pluginKey) => state.pluginKillListService?.find(pluginKey) ?? null, hostEntryPath: resolvePluginHostEntryPath(app.getAppPath(), app.isPackaged) }) + const pluginService = state.pluginService + const pluginAutoUpdater = new PluginAutoUpdater({ + pluginService, + installer: state.pluginMarketplaceInstaller, + firstSeen: new PluginReleaseFirstSeenStore(getPluginsDataDir(app.getPath('userData'))), + isEnabled: () => { + const settings = store.getSettings() + return settings.pluginSystemEnabled === true && isPluginAutoUpdateOn(settings) + }, + refreshCatalogs: () => state.pluginMarketplaceService?.refreshAll() ?? Promise.resolve(), + refreshKillList: () => state.pluginKillListService?.refresh() ?? Promise.resolve(), + approve: (pluginKey, reviewedFingerprint) => + applyPluginConsent({ + store, + pluginService, + pluginKey, + reviewedFingerprint, + decision: 'approve' + }), + onChanged: (autoUpdate, updatedNow) => { + const updated = Object.entries(updatedNow).map(([pluginKey, version]) => ({ + pluginKey, + version, + name: pluginService.findValidPlugin(pluginKey)?.manifest.name ?? pluginKey + })) + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send('plugins:autoUpdateChanged', { state: autoUpdate, updated }) + } + } + } + }) + state.pluginAutoUpdater = pluginAutoUpdater + // Why packaged only (like the safety list): dev and e2e runs must not update fixtures unasked. + const autoUpdateScheduled = app.isPackaged || process.env.ORCA_PLUGIN_AUTO_UPDATE === '1' + const syncPluginAutoUpdate = (runNow: boolean): void => { + if (!autoUpdateScheduled) { + return + } + const enabled = + store.getSettings().pluginSystemEnabled === true && isPluginAutoUpdateOn(store.getSettings()) + pluginAutoUpdater.syncPeriodicRun(enabled) + if (enabled && runNow) { + void pluginAutoUpdater + .run() + .catch((error) => console.warn('[plugins] auto-update failed:', error)) + } + } + installPluginImageProtocolHandler({ + cache: pluginImageCache, + findValidPlugin: (pluginKey) => state.pluginService?.findValidPlugin(pluginKey) ?? null + }) const bundledPluginBootstrap = new PluginBundledBootstrapCoordinator({ root: resolveBundledPluginRoot({ isPackaged: app.isPackaged, @@ -74,7 +145,7 @@ export async function initializeMainProcessPlugins(runtime: OrcaRuntimeService): appPath: app.getAppPath() }), userDataPath: app.getPath('userData'), - hostVersion: app.getVersion(), + hostVersion: pluginHostVersion(app.getVersion()), isEnabled: () => state.store?.getSettings().pluginSystemEnabled === true, blockedPluginReason: (pluginKey) => state.pluginKillListService?.reason(pluginKey) ?? null, refreshPlugins: () => state.pluginService?.refresh() ?? Promise.resolve() @@ -108,6 +179,12 @@ export async function initializeMainProcessPlugins(runtime: OrcaRuntimeService): console.warn('[plugins] failed to refresh plugin safety list; using cached state:', error) ) } + if (app.isPackaged && updates.pluginSystemEnabled !== undefined) { + state.pluginKillListService?.syncPeriodicRefresh(updates.pluginSystemEnabled === true) + } + if (updates.pluginSystemEnabled !== undefined || updates.pluginUpdatePolicy !== undefined) { + syncPluginAutoUpdate(true) + } }) // Headless RPC resolves this service too; consent shares the desktop hash-keyed write path. setPluginServiceForRpc(state.pluginService, { @@ -124,6 +201,8 @@ export async function initializeMainProcessPlugins(runtime: OrcaRuntimeService): durationMs: Number((performance.now() - pluginSystemStartupStartedAt).toFixed(2)), installedPlugins: state.pluginService?.getDiscovered().length ?? 0 }) + // Why delayed: catalog fetches and git checkouts must not compete with startup. + setTimeout(() => syncPluginAutoUpdate(true), PLUGIN_AUTO_UPDATE_STARTUP_DELAY_MS).unref?.() }) .catch((error) => console.warn('[plugins] failed to initialize plugin service:', error)) if (app.isPackaged && store.getSettings().pluginSystemEnabled === true) { @@ -132,11 +211,16 @@ export async function initializeMainProcessPlugins(runtime: OrcaRuntimeService): .catch((error) => console.warn('[plugins] failed to refresh plugin safety list; using cached state:', error) ) + state.pluginKillListService.syncPeriodicRefresh(true) } state.pluginService.onChanged((event) => { if ( event.contentPacksChanged && - setMainPluginLanguagePacks(state.pluginService?.contentPacks.languagePacks.list() ?? []) + setMainPluginLanguagePacks( + (state.pluginService?.contentPacks.languagePacks.list() ?? []).filter( + isUsablePluginLanguagePack + ) + ) ) { void setMainUiLanguage(store.getSettings().uiLanguage).then(() => { rebuildAppMenu() @@ -150,7 +234,11 @@ export async function initializeMainProcessPlugins(runtime: OrcaRuntimeService): } }) requestBundledPluginBootstrap() - requestOfficialMarketplaceSeed() + setTimeout(() => { + if (net.isOnline()) { + requestOfficialMarketplaceSeed() + } + }, PLUGIN_MARKETPLACE_SEED_STARTUP_DELAY_MS).unref?.() // Filter hook/worktree events server-side by each plugin subscription. agentHookServer.subscribeEnrichedStatus((enriched) => { const payload = projectPluginAgentStatusChangedPayload(enriched) diff --git a/src/main/startup/main-process-pty-startup.ts b/src/main/startup/main-process-pty-startup.ts index 492255780ff..b5769216bfa 100644 --- a/src/main/startup/main-process-pty-startup.ts +++ b/src/main/startup/main-process-pty-startup.ts @@ -34,17 +34,19 @@ import { logStartupMilestone } from './startup-diagnostics' import type { WindowsDesktopStartupServices } from './windows-desktop-shell-path-startup' import type { RuntimeWorktreeLifecycleEvent } from '../runtime/orca-runtime' import { mainProcessState as state } from './main-process-state' +import { shortPluginBranchName } from '../../shared/plugins/events/plugin-events' export function emitPluginWorktreeLifecycle(event: RuntimeWorktreeLifecycleEvent): void { + const identity = { workspaceId: event.worktreeId, kind: 'worktree', path: event.path } // Public worktree events retain their Git-only scope. if (parseWorkspaceKey(event.worktreeId)?.type === 'folder') { return } state.pluginService?.emitEvent( - event.kind === 'created' ? 'worktree.created' : 'worktree.removed', + event.kind === 'created' ? 'workspace.created' : 'workspace.removed', event.kind === 'created' - ? { worktreeId: event.worktreeId, path: event.path, branch: event.branch } - : { worktreeId: event.worktreeId, path: event.path } + ? { ...identity, branch: shortPluginBranchName(event.branch) } + : identity ) } diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts index 1e60e750149..3df4f2a65f2 100644 --- a/src/main/startup/main-process-quit.ts +++ b/src/main/startup/main-process-quit.ts @@ -131,10 +131,16 @@ function installWillQuitHandler(): void { // Final stats flush closes working sessions synchronously; quit supplies no terminating hook to preserve their duration. state.starNag?.stop() state.automations?.stop() - // Await plugin shutdown/SIGKILL escalation or forked hosts can outlive Electron. + // Why: plugin hosts are forked children; dispose sends shutdown and + // escalates to SIGKILL so they cannot outlive the app. The promise joins + // the teardown barrier below — quitting before it resolves would let + // Electron exit first and orphan the hosts. + state.pluginKillListService?.dispose() state.pluginKillListService = null state.pluginMarketplaceService = null state.pluginMarketplaceInstaller = null + state.pluginAutoUpdater?.dispose() + state.pluginAutoUpdater = null const pluginHostShutdown = state.pluginService?.dispose() ?? Promise.resolve() const codexBackfillRecoveryShutdown = stopCodexStateDbBackfillRecoveries() stopCodexAccountSessionBridges() diff --git a/src/main/startup/main-process-state.ts b/src/main/startup/main-process-state.ts index 8420c6f65d1..c2c29582b59 100644 --- a/src/main/startup/main-process-state.ts +++ b/src/main/startup/main-process-state.ts @@ -21,9 +21,10 @@ import type { AgentAwakeService } from '../agent-awake-service' import type { CrashReportStore } from '../crash-reporting/crash-report-store' import type { AutomationService } from '../automations/service' import type { PluginService } from '../plugins/plugin-service' -import type { PluginKillListService } from '../plugins/plugin-kill-list-service' -import type { PluginMarketplaceService } from '../plugins/plugin-marketplace-service' -import type { PluginMarketplaceInstaller } from '../plugins/plugin-marketplace-installer' +import type { PluginKillListService } from '../plugins/catalog/plugin-kill-list-service' +import type { PluginAutoUpdater } from '../plugins/catalog/plugin-auto-updater' +import type { PluginMarketplaceService } from '../plugins/catalog/plugin-marketplace-service' +import type { PluginMarketplaceInstaller } from '../plugins/catalog/plugin-marketplace-installer' import type { KeybindingService } from '../keybindings/keybinding-service' import type { RelayBrokerStatus } from '../runtime/mobile-cloud-relay/relay-session-broker-contract' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' @@ -37,7 +38,7 @@ import type { createWindowsShellPathHydration } from './windows-shell-path-hydra import type { ServeOptions } from './main-process-serve' import type { HangDetectionMarker } from '../hang-watchdog/hang-detection-marker' import { ServeReadinessPublisher } from '../server/serve-readiness' -import { SkillShareDeepLinkState } from './skill-share-deep-link-state' +import { PluginInstallLinkState, SkillShareDeepLinkState } from './skill-share-deep-link-state' import { OsOpenedDocumentState } from './os-opened-documents' import { DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD, @@ -114,6 +115,8 @@ export const mainProcessState = { pluginKillListService: null as PluginKillListService | null, pluginMarketplaceService: null as PluginMarketplaceService | null, pluginMarketplaceInstaller: null as PluginMarketplaceInstaller | null, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: widens the null initial value; assigned once plugins start. + pluginAutoUpdater: null as PluginAutoUpdater | null, keybindings: null as KeybindingService | null, // Timed reload flags cannot leak to later loads; recovery did-finish-load must preserve live PTYs (#5787). expectedRendererReload: createWebContentsTimedFlag(), @@ -123,6 +126,7 @@ export const mainProcessState = { // Why: a confirmed Quit Completely can open the window; its renderer pulls this one-shot on mount. pendingQuitAndStopAllSessions: createWebContentsTimedFlag(), skillShareDeepLinks: new SkillShareDeepLinkState(), + pluginInstallLinks: new PluginInstallLinkState(), // Buffer OS Open With before any renderer listener exists. osOpenedDocuments: new OsOpenedDocumentState(), // Window existence does not prove listener readiness; only the renderer pull does, otherwise pushes silently vanish. diff --git a/src/main/startup/main-window-core-services.ts b/src/main/startup/main-window-core-services.ts index 8fedfa0b9fb..a50c72df2d2 100644 --- a/src/main/startup/main-window-core-services.ts +++ b/src/main/startup/main-window-core-services.ts @@ -117,7 +117,11 @@ export function attachMainWindowCoreServices( }, state.pluginService ?? undefined, state.pluginMarketplaceService && state.pluginMarketplaceInstaller - ? { marketplace: state.pluginMarketplaceService, installer: state.pluginMarketplaceInstaller } + ? { + marketplace: state.pluginMarketplaceService, + installer: state.pluginMarketplaceInstaller, + ...(state.pluginAutoUpdater ? { autoUpdater: state.pluginAutoUpdater } : {}) + } : undefined ) automations.setWebContents(window.webContents) diff --git a/src/main/startup/orca-deep-link-url.ts b/src/main/startup/orca-deep-link-url.ts new file mode 100644 index 00000000000..528425c05c4 --- /dev/null +++ b/src/main/startup/orca-deep-link-url.ts @@ -0,0 +1,7 @@ +import { isPluginInstallLinkArgument } from '../../shared/plugins/catalog/plugin-install-link' +import { parseSkillShareId } from '../../shared/skill-share-link' + +/** macOS open-url: claim only links Orca routes, so other schemes keep their default handling. */ +export function isRoutedOrcaDeepLink(url: string): boolean { + return parseSkillShareId(url) !== null || isPluginInstallLinkArgument(url) +} diff --git a/src/main/startup/orca-protocol-registration.test.ts b/src/main/startup/orca-protocol-registration.test.ts new file mode 100644 index 00000000000..ceab6ed7050 --- /dev/null +++ b/src/main/startup/orca-protocol-registration.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest' +import { registerOrcaProtocolClient } from './orca-protocol-registration' + +function fakeApp(overrides: { isPackaged?: boolean; isDefault?: boolean } = {}) { + return { + isPackaged: overrides.isPackaged ?? true, + isDefaultProtocolClient: vi.fn(() => overrides.isDefault ?? false), + setAsDefaultProtocolClient: vi.fn(() => true) + } +} + +describe('registerOrcaProtocolClient', () => { + it('registers orca:// for a packaged Windows desktop launch', () => { + const app = fakeApp() + expect(registerOrcaProtocolClient(app, { platform: 'win32', desktopLaunch: true })).toBe(true) + expect(app.setAsDefaultProtocolClient).toHaveBeenCalledWith('orca') + }) + + it.each([ + ['macOS', { platform: 'darwin' as const, desktopLaunch: true }, {}], + ['Linux', { platform: 'linux' as const, desktopLaunch: true }, {}], + ['dev build', { platform: 'win32' as const, desktopLaunch: true }, { isPackaged: false }], + ['background or serve launch', { platform: 'win32' as const, desktopLaunch: false }, {}], + ['already registered', { platform: 'win32' as const, desktopLaunch: true }, { isDefault: true }] + ])('leaves the handler alone on %s', (_label, options, overrides) => { + const app = fakeApp(overrides) + expect(registerOrcaProtocolClient(app, options)).toBe(false) + expect(app.setAsDefaultProtocolClient).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/startup/orca-protocol-registration.ts b/src/main/startup/orca-protocol-registration.ts new file mode 100644 index 00000000000..5adace836ed --- /dev/null +++ b/src/main/startup/orca-protocol-registration.ts @@ -0,0 +1,24 @@ +import type { App } from 'electron' + +/** + * Registers `orca://` with Windows, which NSIS installs leave unregistered; macOS reads it from + * Info.plist and Linux packages from the desktop entry. Only packaged desktop launches register, + * so dev, test and serve runs never take over the machine's handler. + */ +export function registerOrcaProtocolClient( + app: Pick, + options: { platform: NodeJS.Platform; desktopLaunch: boolean } +): boolean { + if (options.platform !== 'win32' || !app.isPackaged || !options.desktopLaunch) { + return false + } + if (app.isDefaultProtocolClient('orca')) { + return false + } + try { + return app.setAsDefaultProtocolClient('orca') + } catch (error) { + console.warn('[protocol] Failed to register orca:// links:', error) + return false + } +} diff --git a/src/main/startup/plugin-install-link-state.test.ts b/src/main/startup/plugin-install-link-state.test.ts new file mode 100644 index 00000000000..56bbc158c0c --- /dev/null +++ b/src/main/startup/plugin-install-link-state.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest' +import { PluginInstallLinkState } from './skill-share-deep-link-state' +import { isRoutedOrcaDeepLink } from './orca-deep-link-url' + +const LINK = 'orca://plugins/acme.dusk?catalog=community' + +describe('PluginInstallLinkState', () => { + it('queues a cold-start link until the renderer pulls it', () => { + const state = new PluginInstallLinkState() + expect(state.capture(['/Applications/Orca.app/Contents/MacOS/Orca', LINK])).toBe(true) + expect(state.consume()).toBe(LINK) + expect(state.consume()).toBeNull() + }) + + it('publishes a second-instance link and keeps malformed ones for the error toast', () => { + const state = new PluginInstallLinkState() + const publish = vi.fn() + expect(state.capture(['orca.exe', 'orca://plugins/not valid'], publish)).toBe(true) + expect(publish).toHaveBeenCalledWith('orca://plugins/not valid') + expect(state.capture(['orca.exe', '--flag'], publish)).toBe(false) + }) + + it('drops repeats and bursts', () => { + let now = 0 + const state = new PluginInstallLinkState(() => now) + const publish = vi.fn() + state.capture([LINK], publish) + state.capture([LINK], publish) + expect(publish).toHaveBeenCalledTimes(1) + for (let index = 0; index < 10; index += 1) { + state.capture([`orca://plugins/acme.p${index}`], publish) + } + expect(publish).toHaveBeenCalledTimes(5) + now = 5_000 + state.capture([LINK], publish) + expect(publish).toHaveBeenCalledTimes(6) + }) +}) + +describe('isRoutedOrcaDeepLink', () => { + it('claims plugin and skill links on macOS open-url, nothing else', () => { + expect(isRoutedOrcaDeepLink(LINK)).toBe(true) + expect(isRoutedOrcaDeepLink('orca://plugins/bad')).toBe(true) + expect(isRoutedOrcaDeepLink('orca://skills/share/abc_1')).toBe(true) + expect(isRoutedOrcaDeepLink('orca://pair?code=x')).toBe(false) + expect(isRoutedOrcaDeepLink('https://example.com/plugins/acme.dusk')).toBe(false) + }) +}) diff --git a/src/main/startup/plugin-worktree-lifecycle.test.ts b/src/main/startup/plugin-worktree-lifecycle.test.ts index 278cf4db153..5a5b1d9ba72 100644 --- a/src/main/startup/plugin-worktree-lifecycle.test.ts +++ b/src/main/startup/plugin-worktree-lifecycle.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { worktreeRemovedPayloadSchema } from '../../shared/plugins/plugin-events' +import { workspaceRemovedPayloadSchema } from '../../shared/plugins/events/plugin-events' import type { PluginService } from '../plugins/plugin-service' import { emitPluginWorktreeLifecycle } from './main-process-pty-startup' import { mainProcessState } from './main-process-state' @@ -16,10 +16,11 @@ describe('emitPluginWorktreeLifecycle', () => { emitPluginWorktreeLifecycle({ kind: 'removed', worktreeId: 'folder:ws-1', path: '/tmp/ws' }) expect(emitEvent).not.toHaveBeenCalled() emitPluginWorktreeLifecycle({ kind: 'removed', worktreeId: 'repo::/tmp/ws', path: '/tmp/ws' }) - expect(emitEvent).toHaveBeenCalledWith('worktree.removed', { - worktreeId: 'repo::/tmp/ws', + expect(emitEvent).toHaveBeenCalledWith('workspace.removed', { + workspaceId: 'repo::/tmp/ws', + kind: 'worktree', path: '/tmp/ws' }) - expect(worktreeRemovedPayloadSchema.safeParse(emitEvent.mock.calls[0]?.[1]).success).toBe(true) + expect(workspaceRemovedPayloadSchema.safeParse(emitEvent.mock.calls[0]?.[1]).success).toBe(true) }) }) diff --git a/src/main/startup/privileged-custom-schemes.ts b/src/main/startup/privileged-custom-schemes.ts index 2de94a1ea71..5b8dbf6be64 100644 --- a/src/main/startup/privileged-custom-schemes.ts +++ b/src/main/startup/privileged-custom-schemes.ts @@ -1,10 +1,12 @@ import { BROWSER_EXTENSION_API_CUSTOM_SCHEME } from '../browser/extensions/browser-extension-api-scheme' import { JIRA_IMAGE_CUSTOM_SCHEME } from '../jira/attachments/image-protocol' import { MEDIA_PREVIEW_CUSTOM_SCHEME } from '../media/media-preview-protocol' +import { PLUGIN_IMAGE_CUSTOM_SCHEME } from '../plugins/plugin-image-scheme' /** Registered with doc preview's in one call: each registerSchemesAsPrivileged call replaces the last. */ export const PRIVILEGED_CUSTOM_SCHEMES: Electron.CustomScheme[] = [ MEDIA_PREVIEW_CUSTOM_SCHEME, JIRA_IMAGE_CUSTOM_SCHEME, - BROWSER_EXTENSION_API_CUSTOM_SCHEME + BROWSER_EXTENSION_API_CUSTOM_SCHEME, + PLUGIN_IMAGE_CUSTOM_SCHEME ] diff --git a/src/main/startup/skill-share-deep-link-state.ts b/src/main/startup/skill-share-deep-link-state.ts index e49d6fb2df9..9d58f3d6824 100644 --- a/src/main/startup/skill-share-deep-link-state.ts +++ b/src/main/startup/skill-share-deep-link-state.ts @@ -1,21 +1,51 @@ +import { pluginInstallLinkFromArguments } from '../../shared/plugins/catalog/plugin-install-link' import { skillShareIdFromArguments } from '../../shared/skill-share-link' -export class SkillShareDeepLinkState { - private pendingShareId: string | null = null +const REPEAT_WINDOW_MS = 3_000 +const BURST_LIMIT = 5 - capture(argv: readonly string[], publish?: (shareId: string) => void): boolean { - const shareId = skillShareIdFromArguments(argv) - if (!shareId) { +/** Holds the latest deep link from launch argv or open-url until the renderer pulls or is pushed it. */ +export class PendingDeepLinkState { + private pending: string | null = null + private recent: { value: string; at: number }[] = [] + + constructor( + private readonly fromArguments: (argv: readonly string[]) => string | null, + private readonly now: () => number = Date.now + ) {} + + capture(argv: readonly string[], publish?: (value: string) => void): boolean { + const value = this.fromArguments(argv) + if (!value) { return false } - this.pendingShareId = shareId - publish?.(shareId) + // Why: a double click or a page that re-fires the link must not stack pages or toasts. + const at = this.now() + this.recent = this.recent.filter((entry) => at - entry.at < REPEAT_WINDOW_MS) + if (this.recent.some((entry) => entry.value === value) || this.recent.length >= BURST_LIMIT) { + return true + } + this.recent.push({ value, at }) + this.pending = value + publish?.(value) return true } consume(): string | null { - const shareId = this.pendingShareId - this.pendingShareId = null - return shareId + const value = this.pending + this.pending = null + return value + } +} + +export class SkillShareDeepLinkState extends PendingDeepLinkState { + constructor() { + super(skillShareIdFromArguments) + } +} + +export class PluginInstallLinkState extends PendingDeepLinkState { + constructor(now?: () => number) { + super(pluginInstallLinkFromArguments, now) } } diff --git a/src/main/window/createMainWindow-close-confirmation.test.ts b/src/main/window/createMainWindow-close-confirmation.test.ts index 3cd1ebdc257..a5cc53ba6e2 100644 --- a/src/main/window/createMainWindow-close-confirmation.test.ts +++ b/src/main/window/createMainWindow-close-confirmation.test.ts @@ -54,6 +54,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn() @@ -102,6 +103,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -158,6 +160,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -217,6 +220,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -276,6 +280,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -333,6 +338,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -378,6 +384,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -424,6 +431,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -473,6 +481,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), diff --git a/src/main/window/createMainWindow-markdown-editor-focus.test.ts b/src/main/window/createMainWindow-markdown-editor-focus.test.ts index 2237c376232..64483f2640e 100644 --- a/src/main/window/createMainWindow-markdown-editor-focus.test.ts +++ b/src/main/window/createMainWindow-markdown-editor-focus.test.ts @@ -29,6 +29,7 @@ function openMainWindowForContextMenu() { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -76,6 +77,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn() @@ -124,6 +126,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -177,6 +180,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -236,6 +240,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -295,6 +300,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -375,6 +381,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -449,6 +456,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), diff --git a/src/main/window/createMainWindow-recovery-reload-watchdog.test.ts b/src/main/window/createMainWindow-recovery-reload-watchdog.test.ts index b48cd23da77..043689edde0 100644 --- a/src/main/window/createMainWindow-recovery-reload-watchdog.test.ts +++ b/src/main/window/createMainWindow-recovery-reload-watchdog.test.ts @@ -77,6 +77,7 @@ describe('renderer recovery reload watchdog', () => { on: vi.fn(register), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn() diff --git a/src/main/window/createMainWindow-renderer-crash-recovery.test.ts b/src/main/window/createMainWindow-renderer-crash-recovery.test.ts index 00bcca46491..e905196da3d 100644 --- a/src/main/window/createMainWindow-renderer-crash-recovery.test.ts +++ b/src/main/window/createMainWindow-renderer-crash-recovery.test.ts @@ -47,6 +47,7 @@ describe('createMainWindow', () => { on: vi.fn(), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -106,6 +107,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -220,6 +222,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn() @@ -265,6 +268,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn() @@ -319,6 +323,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn() diff --git a/src/main/window/createMainWindow-renderer-launch-failure.test.ts b/src/main/window/createMainWindow-renderer-launch-failure.test.ts index c42c2811e7c..0ee81baf109 100644 --- a/src/main/window/createMainWindow-renderer-launch-failure.test.ts +++ b/src/main/window/createMainWindow-renderer-launch-failure.test.ts @@ -45,6 +45,7 @@ function createSpawnRefusingWindow(platform: NodeJS.Platform = 'darwin') { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn() diff --git a/src/main/window/createMainWindow-startup-reveal.test.ts b/src/main/window/createMainWindow-startup-reveal.test.ts index 0e19bbe0b8c..1b482ef1b7f 100644 --- a/src/main/window/createMainWindow-startup-reveal.test.ts +++ b/src/main/window/createMainWindow-startup-reveal.test.ts @@ -45,6 +45,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn() diff --git a/src/main/window/createMainWindow-system-resume-relay.test.ts b/src/main/window/createMainWindow-system-resume-relay.test.ts index 342f41f59f2..42882041c24 100644 --- a/src/main/window/createMainWindow-system-resume-relay.test.ts +++ b/src/main/window/createMainWindow-system-resume-relay.test.ts @@ -34,6 +34,7 @@ describe('createMainWindow', () => { on: vi.fn(), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), diff --git a/src/main/window/createMainWindow-terminal-focus-shortcuts.test.ts b/src/main/window/createMainWindow-terminal-focus-shortcuts.test.ts index 10eaf49b6f9..95a5d93701d 100644 --- a/src/main/window/createMainWindow-terminal-focus-shortcuts.test.ts +++ b/src/main/window/createMainWindow-terminal-focus-shortcuts.test.ts @@ -31,6 +31,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -130,6 +131,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -224,6 +226,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -289,6 +292,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -353,6 +357,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -411,6 +416,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -473,6 +479,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -552,6 +559,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -617,6 +625,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -694,6 +703,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -764,6 +774,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), diff --git a/src/main/window/createMainWindow-test-harness.ts b/src/main/window/createMainWindow-test-harness.ts index 6f428270450..85f7d76c19f 100644 --- a/src/main/window/createMainWindow-test-harness.ts +++ b/src/main/window/createMainWindow-test-harness.ts @@ -161,6 +161,7 @@ export function createRendererRecoveryWindowHarness(): RendererRecoveryWindowHar }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn() diff --git a/src/main/window/createMainWindow-tray-minimize-close.test.ts b/src/main/window/createMainWindow-tray-minimize-close.test.ts index 37c5aec7b29..5d6b7fa9bab 100644 --- a/src/main/window/createMainWindow-tray-minimize-close.test.ts +++ b/src/main/window/createMainWindow-tray-minimize-close.test.ts @@ -65,6 +65,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), diff --git a/src/main/window/createMainWindow-user-quit-close.test.ts b/src/main/window/createMainWindow-user-quit-close.test.ts index 89077b975ad..f400440ee09 100644 --- a/src/main/window/createMainWindow-user-quit-close.test.ts +++ b/src/main/window/createMainWindow-user-quit-close.test.ts @@ -33,6 +33,7 @@ function setupWindow() { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), diff --git a/src/main/window/createMainWindow-zoom-and-tab-switch-shortcuts.test.ts b/src/main/window/createMainWindow-zoom-and-tab-switch-shortcuts.test.ts index a8a5c16c396..958358f883a 100644 --- a/src/main/window/createMainWindow-zoom-and-tab-switch-shortcuts.test.ts +++ b/src/main/window/createMainWindow-zoom-and-tab-switch-shortcuts.test.ts @@ -40,6 +40,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn() @@ -116,6 +117,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn() @@ -158,6 +160,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn() @@ -215,6 +218,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -261,6 +265,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -307,6 +312,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -377,6 +383,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -425,6 +432,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -486,6 +494,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 301db7bba4d..fc89d63b7bb 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -58,6 +58,7 @@ describe('createMainWindow', () => { on: vi.fn(), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -117,6 +118,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn((handler) => { windowOpenHandler = handler @@ -294,6 +296,7 @@ describe('createMainWindow', () => { }), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -351,6 +354,7 @@ describe('createMainWindow', () => { on: vi.fn(), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -395,6 +399,7 @@ describe('createMainWindow', () => { on: vi.fn(), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), setWindowOpenHandler: vi.fn(), send: vi.fn(), @@ -445,6 +450,7 @@ describe('createMainWindow', () => { on: vi.fn(), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), isDestroyed: vi.fn(() => false), setWindowOpenHandler: vi.fn(), @@ -519,6 +525,7 @@ describe('createMainWindow', () => { on: vi.fn(), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), isDestroyed: vi.fn(() => false), setWindowOpenHandler: vi.fn(), @@ -597,6 +604,7 @@ describe('createMainWindow', () => { on: vi.fn(), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), isDestroyed: vi.fn(() => false), setWindowOpenHandler: vi.fn(), @@ -645,6 +653,7 @@ describe('createMainWindow', () => { on: vi.fn(), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), isDestroyed: vi.fn(() => false), setWindowOpenHandler: vi.fn(), @@ -691,6 +700,7 @@ describe('createMainWindow', () => { on: vi.fn(), setZoomLevel: vi.fn(), setBackgroundThrottling: vi.fn(), + setWebRTCIPHandlingPolicy: vi.fn(), invalidate: vi.fn(), isDestroyed: vi.fn(() => false), setWindowOpenHandler: vi.fn(), diff --git a/src/main/window/host-frame-navigation-guard.test.ts b/src/main/window/host-frame-navigation-guard.test.ts index cbc393ff0e7..2cd3bea64bb 100644 --- a/src/main/window/host-frame-navigation-guard.test.ts +++ b/src/main/window/host-frame-navigation-guard.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { PLUGIN_PANEL_FRAME_NAME_PREFIX } from '../../shared/plugins/plugin-panel-bridge' +import { PLUGIN_PANEL_FRAME_NAME_PREFIX } from '../../shared/plugins/frames/plugin-frame-bridge' import { NATIVE_CHAT_VISUAL_FRAME_NAME_PREFIX } from '../../shared/native-chat/native-chat-visual-shell' import { HostFrameNavigationRegistry, hostFrameKindForName } from './host-frame-navigation-guard' diff --git a/src/main/window/host-frame-navigation-guard.ts b/src/main/window/host-frame-navigation-guard.ts index b40a491eaf9..50b74e12a4a 100644 --- a/src/main/window/host-frame-navigation-guard.ts +++ b/src/main/window/host-frame-navigation-guard.ts @@ -1,6 +1,6 @@ import type { WebContents, WebFrameMain } from 'electron' import { NATIVE_CHAT_VISUAL_FRAME_NAME_PREFIX } from '../../shared/native-chat/native-chat-visual-shell' -import { PLUGIN_PANEL_FRAME_NAME_PREFIX } from '../../shared/plugins/plugin-panel-bridge' +import { PLUGIN_PANEL_FRAME_NAME_PREFIX } from '../../shared/plugins/frames/plugin-frame-bridge' type NavigationFrame = Pick diff --git a/src/main/window/macos-desktop-handoff.test.ts b/src/main/window/macos-desktop-handoff.test.ts index 4e57fb1613c..d7981124bda 100644 --- a/src/main/window/macos-desktop-handoff.test.ts +++ b/src/main/window/macos-desktop-handoff.test.ts @@ -164,10 +164,15 @@ describe('desktopHandoffArguments', () => { it('turns the buffered share id into a link the desktop app reads from argv', () => { const args = desktopHandoffArguments({ skillShareDeepLinks: { consume: () => 'abc_123' }, + pluginInstallLinks: { consume: () => 'orca://plugins/acme.dusk?catalog=community' }, osOpenedDocuments: { consume: () => ['/Users/me/notes.md'] } }) - expect(args).toEqual(['orca://skills/share/abc_123', '/Users/me/notes.md']) + expect(args).toEqual([ + 'orca://skills/share/abc_123', + 'orca://plugins/acme.dusk?catalog=community', + '/Users/me/notes.md' + ]) expect(skillShareIdFromArguments(args)).toBe('abc_123') }) @@ -175,6 +180,7 @@ describe('desktopHandoffArguments', () => { expect( desktopHandoffArguments({ skillShareDeepLinks: { consume: () => null }, + pluginInstallLinks: { consume: () => null }, osOpenedDocuments: { consume: () => [] } }) ).toEqual([]) diff --git a/src/main/window/macos-desktop-handoff.ts b/src/main/window/macos-desktop-handoff.ts index 94e247696bb..11c60602fba 100644 --- a/src/main/window/macos-desktop-handoff.ts +++ b/src/main/window/macos-desktop-handoff.ts @@ -73,11 +73,14 @@ export function createDesktopHandoff(options: { /** Drains what the sidecar buffered from an open into the desktop app's launch arguments. */ export function desktopHandoffArguments(pending: { skillShareDeepLinks: { consume: () => string | null } + pluginInstallLinks: { consume: () => string | null } osOpenedDocuments: { consume: () => string[] } }): string[] { const shareId = pending.skillShareDeepLinks.consume() + const pluginLink = pending.pluginInstallLinks.consume() return [ ...(shareId ? [`orca://skills/share/${shareId}`] : []), + ...(pluginLink ? [pluginLink] : []), ...pending.osOpenedDocuments.consume() ] } diff --git a/src/main/window/main-window-webview-security.test.ts b/src/main/window/main-window-webview-security.test.ts index 852be2c115f..df9b030f4ab 100644 --- a/src/main/window/main-window-webview-security.test.ts +++ b/src/main/window/main-window-webview-security.test.ts @@ -50,13 +50,14 @@ import { buildDocPreviewUrl, DOC_PREVIEW_PARTITION } from '../../shared/doc-prev function installOnFakeWindow(): { handlers: Record void> - webContents: { on: ReturnType } + webContents: { on: ReturnType; setWebRTCIPHandlingPolicy: ReturnType } } { const handlers: Record void> = {} const webContents = { on: vi.fn((event: string, handler: (...args: never[]) => void) => { handlers[event] = handler - }) + }), + setWebRTCIPHandlingPolicy: vi.fn() } installMainWindowWebviewSecurity({ webContents } as never) return { handlers, webContents } @@ -91,6 +92,7 @@ describe('main window webview security', () => { expect(preventDefault).toHaveBeenCalledOnce() expect(mocks.installNavigationPolicy).toHaveBeenCalledWith(webContents) expect(mocks.registerPluginGuard).toHaveBeenCalledWith(webContents) + expect(webContents.setWebRTCIPHandlingPolicy).toHaveBeenCalledWith('disable_non_proxied_udp') }) it('removes renderer preload input and restores every hardened preference', () => { diff --git a/src/main/window/main-window-webview-security.ts b/src/main/window/main-window-webview-security.ts index e210cbd7891..795432fcf41 100644 --- a/src/main/window/main-window-webview-security.ts +++ b/src/main/window/main-window-webview-security.ts @@ -51,7 +51,11 @@ function isAdmissibleExtensionPageAttach(partition: string, src: string): boolea export function installMainWindowWebviewSecurity(mainWindow: BrowserWindow): void { installPrivilegedWindowNavigationPolicy(mainWindow.webContents) - // New shell documents discard preview ownership bookkeeping; revoke grants the replacement renderer cannot release. + // Why: plugin panels are frames of this renderer; same IP-handling fence as doc preview guests. + mainWindow.webContents.setWebRTCIPHandlingPolicy('disable_non_proxied_udp') + // Why here and on every fresh shell document: the renderer holds the only record of which + // preview owns which grant, and a reload throws that record away. Grants it can no longer + // release would stay live read authorities for the rest of the process. revokeAllDocPreviewGrants() mainWindow.webContents.on('did-start-navigation', (details) => { if (details.isMainFrame && !details.isSameDocument) { diff --git a/src/main/wsl/wsl-hosts-policy-live-update.ts b/src/main/wsl/wsl-hosts-policy-live-update.ts index 7dbfb2678fc..914d31ea3a3 100644 --- a/src/main/wsl/wsl-hosts-policy-live-update.ts +++ b/src/main/wsl/wsl-hosts-policy-live-update.ts @@ -10,7 +10,7 @@ import { type WslHostsPolicy } from '../../shared/wsl-hosts-policy' import { getMainHttpClient } from '../network/http-client' -import { writePluginFileAtomically } from '../plugins/plugin-atomic-file-write' +import { writePluginFileAtomically } from '../plugins/installation/plugin-atomic-file-write' const REFRESH_INTERVAL_MS = 4 * 60 * 60 * 1000 const FETCH_TIMEOUT_MS = 30_000 diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 44d306c8eed..a2447850f79 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -192,6 +192,9 @@ export type { NativeChatSubscriptionFrame } from './api/native-chat-api' export type { + PluginAutoUpdateEvent, + PluginHostCustomEditor, + PluginHostNewFile, PluginHostInstallResult, PluginHostInstallSource, PluginHostListEntry, diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts index be86d299cff..9095bc8c106 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -20,6 +20,7 @@ import type { import type { SshMutationExpectation } from '../../shared/ssh-types' import type { LocalFileAccess } from '../../shared/local-file-access' import type { RemoteFilePreviewAddress } from '../../shared/remote-file-preview-address' +import type { FileBytesWritePrecondition } from '../../shared/file-bytes-write' import type { FileWriteDiskPrecondition, FileWriteResult @@ -140,6 +141,16 @@ export type FilesystemApi = { diskPrecondition?: FileWriteDiskPrecondition } & SshMutationExpectation ) => Promise + /** Atomic replacement of a binary file; absent where only text saves are served. */ + writeFileBytes?: ( + args: { + filePath: string + bytes: Uint8Array + connectionId?: string + access?: LocalFileAccess + diskPrecondition: FileBytesWritePrecondition + } & SshMutationExpectation + ) => Promise createFile: ( args: { filePath: string diff --git a/src/preload/api/fs-bridge.ts b/src/preload/api/fs-bridge.ts index 2a7143d3987..644887856ab 100644 --- a/src/preload/api/fs-bridge.ts +++ b/src/preload/api/fs-bridge.ts @@ -7,6 +7,7 @@ import type { import type { SshMutationExpectation } from '../../shared/ssh-types' import type { LocalFileAccess } from '../../shared/local-file-access' import type { RemoteFilePreviewAddress } from '../../shared/remote-file-preview-address' +import type { FileBytesWritePrecondition } from '../../shared/file-bytes-write' import type { FileWriteDiskPrecondition, FileWriteResult @@ -139,6 +140,15 @@ export const fsApi = { diskPrecondition?: FileWriteDiskPrecondition } & SshMutationExpectation ): Promise => ipcRenderer.invoke('fs:writeFile', args), + writeFileBytes: ( + args: { + filePath: string + bytes: Uint8Array + connectionId?: string + access?: LocalFileAccess + diskPrecondition: FileBytesWritePrecondition + } & SshMutationExpectation + ): Promise => ipcRenderer.invoke('fs:writeFileBytes', args), createFile: ( args: { filePath: string; connectionId?: string } & SshMutationExpectation ): Promise => ipcRenderer.invoke('fs:createFile', args), diff --git a/src/preload/api/plugin-host-api.ts b/src/preload/api/plugin-host-api.ts index 9858f93c470..4c6c7282873 100644 --- a/src/preload/api/plugin-host-api.ts +++ b/src/preload/api/plugin-host-api.ts @@ -1,76 +1,36 @@ -import type { PluginThemeRegistration } from '../../shared/plugins/plugin-theme-artifact' -import type { PluginTerminalThemeRegistration } from '../../shared/plugins/plugin-terminal-theme-artifact' +import type { PluginHostImages } from '../../shared/plugins/images/plugin-image-url' +import type { PluginUpdateAccessGrowth } from '../../shared/plugins/approval/plugin-update-access-growth' +import type { PluginAutoUpdateState } from '../../shared/plugins/catalog/plugin-update-policy' import type { - PluginPanelActionOutcome, - PluginPanelEntry -} from '../../shared/plugins/plugin-panel-bridge' -import type { PluginConsentRequest } from '../../shared/plugins/plugin-consent-request' -import type { PluginLanguagePackRegistration } from '../../shared/plugins/plugin-language-pack-artifact' -import type { PluginChangeEvent } from '../../shared/plugins/plugin-change-event' -import type { PluginManifest } from '../../shared/plugins/plugin-manifest' -import type { PluginMarketplaceGitSource } from '../../shared/plugins/plugin-marketplace' + PluginIconThemeMetadata, + PluginIconThemeRegistration +} from '../../shared/plugins/artifacts/plugin-icon-theme-artifact' +import type { PluginThemeRegistration } from '../../shared/plugins/artifacts/plugin-theme-artifact' +import type { + PluginSyntaxMetadata, + PluginSyntaxPayload +} from '../../shared/plugins/artifacts/plugin-syntax-artifact' +import type { PluginTerminalThemeRegistration } from '../../shared/plugins/artifacts/plugin-terminal-theme-artifact' +import type { PluginFrameEntry } from '../../shared/plugins/frames/plugin-frame-bridge' +import type { PluginHostCallOutcome } from '../../shared/plugins/frames/plugin-protocol' +import type { PluginConsentRequest } from '../../shared/plugins/approval/plugin-consent-request' +import type { PluginLanguagePackRegistration } from '../../shared/plugins/artifacts/plugin-language-pack-artifact' +import type { PluginChangeEvent } from '../../shared/plugins/events/plugin-change-event' +import type { PluginManifest } from '../../shared/plugins/manifest/plugin-manifest' +import type { + PluginCatalogGitSource, + PluginMarketplaceGitSource +} from '../../shared/plugins/catalog/plugin-marketplace' +import type { PluginCatalogRelease } from '../../shared/plugins/catalog/plugin-catalog-release' -export type PluginHostPanel = { - id: string - title: string - /** Lucide icon name declared in the plugin manifest. */ - icon?: string - tabKey: `plugin:${string}` -} - -/** pending awaits consent; idle defers worker startup; errored exhausted retries or failed startup. */ -export type PluginHostStatus = - | 'running' - | 'restarting' - | 'idle' - | 'pending' - | 'disabled' - | 'errored' - | 'invalid' - -// Keep assignable from main/plugins/plugin-list-projection.ts. -export type PluginHostListEntry = { - pluginKey: string - consentFingerprint: string | null - name: string - version: string - publisher: string - description?: string - status: PluginHostStatus - needsReconsent: boolean - error?: string - isDev: boolean - official: boolean - bundled: boolean - capabilities: { kind: string; description: string }[] - panels: PluginHostPanel[] - commands: { - id: string - title: string - context: 'global' | 'worktree' - handler: { type: 'built-in'; action: string } | { type: 'worker' } - keybindings: { key: string; when: 'global' | 'worktree' }[] - }[] - hasWorker: boolean - vmRecipes?: { - id: string - name: string - description?: string - commands: { - phase: 'create' | 'suspend' | 'resume' | 'destroy' - command: string - }[] - }[] - restarts: number - blockedByKillList?: { reason: string; advisoryUrl?: string } - source?: { - kind: 'local-path' | 'git' | 'marketplace' | 'bundled' - reference: string - resolvedCommit: string | null - contentHash: string - marketplace?: { reference: string; resolvedCommit: string } - } -} +export type { + PluginHostCustomEditor, + PluginHostListEntry, + PluginHostNewFile, + PluginHostPanel, + PluginHostStatus +} from '../../shared/plugins/plugin-list-entry' +import type { PluginHostListEntry } from '../../shared/plugins/plugin-list-entry' export type PluginHostLogLine = { ts: number @@ -90,6 +50,9 @@ export type PluginHostInstallResult = contentHash: string consentFingerprint: string resolvedCommit: string | null + /** Set when install was asked to enable a content-only plugin: false means it is on; + * true means it runs or adds instructions, so it waits for review. */ + needsReview?: boolean } | { ok: false; error: string } @@ -114,12 +77,33 @@ export type PluginMarketplaceHostListing = { marketplaceOwner: string marketplaceCommit: string pluginKey: string - source: PluginMarketplaceGitSource + source: PluginCatalogGitSource description?: string categories: string[] official: boolean bundled: boolean blockedByKillList?: { reason: string; advisoryUrl?: string } + /** The release record a v2 catalog vouches for; absent for v1 catalogs. */ + release?: PluginCatalogRelease + /** The offered release's images, verified and cached by main; never hotlinked. */ + images?: PluginHostImages + /** Release details from a v2 catalog; all absent for v1 catalogs. */ + name?: string + version?: string + license?: string + repository?: string + homepage?: string + /** ISO 8601 date or date-time the release was published. */ + updatedAt?: string + /** Plain-text release notes, at most 2000 characters. */ + changelog?: string +} + +export type PluginFileTypeHostSuggestion = { + marketplaceSourceId: string + pluginKey: string + fileType: string + name?: string } export type PluginMarketplaceHostInstallPreview = { @@ -128,7 +112,7 @@ export type PluginMarketplaceHostInstallPreview = { marketplaceOwner: string marketplaceCommit: string pluginKey: string - source: PluginMarketplaceGitSource + source: PluginCatalogGitSource resolvedCommit: string contentHash: string consentFingerprint: string @@ -136,6 +120,12 @@ export type PluginMarketplaceHostInstallPreview = { official: boolean bundled: boolean blockedByKillList?: { reason: string; advisoryUrl?: string } + /** The manifest's images, verified at preview. */ + images?: PluginHostImages + /** For an update: what it asks for beyond the installed version. */ + accessGrowth?: PluginUpdateAccessGrowth + /** For an update: the catalog now points at a different repository than the install. */ + repositoryMoved?: true } export type PluginsApi = { @@ -143,45 +133,84 @@ export type PluginsApi = { listLanguagePacks: () => Promise listThemes: () => Promise listTerminalThemes: () => Promise + listIconThemes: () => Promise + /** One icon theme's images, read only once it is selected. */ + loadIconTheme: (id: string) => Promise + listSyntaxes: () => Promise + /** One language's grammars, read only once a matching file opens. */ + loadSyntax: (id: string) => Promise /** Approval is bound to the current capability and trusted-worker fingerprint. */ consent: (args: PluginConsentRequest) => Promise setEnabled: (args: { pluginKey: string; enabled: boolean }) => Promise /** CSP-wrapped HTML for a sandboxed iframe; null if missing or disabled. */ - readPanelEntry: (args: { pluginKey: string; panelId: string }) => Promise + readPanelEntry: (args: { pluginKey: string; panelId: string }) => Promise + /** CSP-wrapped custom editor HTML and its host-call session; null if missing or disabled. */ + readCustomEditorEntry?: (args: { + pluginKey: string + editorId: string + }) => Promise + /** A new file's starting text (its template, or empty); null if the plugin is off or lacks it. */ + readNewFileTemplate?: (args: { pluginKey: string; newFileId: string }) => Promise invokeCommand: (args: { pluginKey: string commandId: string args?: unknown }) => Promise - /** Main enforces consented capabilities before executing sandboxed panel requests. */ + /** One frame host call; main enforces consented capabilities and the frame's surface. */ panelAction: (args: { sessionToken: string action: string params?: unknown - }) => Promise + }) => Promise install: (source: PluginHostInstallSource) => Promise listMarketplaces: () => Promise addMarketplace: (source: PluginMarketplaceGitSource) => Promise removeMarketplace: (args: { sourceId: string }) => Promise refreshMarketplaces: (args?: { sourceId?: string }) => Promise listMarketplacePlugins: () => Promise + /** A catalog plugin that opens this file type, from cached catalogs only; never installs. */ + /** `editor` for a file Orca already opens as text: only custom editors are suggested. */ + suggestForFileType: ( + fileType: string, + purpose?: 'open' | 'editor' + ) => Promise previewMarketplacePlugin: (args: { marketplaceSourceId: string pluginKey: string }) => Promise + /** With `enableIfData`, main also turns the plugin on when the installed manifest is + * `execution: 'data'` and, if given, its fingerprint is `expectedFingerprint`; anything else + * comes back `needsReview: true`. Older hosts ignore both fields. */ installMarketplacePlugin: ( preview: Pick< PluginMarketplaceHostInstallPreview, 'marketplaceSourceId' | 'marketplaceCommit' | 'pluginKey' | 'resolvedCommit' - > + > & { + enableIfData?: true + /** The preview's consentFingerprint; with it, main approves only those exact bytes. */ + expectedFingerprint?: string + } ) => Promise previewMarketplaceUpdate: (args: { pluginKey: string }) => Promise rollbackMarketplacePlugin: (args: { pluginKey: string }) => Promise + /** Version a rollback would restore, or null. Read on demand so listing plugins stays I/O-free. */ + getRollbackVersion: (args: { pluginKey: string }) => Promise + /** An installed plugin's parsed manifest, read on demand; works while the plugin system is off. */ + getInstalledManifest: (args: { pluginKey: string }) => Promise remove: (args: { pluginKey: string }) => Promise getLogs: (args: { pluginKey: string }) => Promise /** Re-discovers after settings edits (feature flag, dev paths). */ refresh: () => Promise onChanged: (callback: (event: PluginChangeEvent) => void) => () => void + /** What automatic updates last did. Older hosts reject the call; treat that as nothing. */ + getAutoUpdateState: () => Promise + /** Fires after each automatic update pass, with the plugins it just updated. */ + onAutoUpdateChanged: (callback: (event: PluginAutoUpdateEvent) => void) => () => void +} + +export type PluginAutoUpdateEvent = { + state: PluginAutoUpdateState + updated: { pluginKey: string; name: string; version: string }[] } diff --git a/src/preload/api/plugins-bridge.ts b/src/preload/api/plugins-bridge.ts index 92b01be3dd5..eede16b5c30 100644 --- a/src/preload/api/plugins-bridge.ts +++ b/src/preload/api/plugins-bridge.ts @@ -1,11 +1,10 @@ import { ipcRenderer } from 'electron' +import type { PluginFrameEntry } from '../../shared/plugins/frames/plugin-frame-bridge' +import type { PluginHostCallOutcome } from '../../shared/plugins/frames/plugin-protocol' +import type { PluginConsentRequest } from '../../shared/plugins/approval/plugin-consent-request' +import type { PluginChangeEvent } from '../../shared/plugins/events/plugin-change-event' import type { - PluginPanelActionOutcome, - PluginPanelEntry -} from '../../shared/plugins/plugin-panel-bridge' -import type { PluginConsentRequest } from '../../shared/plugins/plugin-consent-request' -import type { PluginChangeEvent } from '../../shared/plugins/plugin-change-event' -import type { + PluginAutoUpdateEvent, PluginHostInstallResult, PluginHostInstallSource, PluginHostListEntry, @@ -18,6 +17,10 @@ export const pluginsApi = { listLanguagePacks: () => ipcRenderer.invoke('plugins:listLanguagePacks'), listThemes: () => ipcRenderer.invoke('plugins:listThemes'), listTerminalThemes: () => ipcRenderer.invoke('plugins:listTerminalThemes'), + listIconThemes: () => ipcRenderer.invoke('plugins:listIconThemes'), + loadIconTheme: (id) => ipcRenderer.invoke('plugins:loadIconTheme', id), + listSyntaxes: () => ipcRenderer.invoke('plugins:listSyntaxes'), + loadSyntax: (id) => ipcRenderer.invoke('plugins:loadSyntax', id), consent: (args: PluginConsentRequest): Promise => ipcRenderer.invoke('plugins:consent', args), setEnabled: (args: { pluginKey: string; enabled: boolean }): Promise => @@ -25,14 +28,20 @@ export const pluginsApi = { readPanelEntry: (args: { pluginKey: string panelId: string - }): Promise => ipcRenderer.invoke('plugins:readPanelEntry', args), + }): Promise => ipcRenderer.invoke('plugins:readPanelEntry', args), + readCustomEditorEntry: (args: { + pluginKey: string + editorId: string + }): Promise => ipcRenderer.invoke('plugins:readCustomEditorEntry', args), + readNewFileTemplate: (args: { pluginKey: string; newFileId: string }): Promise => + ipcRenderer.invoke('plugins:readNewFileTemplate', args), invokeCommand: (args: { pluginKey: string; commandId: string; args?: unknown }) => ipcRenderer.invoke('plugins:invokeCommand', args), panelAction: (args: { sessionToken: string action: string params?: unknown - }): Promise => ipcRenderer.invoke('plugins:panelAction', args), + }): Promise => ipcRenderer.invoke('plugins:panelAction', args), install: (source: PluginHostInstallSource): Promise => ipcRenderer.invoke('plugins:install', source), listMarketplaces: () => ipcRenderer.invoke('plugins:listMarketplaces'), @@ -40,12 +49,16 @@ export const pluginsApi = { removeMarketplace: (args) => ipcRenderer.invoke('plugins:removeMarketplace', args), refreshMarketplaces: (args = {}) => ipcRenderer.invoke('plugins:refreshMarketplaces', args), listMarketplacePlugins: () => ipcRenderer.invoke('plugins:listMarketplacePlugins'), + suggestForFileType: (fileType, purpose) => + ipcRenderer.invoke('plugins:suggestForFileType', fileType, purpose), previewMarketplacePlugin: (args) => ipcRenderer.invoke('plugins:previewMarketplacePlugin', args), installMarketplacePlugin: (preview) => ipcRenderer.invoke('plugins:installMarketplacePlugin', preview), previewMarketplaceUpdate: (args) => ipcRenderer.invoke('plugins:previewMarketplaceUpdate', args), rollbackMarketplacePlugin: (args) => ipcRenderer.invoke('plugins:rollbackMarketplacePlugin', args), + getRollbackVersion: (args) => ipcRenderer.invoke('plugins:getRollbackVersion', args), + getInstalledManifest: (args) => ipcRenderer.invoke('plugins:getInstalledManifest', args), remove: (args: { pluginKey: string }): Promise => ipcRenderer.invoke('plugins:remove', args), getLogs: (args: { pluginKey: string }): Promise => @@ -58,5 +71,14 @@ export const pluginsApi = { return () => { ipcRenderer.removeListener('plugins:changed', listener) } + }, + getAutoUpdateState: () => ipcRenderer.invoke('plugins:getAutoUpdateState'), + onAutoUpdateChanged: (callback): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, change: PluginAutoUpdateEvent): void => + callback(change) + ipcRenderer.on('plugins:autoUpdateChanged', listener) + return () => { + ipcRenderer.removeListener('plugins:autoUpdateChanged', listener) + } } } satisfies PreloadApi['plugins'] diff --git a/src/preload/api/ui-bridge-state-and-menu-commands.ts b/src/preload/api/ui-bridge-state-and-menu-commands.ts index 17cb3e9e22c..7e128dc41e0 100644 --- a/src/preload/api/ui-bridge-state-and-menu-commands.ts +++ b/src/preload/api/ui-bridge-state-and-menu-commands.ts @@ -45,6 +45,13 @@ export const uiStateAndMenuCommandsApi = { }, consumePendingSkillShare: (): Promise => ipcRenderer.invoke('ui:consumePendingSkillShare'), + onOpenPluginInstallLink: (callback: (link: string) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, link: string): void => callback(link) + ipcRenderer.on('ui:openPluginInstallLink', listener) + return () => ipcRenderer.removeListener('ui:openPluginInstallLink', listener) + }, + consumePendingPluginInstallLink: (): Promise => + ipcRenderer.invoke('ui:consumePendingPluginInstallLink'), onOpenMarkdownFiles: (callback: (documents: MarkdownDocument[]) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, documents: MarkdownDocument[]): void => callback(documents) diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts index ac5cbebe883..b252bde6910 100644 --- a/src/preload/api/ui-command-event-api.ts +++ b/src/preload/api/ui-command-event-api.ts @@ -89,6 +89,9 @@ export type UiCommandEventApi = { quitAfterStoppingAllSessions?: () => Promise onOpenSkillShare: (callback: (shareId: string) => void) => () => void consumePendingSkillShare: () => Promise + /** Raw `orca://plugins/...` link; the renderer validates it. */ + onOpenPluginInstallLink: (callback: (link: string) => void) => () => void + consumePendingPluginInstallLink: () => Promise /** OS "Open With" Markdown/CSV/TSV documents; the local IPC name is retained. */ onOpenMarkdownFiles: (callback: (documents: MarkdownDocument[]) => void) => () => void consumePendingMarkdownFileOpens: () => Promise diff --git a/src/renderer/index.html b/src/renderer/index.html index 390af65c6f8..9d0ccc0a5ca 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -4,7 +4,7 @@ Orca diff --git a/src/renderer/src/app-shell/use-document-appearance.test.tsx b/src/renderer/src/app-shell/use-document-appearance.test.tsx index f61c2257c8d..59ca0ab1899 100644 --- a/src/renderer/src/app-shell/use-document-appearance.test.tsx +++ b/src/renderer/src/app-shell/use-document-appearance.test.tsx @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../shared/constants' import { useAppStore } from '../store' import { usePluginThemeStore } from '../store/plugin-themes' +import { resetSystemPrefersDarkSubscriptionForTests } from '@/components/terminal-pane/use-system-prefers-dark' const mocks = vi.hoisted(() => ({ applyDocumentTheme: vi.fn(), @@ -80,42 +81,148 @@ describe('useDocumentAppearance', () => { unmount() }) - it('applies a selected plugin theme and restores the earlier choice when it disappears', async () => { - const midnight = { - id: 'plugin:samples.midnight/midnight' as const, - revision: 'r1', - pluginKey: 'samples.midnight', - pluginName: 'Midnight', - label: 'Midnight', - base: 'light' as const, - tokens: { '--background': '#fafafa' } - } - usePluginThemeStore.setState({ entries: [midnight], loaded: true }) + const pluginTheme = (name: string, base: 'light' | 'dark') => ({ + id: `plugin:samples.${name}/${name}` as const, + revision: 'r1', + pluginKey: `samples.${name}`, + pluginName: name, + label: name, + base, + tokens: { '--background': base === 'dark' ? '#1a1025' : '#fafafa' } + }) + const midnight = pluginTheme('midnight', 'light') + const dusk = pluginTheme('dusk', 'dark') + + function storeUpdates(): void { useAppStore.setState({ updateSettings: async (updates) => { const current = useAppStore.getState().settings ?? getDefaultSettings('/tmp') useAppStore.setState({ settings: { ...current, ...updates } }) } }) + } + + it('moves a legacy single theme into the slot of its base without changing the mode', async () => { + usePluginThemeStore.setState({ entries: [midnight], loaded: true }) + storeUpdates() act(() => { const settings = useAppStore.getState().settings! useAppStore.setState({ settings: { ...settings, - theme: midnight.base, + theme: 'light', themeBeforePlugin: 'dark', pluginAppTheme: midnight.id } }) }) const { unmount } = renderHook(() => useDocumentAppearance()) + await vi.waitFor(() => + expect(useAppStore.getState().settings).toMatchObject({ + theme: 'light', + pluginAppTheme: null, + themeBeforePlugin: null, + pluginAppThemeLight: midnight.id, + pluginAppThemeDark: null + }) + ) expect(mocks.applyPluginAppTheme).toHaveBeenLastCalledWith(midnight) - expect(mocks.applyDocumentTheme).toHaveBeenLastCalledWith('light') + unmount() + usePluginThemeStore.setState({ entries: [], loaded: false }) + }) + it('swaps between the light and dark slots live when the OS appearance flips', () => { + const listeners = new Set<(event: { matches: boolean }) => void>() + let systemDark = false + const media = { + get matches() { + return systemDark + }, + addEventListener: (_: string, listener: (event: { matches: boolean }) => void) => + listeners.add(listener), + removeEventListener: (_: string, listener: (event: { matches: boolean }) => void) => + listeners.delete(listener) + } + vi.stubGlobal('matchMedia', () => media) + resetSystemPrefersDarkSubscriptionForTests() + usePluginThemeStore.setState({ entries: [midnight, dusk], loaded: true }) + act(() => { + const settings = useAppStore.getState().settings! + useAppStore.setState({ + settings: { + ...settings, + theme: 'system', + pluginAppThemeLight: midnight.id, + pluginAppThemeDark: dusk.id + } + }) + }) + const { unmount } = renderHook(() => useDocumentAppearance()) + expect(mocks.applyPluginAppTheme).toHaveBeenLastCalledWith(midnight) + act(() => { + systemDark = true + for (const listener of listeners) { + listener({ matches: true }) + } + }) + expect(mocks.applyDocumentTheme).toHaveBeenLastCalledWith('system') + expect(mocks.applyPluginAppTheme).toHaveBeenLastCalledWith(dusk) + unmount() + vi.unstubAllGlobals() + resetSystemPrefersDarkSubscriptionForTests() + usePluginThemeStore.setState({ entries: [], loaded: false }) + }) + + it("paints the built-in colors while a slot's plugin is disabled and keeps the slot", () => { + usePluginThemeStore.setState({ entries: [dusk], loaded: true }) + const updateSettings = vi.fn().mockResolvedValue(undefined) + useAppStore.setState({ updateSettings }) + act(() => { + const settings = useAppStore.getState().settings! + useAppStore.setState({ + settings: { ...settings, theme: 'dark', pluginAppThemeDark: dusk.id } + }) + }) + const { unmount } = renderHook(() => useDocumentAppearance()) + expect(mocks.applyPluginAppTheme).toHaveBeenLastCalledWith(dusk) + // A disabled plugin's themes leave the list; re-enabling paints the same slot again. act(() => usePluginThemeStore.setState({ entries: [] })) expect(mocks.applyPluginAppTheme).toHaveBeenLastCalledWith(null) - // The earlier built-in choice comes back with the built-in colors. - await vi.waitFor(() => expect(useAppStore.getState().settings?.theme).toBe('dark')) + expect(updateSettings).not.toHaveBeenCalled() + unmount() + usePluginThemeStore.setState({ entries: [], loaded: false }) + }) + + it('selects no theme when one appears without a click (update, startup discovery, refresh)', () => { + const updateSettings = vi.fn().mockResolvedValue(undefined) + useAppStore.setState({ updateSettings }) + usePluginThemeStore.setState({ entries: [], loaded: true }) + const { unmount } = renderHook(() => useDocumentAppearance()) + mocks.applyDocumentTheme.mockClear() + mocks.applyPluginAppTheme.mockClear() + + // Why: only a user's install/enable click, Use now or Appearance picks a plugin theme. + act(() => + usePluginThemeStore.setState({ + entries: [ + { + id: 'plugin:samples.dusk/dusk', + revision: 'r1', + pluginKey: 'samples.dusk', + pluginName: 'Dusk', + label: 'Dusk', + base: 'dark', + tokens: { '--background': '#1a1025' } + } + ] + }) + ) + + expect(updateSettings).not.toHaveBeenCalled() + expect(mocks.applyDocumentTheme).not.toHaveBeenCalled() + expect(mocks.applyPluginAppTheme).not.toHaveBeenCalledWith( + expect.objectContaining({ id: 'plugin:samples.dusk/dusk' }) + ) unmount() usePluginThemeStore.setState({ entries: [], loaded: false }) }) diff --git a/src/renderer/src/app-shell/use-document-appearance.ts b/src/renderer/src/app-shell/use-document-appearance.ts index ed7c9b8795c..fa349b5505d 100644 --- a/src/renderer/src/app-shell/use-document-appearance.ts +++ b/src/renderer/src/app-shell/use-document-appearance.ts @@ -1,57 +1,67 @@ import { useEffect } from 'react' import { buildAppFontFamily } from '@/lib/app-font-family' import { applyDocumentTheme, applyPluginAppTheme } from '../lib/document-theme' +import { derivePluginMonacoTheme } from '../lib/plugin-monaco-theme' +import { browserColorProbe } from '../lib/plugin-theme-tokens' +import { usePluginMonacoThemeStore } from '../store/plugin-monaco-theme' import { usePluginAppThemeSync, usePluginTerminalPaletteRefresh, usePluginThemeStore, usePluginThemes } from '../store/plugin-themes' +import { usePluginIconThemeActivation } from '../store/plugin-icon-themes' import { scheduleRuntimeGraphSync } from '../runtime/sync-runtime-graph' import { useAppStore } from '../store' +import { useDocumentDarkTheme } from '@/hooks/use-document-dark-theme' export function useDocumentAppearance(): void { - const selectedPluginTheme = useAppStore((s) => s.settings?.pluginAppTheme) - // Why find by id: a disabled or removed plugin drops out of the list, which restores built-in colors. - const pluginTheme = usePluginThemes().find((entry) => entry.id === selectedPluginTheme) ?? null - const pluginThemesLoaded = usePluginThemeStore((s) => s.loaded) - // Why settings.theme alone: choosing a plugin theme stores its base there, so every reader of - // the light/dark mode agrees with the painted colors. const theme = useAppStore((s) => s.settings?.theme) + const dark = useDocumentDarkTheme() + const selectedPluginTheme = useAppStore((s) => + dark ? s.settings?.pluginAppThemeDark : s.settings?.pluginAppThemeLight + ) + // Why find by id: a disabled or removed plugin drops out of the list, which restores built-in colors. + const pluginTheme = + usePluginThemes().find( + (entry) => entry.id === selectedPluginTheme && (entry.base === 'dark') === dark + ) ?? null + const pluginThemesLoaded = usePluginThemeStore((s) => s.loaded) usePluginTerminalPaletteRefresh() usePluginAppThemeSync() const appFontFamily = useAppStore((s) => s.settings?.appFontFamily) + usePluginIconThemeActivation(useAppStore((s) => s.settings?.pluginIconTheme)) + // Why `dark` too: a system theme repaints when the OS flips, before the slot paint below. useEffect(() => { - if (!theme) { - return + if (theme) { + applyDocumentTheme(theme) } - - if (theme === 'dark') { - applyDocumentTheme('dark') - return undefined - } else if (theme === 'light') { - applyDocumentTheme('light') - return undefined - } - const mq = window.matchMedia('(prefers-color-scheme: dark)') - applyDocumentTheme('system') - const handler = (): void => { - applyDocumentTheme('system') + if (theme === 'system') { // System theme changes don't mutate the store, so mobile terminal colors need an explicit graph republish. scheduleRuntimeGraphSync() } - mq.addEventListener('change', handler) - return () => mq.removeEventListener('change', handler) - }, [theme]) + }, [theme, dark]) useEffect(() => { // Why wait for the list: until it loads, the startup cache is still the right paint. // Why theme too: contrast is checked against the built-ins of the current mode. if (pluginThemesLoaded && theme) { applyPluginAppTheme(pluginTheme) + // Why read the document: the editor should match what was painted, built-ins included. + const computed = getComputedStyle(document.documentElement) + usePluginMonacoThemeStore.setState({ + theme: pluginTheme + ? derivePluginMonacoTheme({ + base: pluginTheme.base, + read: (token) => computed.getPropertyValue(token), + probe: browserColorProbe, + tokenColors: pluginTheme.tokenColors + }) + : null + }) } - }, [pluginTheme, pluginThemesLoaded, theme]) + }, [pluginTheme, pluginThemesLoaded, theme, dark]) useEffect(() => { document.documentElement.style.setProperty( diff --git a/src/renderer/src/app-shell/use-global-keybindings.ts b/src/renderer/src/app-shell/use-global-keybindings.ts index d237ce6f508..f0e6ce380df 100644 --- a/src/renderer/src/app-shell/use-global-keybindings.ts +++ b/src/renderer/src/app-shell/use-global-keybindings.ts @@ -18,7 +18,7 @@ import { import { usePluginCommands } from '@/store/plugin-panels' import { useAppStore } from '../store' import { keybindingMatchesAction, type KeybindingActionId } from '../../../shared/keybindings' -import { PLUGIN_COMMAND_ALIAS_ACTION_IDS } from '../../../shared/plugins/plugin-command-actions' +import { PLUGIN_COMMAND_ALIAS_ACTION_IDS } from '../../../shared/plugins/contributions/plugin-command-actions' import { ModifierDoubleTapDetector, toModifierDoubleTapEvent diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 0e3ca501991..b263c71dbfc 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -89,6 +89,8 @@ --color-input: var(--input); --color-ring: var(--ring); --color-editor-surface: var(--editor-surface); + --color-floating-surface: var(--floating-surface); + --color-dialog-surface: var(--dialog-surface); --color-agent-question: var(--agent-question); --color-agent-working: var(--agent-working); --color-agent-question-text: var(--agent-question-text); @@ -134,6 +136,7 @@ --color-status-success-background: var(--status-success-background); --color-status-success-border: var(--status-success-border); --color-status-warning: var(--status-warning); + --color-status-warning-text: var(--status-warning-text); --color-jira-priority-high: var(--jira-priority-high); --color-jira-priority-medium: var(--jira-priority-medium); --color-jira-priority-low: var(--jira-priority-low); @@ -211,6 +214,9 @@ --card-foreground: #0a0a0a; --popover: #fff; --popover-foreground: #0a0a0a; + /* Glass tints for menus and dialogs; a plugin theme replaces both with its --popover. */ + --floating-surface: rgba(255, 255, 255, 0.82); + --dialog-surface: color-mix(in srgb, var(--background) 96%, transparent); --primary: #171717; --primary-foreground: #fafafa; --secondary: #f5f5f5; @@ -323,6 +329,8 @@ --card-foreground: #fafafa; --popover: #171717; --popover-foreground: #fafafa; + --floating-surface: rgba(0, 0, 0, 0.72); + --dialog-surface: rgba(23, 23, 23, 0.96); --primary: #e5e5e5; --primary-foreground: #171717; --secondary: #262626; @@ -403,6 +411,8 @@ --status-success-border: color-mix(in srgb, var(--status-success) 25%, transparent); /* Needs-attention states use yellow, separate from errors and working states. */ --status-warning: #ca8a04; + /* Why: raw amber is under 3:1 on white; small warning text needs the darker mix. */ + --status-warning-text: color-mix(in srgb, var(--status-warning) 70%, var(--foreground)); --jira-priority-high: var(--destructive); --jira-priority-medium: var(--status-warning); --jira-priority-low: var(--callout-note); @@ -432,6 +442,7 @@ --status-success-background: color-mix(in srgb, var(--status-success) 10%, transparent); --status-success-border: color-mix(in srgb, var(--status-success) 25%, transparent); --status-warning: #eab308; + --status-warning-text: var(--status-warning); --jira-priority-high: var(--destructive); --jira-priority-medium: var(--status-warning); --jira-priority-low: var(--callout-note); @@ -1309,8 +1320,12 @@ html.native-shell .app-layout { [role='option'].jump-palette-item[data-selected='true'], .jump-palette-command [data-slot='command-item'][data-selected='true'], .jump-palette-command [role='option'][data-selected='true'], -.ai-vault-session-row[data-selected='true'] { - /* Flat --accent is faint in light mode; foreground mix/ring shows selection and exposes a matching status-pip cutout. */ +.ai-vault-session-row[data-selected='true'], +.plugin-list-row:has([data-plugin-row-name]:focus-visible) { + /* Why: flat --accent (#f5f5f5) on the palette surface (#fff) is nearly invisible + in light mode — the keyboard cursor has to read as a solid selected row. + Mix foreground into background and draw an inset ring; expose the surface + as a var so status-pip cutouts can match without hardcoding. */ --jump-palette-selection-surface: color-mix(in srgb, var(--foreground) 13%, var(--background)); background: var(--jump-palette-selection-surface); border-color: transparent; @@ -1322,8 +1337,10 @@ html.native-shell .app-layout { .dark [role='option'].jump-palette-item[data-selected='true'], .dark .jump-palette-command [data-slot='command-item'][data-selected='true'], .dark .jump-palette-command [role='option'][data-selected='true'], -.dark .ai-vault-session-row[data-selected='true'] { - /* Dark --accent already contrasts; the light-mode foreground mix weakens it. */ +.dark .ai-vault-session-row[data-selected='true'], +.dark .plugin-list-row:has([data-plugin-row-name]:focus-visible) { + /* Why accent, not the light-mode mix: --accent (#404040) already reads clearly on + dark surfaces; a low-%% foreground mix was a visible step down from the old row. */ --jump-palette-selection-surface: var(--accent); background: var(--jump-palette-selection-surface); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 16%, transparent); diff --git a/src/renderer/src/components/FileTypeIcon.tsx b/src/renderer/src/components/FileTypeIcon.tsx index c179e6b1356..9ab5e710fe3 100644 --- a/src/renderer/src/components/FileTypeIcon.tsx +++ b/src/renderer/src/components/FileTypeIcon.tsx @@ -1,12 +1,48 @@ import { createElement, type CSSProperties } from 'react' -import { getFileTypeIcon } from '@/lib/file-type-icons' +import { Folder, FolderOpen } from 'lucide-react' +import { getFileTypeIcon, getPluginFileTypeIconIndex } from '@/lib/file-type-icons' +import { PLUGIN_ICON_ATTRIBUTE, useActivePluginIconTheme } from '@/store/plugin-icon-themes' -type FileTypeIconProps = { - filePath: string | null | undefined - className?: string - style?: CSSProperties +type IconProps = { className?: string; style?: CSSProperties } + +function PluginIcon({ index, className, style }: IconProps & { index: number }): React.JSX.Element { + return ( +