diff --git a/.gitattributes b/.gitattributes
index b1dacdd54..565a02879 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -10,3 +10,5 @@
/skill-stubs/*.md text eol=lf
/skills/*/SKILL.md text eol=lf
/src/cli/bundled-skill-guides.ts text eol=lf
+# Bundled plugin trees are byte-hashed; CRLF checkout would break the pinned hash.
+/resources/plugins/** text eol=lf
diff --git a/.github/workflows/win-crash-survival-e2e.yml b/.github/workflows/win-crash-survival-e2e.yml
index e4c388ceb..7dc5f0e21 100644
--- a/.github/workflows/win-crash-survival-e2e.yml
+++ b/.github/workflows/win-crash-survival-e2e.yml
@@ -27,7 +27,11 @@ on:
- 'config/patches/**'
- 'config/scripts/ensure-native-runtime.mjs'
- 'config/scripts/rebuild-native-deps.mjs'
+ - 'config/scripts/verify-packaged-plugin-resources.cjs'
- 'native/**'
+ # Byte-hashed at package time, and CRLF-sensitive on Windows.
+ - '.gitattributes'
+ - 'resources/plugins/**'
- 'resources/win32/**'
- 'src/main/daemon/**'
- 'src/main/index.ts'
diff --git a/build-plugins/plain-node-entry-guard.js b/build-plugins/plain-node-entry-guard.js
new file mode 100644
index 000000000..ba8c0e8e4
--- /dev/null
+++ b/build-plugins/plain-node-entry-guard.js
@@ -0,0 +1,104 @@
+import { spawnSync } from 'node:child_process';
+import { join } from 'node:path';
+// Why: v1.4.129-rc.1 shipped a dead terminal daemon because a shared main
+// chunk gained `require("electron")` (an import edge added in #7642), and the
+// daemon is forked as a plain-Node process where electron cannot be required.
+// Nothing in CI executes the built daemon-entry under plain Node, so the leak
+// stayed invisible until an adopted old daemon died. This guard fails the
+// build when any chunk reachable from a plain-Node fork entry requires
+// electron, and smoke-loads daemon-entry under plain Node to prove its module
+// graph still resolves.
+// Entries executed as plain Node (ELECTRON_RUN_AS_NODE / no electron runtime):
+// forked daemon, parcel-watcher and computer sidecars, and the CLI-run
+// agent-hooks entry. require("electron") throws MODULE_NOT_FOUND in all of them.
+const PLAIN_NODE_ENTRY_NAMES = [
+ 'daemon-entry',
+ 'parcel-watcher-process-entry',
+ 'computer-sidecar',
+ 'agent-hooks/managed-agent-hook-controls',
+ 'codex/codex-app-server-grant-entry'
+];
+const ELECTRON_REQUIRE_RE = /require\(\s*["']electron["']\s*\)/;
+function collectReachableChunks(entry, byFileName) {
+ const seen = new Set();
+ const reachable = [];
+ const stack = [entry.fileName];
+ while (stack.length > 0) {
+ const fileName = stack.pop();
+ if (seen.has(fileName)) {
+ continue;
+ }
+ seen.add(fileName);
+ const chunk = byFileName.get(fileName);
+ if (!chunk) {
+ continue;
+ }
+ reachable.push(chunk);
+ for (const imported of [...chunk.imports, ...chunk.dynamicImports]) {
+ stack.push(imported);
+ }
+ }
+ return reachable;
+}
+function assertNoElectronRequire(entryName, entry, byFileName) {
+ for (const chunk of collectReachableChunks(entry, byFileName)) {
+ if (ELECTRON_REQUIRE_RE.test(chunk.code)) {
+ throw new Error(`[plain-node-entry-guard] "${entryName}" reaches chunk "${chunk.fileName}" that ` +
+ `requires electron. "${entryName}" runs as a plain-Node process, where ` +
+ `require("electron") throws MODULE_NOT_FOUND and kills it at startup (the ` +
+ `v1.4.129-rc.1 daemon outage). Keep electron imports out of its module graph.`);
+ }
+ }
+}
+// Why: proves the whole daemon-entry graph resolves under plain Node (no
+// unresolved requires). require("electron") does not throw in a dev tree with
+// node_modules present, so the static scan above — not this smoke — is the
+// electron regression guard; this only catches gross load failures.
+function smokeLoadDaemonEntry(outputDir) {
+ const entryPath = join(outputDir, 'daemon-entry.js');
+ const result = spawnSync(process.execPath, [entryPath], {
+ encoding: 'utf8',
+ timeout: 15_000
+ });
+ if (result.error) {
+ throw new Error(`[plain-node-entry-guard] could not smoke-load daemon-entry.js under plain Node: ` +
+ `${result.error.message}`);
+ }
+ const stderr = result.stderr ?? '';
+ if (/Cannot find module|MODULE_NOT_FOUND/.test(stderr)) {
+ throw new Error(`[plain-node-entry-guard] daemon-entry.js failed to load under plain Node:\n${stderr}`);
+ }
+ if (!stderr.includes('Usage: daemon-entry')) {
+ throw new Error(`[plain-node-entry-guard] daemon-entry.js did not reach argv parsing under plain Node ` +
+ `(expected the "Usage: daemon-entry" error). stderr:\n${stderr}`);
+ }
+}
+export function createPlainNodeEntryGuardPlugin() {
+ return {
+ name: 'orca-plain-node-entry-guard',
+ writeBundle(options, bundle) {
+ // Why: skip in `electron-vite dev` watch mode — the smoke would respawn on
+ // every rebuild, and the guard only needs to gate produced builds.
+ if (this.meta.watchMode) {
+ return;
+ }
+ const chunks = Object.values(bundle).filter((item) => item.type === 'chunk');
+ const byFileName = new Map(chunks.map((chunk) => [chunk.fileName, chunk]));
+ const entryByName = new Map();
+ for (const chunk of chunks) {
+ if (chunk.isEntry && chunk.name) {
+ entryByName.set(chunk.name, chunk);
+ }
+ }
+ for (const entryName of PLAIN_NODE_ENTRY_NAMES) {
+ const entry = entryByName.get(entryName);
+ if (entry) {
+ assertNoElectronRequire(entryName, entry, byFileName);
+ }
+ }
+ if (entryByName.has('daemon-entry') && options.dir) {
+ smokeLoadDaemonEntry(options.dir);
+ }
+ }
+ };
+}
diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs
index 1f6246ec5..9a3850661 100644
--- a/config/electron-builder.config.cjs
+++ b/config/electron-builder.config.cjs
@@ -12,6 +12,7 @@ const {
verifyPackagedMainRuntimeDeps
} = require('./packaged-runtime-node-modules.cjs')
const { verifyLinuxGlibcFloor } = require('./scripts/verify-linux-glibc-floor.cjs')
+const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs')
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1'
const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1'
@@ -32,11 +33,17 @@ const relayExtraResource = {
from: 'out/relay',
to: 'relay'
}
+// Why: bundled plugins are immutable install inputs and must remain ordinary
+// directories so the startup bootstrap can verify and publish exact bytes.
+const bundledPluginResources = {
+ from: 'resources/plugins/launch',
+ to: 'plugins/launch'
+}
// Why: the main bundle, packaged CLI, SSH paths, and speech worker all execute
// from package directories where pnpm's symlink farm is absent. Copy the exact
// runtime dependency closure to Resources/node_modules so bare require() calls
// do not fall through to a developer checkout's node_modules.
-const commonExtraResources = [relayExtraResource, skillFreshnessResources]
+const commonExtraResources = [relayExtraResource, bundledPluginResources, skillFreshnessResources]
const macSpeechNativeResource = {
from: 'node_modules/sherpa-onnx-darwin-${arch}',
to: 'node_modules/sherpa-onnx-darwin-${arch}'
@@ -87,6 +94,9 @@ module.exports = {
// it from process.resourcesPath; exclude the source copy from app.asar.
'!resources/onboarding/feature-wall/**',
'!resources/skills/**',
+ // Why: bundled plugins ship via extraResources to resources/plugins/launch;
+ // packing the source tree into app.asar would duplicate those exact bytes.
+ '!resources/plugins/launch/**',
// Why: the Windows CLI shim ships via extraResources to resources/bin/orca.cmd
// (beside the native resources/bin/orca.exe). Packing the source tree into
// app.asar too lets asarUnpack:['resources/**'] extract a second copy at
@@ -129,6 +139,7 @@ module.exports = {
'out/main/hermes/**',
'out/main/win32-utils.js',
'out/main/daemon-entry.js',
+ 'out/main/plugin-host-entry.js',
'out/main/computer-sidecar.js',
'out/main/parcel-watcher-process-entry.js',
'out/main/chunks/**',
@@ -178,6 +189,9 @@ module.exports = {
`[verify-packaged-daemon-entry] skipped boot on cross-arch slice (target ${context.arch}, host ${process.arch})`
)
}
+ // Why: inspect electron-builder's real output so a broken extraResources
+ // mapping fails packaging before bundled content reaches users.
+ verifyPackagedPluginResources(resourcesDir)
chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName)
chmodMacServeSimHelpers(resourcesDir, context.electronPlatformName)
for (const filename of readdirSync(resourcesDir)) {
diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc
index f5200abb1..53c89e200 100644
--- a/config/reliability-gates.jsonc
+++ b/config/reliability-gates.jsonc
@@ -6570,6 +6570,112 @@
"Windows native runtime watches bypass this child path; WSL reservation/release is deterministic-contract tested but not live fault-injected, and SSH registration ownership is not live-relay fault-injected."
],
"demotionRule": "Demote or quarantine if the fault harness flakes without a product or harness bug, if healthy operation exceeds one runtime watcher child, if total physical operation exceeds eight children including retiring generations, if quarantine children outlive their roots or repeat after fusing, if event delivery becomes unbounded, or if metadata/stat work returns to the serve process."
+ },
+ {
+ "id": "terminal-input.plugin-explicit-worktree-routing",
+ "title": "Plugin terminal input stays inside the freshly resolved worktree",
+ "maturity": "experimental",
+ "protection": "partial",
+ "owner": "plugin-platform",
+ "layer": "main-relay-contract",
+ "surfaces": [
+ "plugin host API terminal input",
+ "active worktree resolution",
+ "provider terminal inventory",
+ "relay capability enforcement"
+ ],
+ "platforms": [
+ "macos",
+ "linux",
+ "windows"
+ ],
+ "providers": [
+ "local",
+ "daemon",
+ "ssh",
+ "wsl",
+ "remote-runtime",
+ "mobile-relay"
+ ],
+ "coveredPlatforms": [
+ "macos"
+ ],
+ "coveredProviders": [
+ "local",
+ "ssh"
+ ],
+ "coverageNotes": "Deterministic macOS contract evidence covers opaque local- and SSH-shaped terminal ids, one bounded worktree listing, mismatch rejection, and the main/relay host-call adapter matrix. It does not launch a live PTY or provision a relay-hosted plugin.",
+ "motivatingLinks": [
+ "https://github.com/stablyai/orca/pull/8549"
+ ],
+ "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 src/main/plugins/plugin-host-conformance.test.ts"
+ ],
+ "testFiles": [
+ "src/main/plugins/plugin-host-methods.test.ts",
+ "src/main/plugins/plugin-host-conformance.test.ts"
+ ],
+ "assertionRefs": [
+ {
+ "file": "src/main/plugins/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",
+ "workspace.readContext drops provider paths, path-bearing internal worktree ids, and terminal titles while capping its terminal projection"
+ ]
+ },
+ {
+ "file": "src/main/plugins/plugin-host-conformance.test.ts",
+ "assertions": [
+ "all 13 v0 methods succeed with the required consented capability through desktop-main and relay adapters",
+ "missing consent, missing capability, unknown method, malformed params, panel-forbidden access, malformed results, and mutation-audit failure return identical codes",
+ "malformed qualified keys, client-supplied grants, and client-supplied transport flags are rejected before host policy resolution"
+ ]
+ }
+ ],
+ "evidenceRuns": [
+ {
+ "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 src/main/plugins/plugin-host-conformance.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."
+ }
+ ],
+ "runtimeBudget": {
+ "p95Seconds": 10,
+ "scope": "plugin host main/relay contract tests"
+ },
+ "flakeHistory": {
+ "status": "unknown",
+ "evidence": "The deterministic focused suite passed locally once and needs CI and soak history before promotion."
+ },
+ "redGreenEvidence": {
+ "status": "partial",
+ "evidence": "Exact mismatch/send counts and adapter error parity are asserted; intentional-break and saved CI evidence are still missing."
+ },
+ "performanceBudget": {
+ "required": true,
+ "evidence": "Each plugin send resolves once, performs exactly one list capped at 50 terminals, and performs at most one send. The path adds no polling, subprocesses, provider fanout, renderer work, or startup await."
+ },
+ "promotionCriteria": [
+ "Run for at least 100 consecutive passes or 14 days across required CI platforms.",
+ "Attach intentional-break evidence for the worktree membership check and relay transport binding.",
+ "Exercise live local and SSH provider terminals, including mismatch rejection and successful input echo.",
+ "Keep relay-hosted plugin provisioning behind a separate reviewed policy before replacing the fail-closed registration."
+ ],
+ "knownGaps": [
+ "Linux and Windows execution evidence is not recorded.",
+ "Daemon, WSL, remote-runtime, and mobile-relay providers have no live input evidence.",
+ "Local and SSH coverage is contract-level over opaque ids, not a live PTY input/echo run.",
+ "The bounded 50-terminal inventory intentionally rejects a target not present in the capped result; scale behavior above that cap needs a targeted membership API before expansion.",
+ "Relay-hosted plugin provisioning, consent persistence, workers, and audit services remain out of scope and the relay registration therefore denies every provisioned identity by default."
+ ],
+ "demotionRule": "Keep experimental or demote to protection none if the suite flakes, permits a mismatched terminal send, performs more than one inventory list per call, accepts client-supplied grants, or relay and desktop error codes diverge."
}
]
}
diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs
index 50104b2cd..4d492d68c 100644
--- a/config/scripts/electron-builder-config.test.mjs
+++ b/config/scripts/electron-builder-config.test.mjs
@@ -1,4 +1,4 @@
-import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
+import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -35,17 +35,25 @@ describe('electron-builder config', () => {
'!pr-evidence{,/**/*}',
'!Casks{,/**/*}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
- '!out/**/*.test.js'
+ '!out/**/*.test.js',
+ '!resources/plugins/launch/**'
])
)
})
it('keeps runtime resources available through extraResources', () => {
+ const bundledPluginResources = expect.objectContaining({
+ from: 'resources/plugins/launch',
+ to: 'plugins/launch'
+ })
for (const platform of ['mac', 'linux', 'win']) {
expect(electronBuilderConfig[platform].extraResources).toContainEqual({
from: 'resources/skills',
to: 'skills'
})
+ expect(electronBuilderConfig[platform].extraResources).toEqual(
+ expect.arrayContaining([bundledPluginResources])
+ )
}
expect(electronBuilderConfig.mac.extraResources).toEqual(
expect.arrayContaining([
@@ -386,6 +394,11 @@ describe('electron-builder config', () => {
const resourcesDir = join(root, 'linux-unpacked', 'resources')
const launcherPath = join(resourcesDir, 'bin', 'orca-ide')
await mkdir(join(resourcesDir, 'bin'), { recursive: true })
+ await cp(
+ join(process.cwd(), 'resources', 'plugins', 'launch'),
+ join(resourcesDir, 'plugins', 'launch'),
+ { recursive: true }
+ )
await mkdir(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true })
// Why: afterPack now fails hard when the unpacked daemon entry is
// missing, so the fixture must carry one like a real package layout.
diff --git a/config/scripts/verify-localization-catalog.mjs b/config/scripts/verify-localization-catalog.mjs
index 23126da73..1c16279a2 100644
--- a/config/scripts/verify-localization-catalog.mjs
+++ b/config/scripts/verify-localization-catalog.mjs
@@ -385,15 +385,79 @@ function verifyLocaleParity(enCatalog, localeName, localeCatalog) {
}
function parseArgs(argv) {
- return {
- fix: argv.includes('--fix')
+ const pluginCatalogs = []
+ for (let index = 0; index < argv.length; index += 1) {
+ const argument = argv[index]
+ if (argument === '--plugin-catalog') {
+ const catalogPath = argv[index + 1]
+ if (!catalogPath || catalogPath.startsWith('--')) {
+ throw new Error('--plugin-catalog requires a JSON catalog path')
+ }
+ pluginCatalogs.push(catalogPath)
+ index += 1
+ } else if (argument.startsWith('--plugin-catalog=')) {
+ pluginCatalogs.push(argument.slice('--plugin-catalog='.length))
+ }
}
+ return {
+ fix: argv.includes('--fix'),
+ pluginCatalogs
+ }
+}
+
+async function reportPluginCatalog(root, catalog, pluginCatalogPath) {
+ const resolvedPath = path.resolve(root, pluginCatalogPath)
+ let pluginCatalog
+ try {
+ pluginCatalog = JSON.parse(await fs.readFile(resolvedPath, 'utf8'))
+ } catch (error) {
+ console.error(
+ `Could not read plugin catalog ${normalizePath(root, resolvedPath)}: ${error instanceof Error ? error.message : String(error)}`
+ )
+ return 1
+ }
+ const { enEntries, localeEntries, missingInLocale, extraInLocale, interpolationMismatches } =
+ collectLocaleParityIssues(catalog, pluginCatalog)
+ const translated = enEntries.size - missingInLocale.length - interpolationMismatches.length
+ const coverage = enEntries.size === 0 ? 100 : (translated / enEntries.size) * 100
+ console.log(
+ `Plugin catalog ${normalizePath(root, resolvedPath)}: ${translated}/${enEntries.size} core keys (${coverage.toFixed(1)}% coverage), ${localeEntries.size} catalog entries.`
+ )
+ if (missingInLocale.length > 0) {
+ console.log(formatMissingKeys('missing', missingInLocale.slice(0, 20)))
+ if (missingInLocale.length > 20) {
+ console.log(`...and ${missingInLocale.length - 20} more missing keys`)
+ }
+ }
+ if (extraInLocale.length > 0) {
+ console.log(formatMissingKeys('extra', extraInLocale.slice(0, 20)))
+ }
+ if (interpolationMismatches.length > 0) {
+ console.log(formatMissingKeys('interpolation mismatch', interpolationMismatches.slice(0, 20)))
+ }
+ // Why: absent plugin translations safely fall back to English, but a present
+ // value with different variables can render broken or misleading UI.
+ return interpolationMismatches.length > 0 ? 1 : 0
}
export async function main(root = process.cwd(), options = parseArgs(process.argv.slice(2))) {
const localesDir = path.join(root, LOCALES_RELATIVE_DIR)
const catalogPath = path.join(localesDir, 'en.json')
const catalog = JSON.parse(await fs.readFile(catalogPath, 'utf8'))
+ const pluginCatalogs = options.pluginCatalogs ?? []
+ if (pluginCatalogs.length > 0) {
+ if (options.fix) {
+ console.error('--fix cannot be combined with --plugin-catalog')
+ return 1
+ }
+ for (const pluginCatalogPath of pluginCatalogs) {
+ const result = await reportPluginCatalog(root, catalog, pluginCatalogPath)
+ if (result !== 0) {
+ return result
+ }
+ }
+ return 0
+ }
let catalogKeys = new Set(flattenCatalogKeys(catalog))
const sourceRoots = SOURCE_RELATIVE_ROOTS.map((sourceRoot) => path.join(root, sourceRoot))
const references = []
diff --git a/config/scripts/verify-localization-catalog.test.mjs b/config/scripts/verify-localization-catalog.test.mjs
index ce148c789..14a41fcef 100644
--- a/config/scripts/verify-localization-catalog.test.mjs
+++ b/config/scripts/verify-localization-catalog.test.mjs
@@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
import { main as verifyLocalizationCatalog } from './verify-localization-catalog.mjs'
@@ -79,4 +79,34 @@ describe('verify-localization-catalog', () => {
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(1)
expect(readJson(path.join(localesDir, 'en.json'))).toEqual({})
})
+
+ it('reports partial plugin catalog gaps but rejects malformed interpolation', async () => {
+ const { root } = makeProject({
+ sourceText: 'export {}\n',
+ enCatalog: {
+ auto: { first: 'First {{name}}', second: 'Second' }
+ },
+ esCatalog: {
+ auto: { first: 'Primero {{name}}', second: 'Segundo' }
+ }
+ })
+ const pluginCatalogPath = path.join(root, 'plugin-locale.json')
+ writeJson(pluginCatalogPath, {
+ auto: { first: 'Primeiro {{wrongName}}', pluginOnly: 'Plugin only' }
+ })
+ const report = vi.spyOn(console, 'log').mockImplementation(() => undefined)
+
+ try {
+ await expect(
+ verifyLocalizationCatalog(root, {
+ fix: false,
+ pluginCatalogs: [pluginCatalogPath]
+ })
+ ).resolves.toBe(1)
+ expect(report).toHaveBeenCalledWith(expect.stringContaining('0/2 core keys'))
+ expect(report).toHaveBeenCalledWith(expect.stringContaining('interpolation mismatch'))
+ } finally {
+ report.mockRestore()
+ }
+ })
})
diff --git a/config/scripts/verify-packaged-plugin-resources.cjs b/config/scripts/verify-packaged-plugin-resources.cjs
new file mode 100644
index 000000000..701c5e732
--- /dev/null
+++ b/config/scripts/verify-packaged-plugin-resources.cjs
@@ -0,0 +1,111 @@
+const { createHash } = require('node:crypto')
+const { lstatSync, readFileSync, readdirSync, statSync } = require('node:fs')
+const { isAbsolute, join, relative, resolve, sep } = require('node:path')
+
+const MAX_PLUGIN_FILES = 2_000
+const MAX_PLUGIN_TOTAL_BYTES = 50 * 1024 * 1024
+
+function hashLength(hash, length) {
+ const framedLength = Buffer.allocUnsafe(8)
+ framedLength.writeBigUInt64BE(BigInt(length))
+ hash.update(framedLength)
+}
+
+function hashPackagedPluginTree(root) {
+ const files = []
+ let entriesVisited = 0
+ let totalBytes = 0
+ const visit = (directory) => {
+ const entries = readdirSync(directory, { withFileTypes: true }).sort((left, right) =>
+ left.name < right.name ? -1 : left.name > right.name ? 1 : 0
+ )
+ for (const entry of entries) {
+ if (directory === root && entry.name === '.git') {
+ continue
+ }
+ const entryPath = join(directory, entry.name)
+ const metadata = lstatSync(entryPath)
+ entriesVisited += 1
+ if (entriesVisited > MAX_PLUGIN_FILES) {
+ throw new Error(`plugin exceeds the ${MAX_PLUGIN_FILES}-entry limit`)
+ }
+ if (metadata.isSymbolicLink()) {
+ throw new Error(`packaged plugin contains a symlink: ${relative(root, entryPath)}`)
+ }
+ if (metadata.isDirectory()) {
+ visit(entryPath)
+ } else if (metadata.isFile()) {
+ totalBytes += metadata.size
+ if (totalBytes > MAX_PLUGIN_TOTAL_BYTES) {
+ throw new Error(`plugin exceeds the ${MAX_PLUGIN_TOTAL_BYTES}-byte limit`)
+ }
+ files.push({ path: entryPath, size: metadata.size })
+ } else {
+ throw new Error(`packaged plugin contains an unsupported entry: ${entryPath}`)
+ }
+ }
+ }
+ visit(root)
+ const hash = createHash('sha256').update('orca-plugin-tree-v1\0')
+ for (const file of files) {
+ const relativePath = relative(root, file.path).replaceAll('\\', '/')
+ hashLength(hash, Buffer.byteLength(relativePath, 'utf8'))
+ hash.update(relativePath, 'utf8')
+ hashLength(hash, file.size)
+ hash.update(readFileSync(file.path))
+ }
+ return hash.digest('hex')
+}
+
+function readJsonFile(path, label) {
+ try {
+ return JSON.parse(readFileSync(path, 'utf8'))
+ } catch (error) {
+ throw new Error(
+ `[verify-packaged-plugin-resources] invalid ${label} at ${path}: ${error instanceof Error ? error.message : String(error)}`
+ )
+ }
+}
+
+function verifyPackagedPluginResources(resourcesDir) {
+ const launchRoot = join(resourcesDir, 'plugins', 'launch')
+ if (!statSync(launchRoot).isDirectory()) {
+ throw new Error(`[verify-packaged-plugin-resources] missing launch directory at ${launchRoot}`)
+ }
+ 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')
+ }
+ const resolvedRoot = resolve(launchRoot)
+ for (const entry of index.plugins) {
+ if (
+ typeof entry?.pluginKey !== 'string' ||
+ typeof entry.path !== 'string' ||
+ !/^[0-9a-f]{64}$/.test(entry.contentHash)
+ ) {
+ throw new Error('[verify-packaged-plugin-resources] bundled plugin entry is invalid')
+ }
+ const pluginRoot = resolve(launchRoot, entry.path)
+ const fromRoot = relative(resolvedRoot, pluginRoot)
+ if (!fromRoot || fromRoot === '..' || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) {
+ throw new Error('[verify-packaged-plugin-resources] bundled plugin path escapes launch root')
+ }
+ const manifest = readJsonFile(join(pluginRoot, 'orca-plugin.json'), 'plugin manifest')
+ if (`${manifest.publisher}.${manifest.id}` !== entry.pluginKey) {
+ throw new Error(
+ `[verify-packaged-plugin-resources] manifest identity does not match ${entry.pluginKey}`
+ )
+ }
+ if (hashPackagedPluginTree(pluginRoot) !== entry.contentHash) {
+ throw new Error(
+ `[verify-packaged-plugin-resources] packaged bytes do not match ${entry.pluginKey}`
+ )
+ }
+ }
+ console.log(
+ `[verify-packaged-plugin-resources] OK — verified ${index.plugins.length} bundled plugin(s)`
+ )
+}
+
+module.exports = { verifyPackagedPluginResources }
diff --git a/config/scripts/verify-packaged-plugin-resources.test.mjs b/config/scripts/verify-packaged-plugin-resources.test.mjs
new file mode 100644
index 000000000..79dda1f48
--- /dev/null
+++ b/config/scripts/verify-packaged-plugin-resources.test.mjs
@@ -0,0 +1,77 @@
+import { cp, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
+import { createRequire } from 'node:module'
+import { tmpdir } from 'node:os'
+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')
+
+describe('verify packaged plugin resources', () => {
+ it('accepts exact launch bytes copied into a packaged resources directory', async () => {
+ const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-packaged-plugins-'))
+ try {
+ await cp(
+ join(process.cwd(), 'resources', 'plugins', 'launch'),
+ join(resourcesDir, 'plugins', 'launch'),
+ { recursive: true }
+ )
+
+ expect(() => verifyPackagedPluginResources(resourcesDir)).not.toThrow()
+ } finally {
+ await rm(resourcesDir, { recursive: true, force: true })
+ }
+ })
+
+ 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'
+ )
+
+ expect(() => verifyPackagedPluginResources(resourcesDir)).toThrow(
+ 'packaged bytes do not match stablyai.orca-navigation-shortcuts'
+ )
+ } finally {
+ await rm(resourcesDir, { recursive: true, force: true })
+ }
+ })
+
+ // The tree is hashed by raw bytes, so a CRLF checkout on Windows breaks the
+ // pinned hash. These two guard the `.gitattributes` eol=lf pin that prevents it.
+ it('pins the launch tree to LF so Windows checkouts hash identically', async () => {
+ const attributes = await readFile(join(process.cwd(), '.gitattributes'), 'utf8')
+ expect(attributes).toContain('/resources/plugins/** text eol=lf')
+ })
+
+ 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
+ })
+ for (const entry of await readdir(launchRoot, { recursive: true })) {
+ const path = join(launchRoot, entry)
+ if (!(await stat(path)).isFile()) {
+ continue
+ }
+ await writeFile(path, (await readFile(path, 'utf8')).replace(/\r?\n/g, '\r\n'))
+ }
+
+ // Every file is rewritten, so the first mismatch is whichever plugin sorts
+ // first — don't pin a name a later branch can reorder.
+ expect(() => verifyPackagedPluginResources(resourcesDir)).toThrow(
+ /packaged bytes do not match stablyai\./
+ )
+ } finally {
+ await rm(resourcesDir, { recursive: true, force: true })
+ }
+ })
+})
diff --git a/electron.vite.config.ts b/electron.vite.config.ts
index 956349c70..02262dd82 100644
--- a/electron.vite.config.ts
+++ b/electron.vite.config.ts
@@ -179,6 +179,7 @@ export default defineConfig({
input: {
index: resolve('src/main/index.ts'),
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
+ 'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'),
'computer-sidecar': resolve('src/main/computer/sidecar-entry.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/examples/plugins/hello-orca/main.mjs b/examples/plugins/hello-orca/main.mjs
new file mode 100644
index 000000000..17dacd637
--- /dev/null
+++ b/examples/plugins/hello-orca/main.mjs
@@ -0,0 +1,24 @@
+// Sample Orca plugin worker entry. Runs inside the out-of-process plugin
+// worker (plain Node, no Electron), forked lazily on the first trigger. The
+// default export receives the `orca` API: command registration, event
+// handlers, and the capability-gated host API.
+export default function activate(orca) {
+ orca.commands.register('hello-ping', async (args) => {
+ 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 }
+ })
+
+ orca.events.on('worktree.created', async (payload) => {
+ orca.log(`worktree created: ${payload.worktreeId} at ${payload.path}`)
+ await orca.host.call('notifications.show', {
+ title: 'Worktree created',
+ body: payload.path
+ })
+ })
+
+ orca.events.on('agent.status.changed', (payload) => {
+ orca.log(`agent status: ${payload.state} in ${payload.worktreeId ?? 'unknown worktree'}`)
+ })
+}
diff --git a/examples/plugins/hello-orca/orca-plugin.json b/examples/plugins/hello-orca/orca-plugin.json
new file mode 100644
index 000000000..da41d0f7e
--- /dev/null
+++ b/examples/plugins/hello-orca/orca-plugin.json
@@ -0,0 +1,23 @@
+{
+ "manifestVersion": 1,
+ "id": "hello-orca",
+ "publisher": "orca-samples",
+ "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" },
+ "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" }]
+ },
+ "capabilities": [
+ { "kind": "workspace:read" },
+ { "kind": "terminal:send" },
+ { "kind": "notifications:show" },
+ { "kind": "storage" },
+ { "kind": "events:subscribe" }
+ ]
+}
diff --git a/examples/plugins/hello-orca/panel.html b/examples/plugins/hello-orca/panel.html
new file mode 100644
index 000000000..6c7b5cf81
--- /dev/null
+++ b/examples/plugins/hello-orca/panel.html
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
+ 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
new file mode 100644
index 000000000..cf3a2f90a
--- /dev/null
+++ b/examples/plugins/hostile-panel/orca-plugin.json
@@ -0,0 +1,16 @@
+{
+ "manifestVersion": 1,
+ "id": "hostile-panel",
+ "publisher": "orca-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" },
+ "pluginApi": 1,
+ "contributes": {
+ "panels": [
+ { "id": "hostile", "title": "Hostile Fixture", "icon": "bug", "entry": "panel.html" }
+ ]
+ },
+ "capabilities": []
+}
diff --git a/examples/plugins/hostile-panel/panel.html b/examples/plugins/hostile-panel/panel.html
new file mode 100644
index 000000000..d4f779333
--- /dev/null
+++ b/examples/plugins/hostile-panel/panel.html
@@ -0,0 +1,206 @@
+
+
+
+
+
+
+
+ Hostile panel fixture
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/plugins/launch/bundled-plugins.json b/resources/plugins/launch/bundled-plugins.json
new file mode 100644
index 000000000..3bc0d4e1d
--- /dev/null
+++ b/resources/plugins/launch/bundled-plugins.json
@@ -0,0 +1,10 @@
+{
+ "version": 1,
+ "plugins": [
+ {
+ "pluginKey": "stablyai.orca-navigation-shortcuts",
+ "path": "stablyai.orca-navigation-shortcuts",
+ "contentHash": "ce3a146bae9e121a18cb86a710973422be749a25da5a1c21a8911e2e98cc3a77"
+ }
+ ]
+}
diff --git a/resources/plugins/launch/orca-marketplace.json b/resources/plugins/launch/orca-marketplace.json
new file mode 100644
index 000000000..1088bdcd2
--- /dev/null
+++ b/resources/plugins/launch/orca-marketplace.json
@@ -0,0 +1,36 @@
+{
+ "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": {
+ "kind": "git",
+ "url": "https://github.com/stablyai/orca-multipass-recipes.git",
+ "ref": "v1.0.0"
+ },
+ "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-multipass-recipes/orca-plugin.json b/resources/plugins/launch/stablyai.orca-multipass-recipes/orca-plugin.json
new file mode 100644
index 000000000..53dd14d72
--- /dev/null
+++ b/resources/plugins/launch/stablyai.orca-multipass-recipes/orca-plugin.json
@@ -0,0 +1,15 @@
+{
+ "manifestVersion": 1,
+ "id": "orca-multipass-recipes",
+ "publisher": "stablyai",
+ "name": "Multipass VM Recipes",
+ "version": "1.0.0",
+ "description": "A reviewed starter lifecycle for disposable Multipass workspaces.",
+ "repository": "https://github.com/stablyai/orca-multipass-recipes",
+ "engines": { "orca": ">=1.4.0" },
+ "pluginApi": 1,
+ "contributes": {
+ "vmRecipes": [{ "path": "recipes/ubuntu-lts.json" }]
+ },
+ "capabilities": []
+}
diff --git a/resources/plugins/launch/stablyai.orca-multipass-recipes/recipes/ubuntu-lts.json b/resources/plugins/launch/stablyai.orca-multipass-recipes/recipes/ubuntu-lts.json
new file mode 100644
index 000000000..b2c60c798
--- /dev/null
+++ b/resources/plugins/launch/stablyai.orca-multipass-recipes/recipes/ubuntu-lts.json
@@ -0,0 +1,10 @@
+{
+ "schemaVersion": 1,
+ "id": "multipass-ubuntu-lts",
+ "name": "Multipass Ubuntu LTS",
+ "description": "Creates a four-CPU disposable Ubuntu workspace with Multipass.",
+ "create": "multipass launch 24.04 --name \"$ORCA_VM_NAME\" --cpus 4 --memory 8G --disk 40G",
+ "suspend": "multipass suspend \"$ORCA_VM_NAME\"",
+ "resume": "multipass start \"$ORCA_VM_NAME\"",
+ "destroy": "multipass delete \"$ORCA_VM_NAME\" --purge"
+}
diff --git a/resources/plugins/launch/stablyai.orca-navigation-shortcuts/orca-plugin.json b/resources/plugins/launch/stablyai.orca-navigation-shortcuts/orca-plugin.json
new file mode 100644
index 000000000..6310d4e54
--- /dev/null
+++ b/resources/plugins/launch/stablyai.orca-navigation-shortcuts/orca-plugin.json
@@ -0,0 +1,34 @@
+{
+ "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
new file mode 100644
index 000000000..27945d1be
--- /dev/null
+++ b/resources/plugins/launch/stablyai.orca-portuguese/locales/pt-BR.json
@@ -0,0 +1,36 @@
+{
+ "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
new file mode 100644
index 000000000..d32be4b38
--- /dev/null
+++ b/resources/plugins/launch/stablyai.orca-portuguese/orca-plugin.json
@@ -0,0 +1,15 @@
+{
+ "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/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts
index 351960dd7..e05e0c173 100644
--- a/src/main/agent-hooks/server.test.ts
+++ b/src/main/agent-hooks/server.test.ts
@@ -7083,6 +7083,38 @@ describe('AgentHookServer ingestRemote', () => {
}
})
+ it('fans a Pi session-only status out to plugins, not just the renderer', () => {
+ const server = new AgentHookServer()
+ const rendererListener = vi.fn()
+ const pluginListener = vi.fn()
+ server.setListener(rendererListener)
+ server.subscribeEnrichedStatus(pluginListener)
+
+ server.ingestRemote(
+ {
+ paneKey: PANE,
+ tabId: 'tab-1',
+ worktreeId: 'wt-1',
+ providerSession: {
+ key: 'session_id',
+ id: 'pi-session-1',
+ transcriptPath: '/tmp/pi-session-1.jsonl'
+ },
+ providerSessionOnly: true,
+ payload: { state: 'done', prompt: '', agentType: 'pi' }
+ },
+ 'conn-1'
+ )
+
+ // The session-only path returns early, so it must not skip the plugin tap.
+ expect(rendererListener).toHaveBeenCalledWith(
+ expect.objectContaining({ paneKey: PANE, providerSessionOnly: true })
+ )
+ expect(pluginListener).toHaveBeenCalledWith(
+ expect.objectContaining({ paneKey: PANE, providerSessionOnly: true })
+ )
+ })
+
it('rejects invalid remote metadata-only session envelopes', () => {
const server = new AgentHookServer()
const listener = vi.fn()
diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts
index 1aeb253a1..a9395cf44 100644
--- a/src/main/agent-hooks/server.ts
+++ b/src/main/agent-hooks/server.ts
@@ -465,6 +465,10 @@ export class AgentHookServer {
private onPaneStatusCleared: PaneStatusClearListener | null = null
private statusChangeListeners = new Set()
private providerSessionChangeListeners = new Set()
+ // Why: setListener is a single slot owned by the main-window fanout; the
+ // plugin event bus (and future consumers) need an additive subscription
+ // that also works in headless serve, where no window listener exists.
+ private enrichedStatusListeners = new Set<(payload: EnrichedAgentHookEventPayload) => void>()
// Why: set via start()'s userDataPath so the class has no direct Electron dependency (mockable in vitest node env).
private endpointDir: string | null = null
private endpointFilePathCache: string | null = null
@@ -525,6 +529,14 @@ export class AgentHookServer {
}
}
+ /** Multi-subscriber tap on every enriched status change (no replay). */
+ subscribeEnrichedStatus(listener: (payload: EnrichedAgentHookEventPayload) => void): () => void {
+ this.enrichedStatusListeners.add(listener)
+ return () => {
+ this.enrichedStatusListeners.delete(listener)
+ }
+ }
+
setPaneStatusClearListener(listener: PaneStatusClearListener | null): void {
this.onPaneStatusCleared = listener
}
@@ -890,7 +902,7 @@ export class AgentHookServer {
this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched)
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
- this.onAgentStatus?.(enriched)
+ this.emitEnrichedStatus(enriched)
return enriched
}
const stateReconciledPayload =
@@ -1002,10 +1014,23 @@ export class AgentHookServer {
this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched)
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
- this.onAgentStatus?.(enriched)
+ this.emitEnrichedStatus(enriched)
return enriched
}
+ // Why: every status emit must reach plugins too, so a new early-return path
+ // upstream cannot silently leave the plugin tap behind the main-window fanout.
+ private emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void {
+ this.onAgentStatus?.(enriched)
+ for (const listener of this.enrichedStatusListeners) {
+ try {
+ listener(enriched)
+ } catch (err) {
+ console.error('[agent-hooks] enriched status listener threw', err)
+ }
+ }
+ }
+
private clearAssistantMessageRetry(paneKey: string): void {
const timer = this.assistantMessageRetryTimers.get(paneKey)
if (!timer) {
diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts
index e94067c42..9546c2f25 100644
--- a/src/main/codex-accounts/runtime-home-service.test.ts
+++ b/src/main/codex-accounts/runtime-home-service.test.ts
@@ -140,6 +140,10 @@ function createSettings(overrides: TestSettingsOverrides = {}): GlobalSettings {
terminalScopeHistoryByWorktree: true,
defaultTuiAgent: null,
disabledTuiAgents: [],
+ pluginSystemEnabled: false,
+ disabledPlugins: [],
+ pluginConsents: {},
+ devPluginPaths: [],
skipDeleteWorktreeConfirm: false,
skipCloseTerminalWithRunningProcessConfirm: false,
skipDeleteAutomationConfirm: false,
diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts
index cb6ba306f..c2ae74b89 100644
--- a/src/main/codex-accounts/service.test.ts
+++ b/src/main/codex-accounts/service.test.ts
@@ -148,6 +148,10 @@ function createSettings(overrides: TestSettingsOverrides = {}): GlobalSettings {
terminalScopeHistoryByWorktree: true,
defaultTuiAgent: null,
disabledTuiAgents: [],
+ pluginSystemEnabled: false,
+ disabledPlugins: [],
+ pluginConsents: {},
+ devPluginPaths: [],
skipDeleteWorktreeConfirm: false,
skipCloseTerminalWithRunningProcessConfirm: false,
skipDeleteAutomationConfirm: false,
diff --git a/src/main/ephemeral-vm-runtime-service.test.ts b/src/main/ephemeral-vm-runtime-service.test.ts
index 6f31e7a75..61fc991ad 100644
--- a/src/main/ephemeral-vm-runtime-service.test.ts
+++ b/src/main/ephemeral-vm-runtime-service.test.ts
@@ -83,6 +83,9 @@ describe('ephemeral VM runtime service', () => {
const recipe: OrcaVmRecipe = {
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
+ // Repo-owned recipes predate plugin bounds; snapshotting must not fail
+ // after create has already provisioned external resources.
+ description: 'x'.repeat(2_048),
create: nodeCommand(startPath),
destroy: nodeCommand(cleanupPath)
}
@@ -104,6 +107,7 @@ describe('ephemeral VM runtime service', () => {
expect(provisioned.runtime).toMatchObject({
id: provisioned.start.context.instanceId,
recipeId: 'cloud-sandbox',
+ recipe,
repoId: 'repo-1',
projectId: 'project-1',
workspaceName: 'Fix Login Race',
diff --git a/src/main/ephemeral-vm-runtime-service.ts b/src/main/ephemeral-vm-runtime-service.ts
index 58ad107a3..90eb216c4 100644
--- a/src/main/ephemeral-vm-runtime-service.ts
+++ b/src/main/ephemeral-vm-runtime-service.ts
@@ -120,6 +120,7 @@ export async function provisionEphemeralVmRuntime(
const runtime = upsertEphemeralVmRuntime(args.userDataPath, {
id: start.context.instanceId ?? start.context.recipeId,
recipeId: args.recipe.id,
+ recipe: args.recipe,
...(args.repoId ? { repoId: args.repoId } : {}),
...(args.projectId ? { projectId: args.projectId } : {}),
...(args.workspaceId ? { workspaceId: args.workspaceId } : {}),
diff --git a/src/main/i18n/main-i18n-lazy-locale.test.ts b/src/main/i18n/main-i18n-lazy-locale.test.ts
index 877c36c47..30d11f549 100644
--- a/src/main/i18n/main-i18n-lazy-locale.test.ts
+++ b/src/main/i18n/main-i18n-lazy-locale.test.ts
@@ -17,11 +17,18 @@ import {
UI_LANGUAGE_KOREAN,
UI_LANGUAGE_SPANISH
} from '../../shared/ui-language'
-import { ensureMainI18n, setMainUiLanguage, translateMain } from './main-i18n'
+import {
+ ensureMainI18n,
+ setMainPluginLanguagePacks,
+ setMainUiLanguage,
+ translateMain
+} from './main-i18n'
+import { pluginLanguageResourceId } from '../../shared/plugins/plugin-language-pack-artifact'
describe('main-i18n lazy locale loading', () => {
beforeEach(async () => {
await ensureMainI18n()
+ setMainPluginLanguagePacks([])
await setMainUiLanguage(UI_LANGUAGE_ENGLISH)
})
@@ -58,4 +65,24 @@ describe('main-i18n lazy locale loading', () => {
await setMainUiLanguage(UI_LANGUAGE_ENGLISH)
expect(translateMain('menu.file', 'File')).toBe('File')
})
+
+ it('loads a contributed catalog for native menus and dialogs', async () => {
+ const id = 'plugin:orca-samples.portuguese/pt-BR' as const
+ setMainPluginLanguagePacks([
+ {
+ id,
+ resourceLanguage: pluginLanguageResourceId(id),
+ pluginKey: 'orca-samples.portuguese',
+ locale: 'pt-BR',
+ catalog: { menu: { file: 'Arquivo Orca' } }
+ }
+ ])
+
+ await setMainUiLanguage(id)
+ expect(translateMain('menu.file', 'File')).toBe('Arquivo Orca')
+
+ setMainPluginLanguagePacks([])
+ expect(await setMainUiLanguage(id)).toBe('en')
+ expect(translateMain('menu.file', 'File')).toBe('File')
+ })
})
diff --git a/src/main/i18n/main-i18n.ts b/src/main/i18n/main-i18n.ts
index 4904260db..8ebe21e05 100644
--- a/src/main/i18n/main-i18n.ts
+++ b/src/main/i18n/main-i18n.ts
@@ -9,10 +9,13 @@ import i18next, {
import { isPseudoLocalizationLocale, pseudoLocalizeString } from '../../shared/pseudo-localization'
import { DEFAULT_UI_LOCALE, resolveUiLocale, type SupportedUiLocale } from '../../shared/ui-locale'
import { UI_LANGUAGE_SYSTEM, type UiLanguage } from '../../shared/ui-language'
+import type { PluginLanguagePackRegistration } from '../../shared/plugins/plugin-language-pack-artifact'
export const mainI18n: I18nInstance = i18next.createInstance()
let initialized = false
+let pluginLanguagePacks: readonly PluginLanguagePackRegistration[] = []
+const registeredPluginLanguages = new Set()
// Why: main-process callers pass English fallbacks to translateMain(), so the
// main bundle does not need to parse any locale catalog at cold start. Only
@@ -72,16 +75,20 @@ export async function ensureMainI18n(): Promise {
}
})
initialized = true
+ applyMainPluginLanguagePacks()
}
return mainI18n
}
-export async function setMainUiLanguage(language: UiLanguage): Promise {
+export async function setMainUiLanguage(language: UiLanguage): Promise {
await ensureMainI18n()
- const locale = resolveUiLocale(
+ const selectedLocale = resolveUiLocale(
language,
language === UI_LANGUAGE_SYSTEM ? getMainSystemLocale() : DEFAULT_UI_LOCALE
)
+ const locale =
+ pluginLanguagePacks.find((pack) => pack.id === selectedLocale)?.resourceLanguage ??
+ (selectedLocale.startsWith('plugin:') ? DEFAULT_UI_LOCALE : selectedLocale)
if (mainI18n.language !== locale) {
// changeLanguage triggers the lazy backend load for non-English locales and
// resolves once the catalog is in memory, so callers that await this have
@@ -91,6 +98,30 @@ export async function setMainUiLanguage(language: UiLanguage): Promise void) | null = null
let watcherShutdownPromise: Promise | null = null
let watcherShutdownDone = false
let automations: AutomationService | null = null
+let pluginService: PluginService | null = null
+let pluginKillListService: PluginKillListService | null = null
+let pluginMarketplaceService: PluginMarketplaceService | null = null
+let pluginMarketplaceInstaller: PluginMarketplaceInstaller | null = null
let keybindings: KeybindingService | null = null
+
+function emitPluginWorktreeLifecycle(event: RuntimeWorktreeLifecycleEvent): void {
+ pluginService?.emitEvent(
+ event.kind === 'created' ? 'worktree.created' : 'worktree.removed',
+ event.kind === 'created'
+ ? { worktreeId: event.worktreeId, path: event.path, branch: event.branch }
+ : { worktreeId: event.worktreeId, path: event.path }
+ )
+}
// Why: a reload intent must not leak to a later load; the recovery reload re-fires did-finish-load, so its flag spares live PTYs from the orphan sweep (#5787).
const expectedRendererReload = createWebContentsTimedFlag()
const recoveryReloadInFlight = createWebContentsTimedFlag()
@@ -1177,7 +1204,11 @@ function openMainWindow(): BrowserWindow {
},
onOrcaProfileAuthMutation: () => desktopRelayService?.authMutated(),
onBeforeOrcaProfileSignOut: () => desktopRelayService?.fenceAndCloseNow()
- }
+ },
+ pluginService ?? undefined,
+ pluginMarketplaceService && pluginMarketplaceInstaller
+ ? { marketplace: pluginMarketplaceService, installer: pluginMarketplaceInstaller }
+ : undefined
)
automations.setWebContents(window.webContents)
automations.start()
@@ -1201,7 +1232,8 @@ function openMainWindow(): BrowserWindow {
isRecoveryReloadInFlight,
onBeforeUpdateQuit: () =>
preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }),
- updateInstallMode: resolveUpdateInstallMode(isServeMode)
+ updateInstallMode: resolveUpdateInstallMode(isServeMode),
+ onWorktreeLifecycle: emitPluginWorktreeLifecycle
}
)
rateLimits.attach(window)
@@ -2229,6 +2261,137 @@ app.whenReady().then(async () => {
prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch,
prepareForClaudeLaunch: (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target)
})
+ const pluginSystemStartupStartedAt = performance.now()
+ pluginKillListService = new PluginKillListService({
+ pluginsDataDir: getPluginsDataDir(app.getPath('userData'))
+ })
+ await pluginKillListService.initialize()
+ pluginMarketplaceService = new PluginMarketplaceService({
+ pluginsDataDir: getPluginsDataDir(app.getPath('userData')),
+ getKillListEntry: (pluginKey) => pluginKillListService?.find(pluginKey) ?? null
+ })
+ const requestOfficialMarketplaceSeed = (): void => {
+ if (store?.getSettings().pluginSystemEnabled !== true) {
+ return
+ }
+ void pluginMarketplaceService?.seedOfficialSource().catch((error) => {
+ console.warn('[plugins] failed to configure the official marketplace:', error)
+ })
+ }
+ pluginMarketplaceInstaller = new PluginMarketplaceInstaller({
+ marketplace: pluginMarketplaceService,
+ userDataPath: app.getPath('userData'),
+ hostVersion: app.getVersion(),
+ blockedPluginReason: (pluginKey) => pluginKillListService?.reason(pluginKey) ?? null
+ })
+ pluginService = new PluginService({
+ userDataPath: app.getPath('userData'),
+ hostVersion: app.getVersion(),
+ // Feature flag: with the setting off, discovery returns nothing and no
+ // plugin code path runs at all.
+ isPluginSystemEnabled: () => store?.getSettings().pluginSystemEnabled === true,
+ getDisabledPlugins: () => normalizePluginIdList(store?.getSettings().disabledPlugins),
+ getPluginConsents: () => normalizePluginConsents(store?.getSettings().pluginConsents),
+ getDevPluginPaths: () => normalizePluginIdList(store?.getSettings().devPluginPaths),
+ getKeybindings: () => keybindings?.getOverrides() ?? {},
+ getPluginKillListEntry: (pluginKey) => pluginKillListService?.find(pluginKey) ?? null,
+ hostEntryPath: resolvePluginHostEntryPath(app.getAppPath(), app.isPackaged)
+ })
+ const bundledPluginBootstrap = new PluginBundledBootstrapCoordinator({
+ root: resolveBundledPluginRoot({
+ isPackaged: app.isPackaged,
+ resourcesPath: process.resourcesPath,
+ appPath: app.getAppPath()
+ }),
+ userDataPath: app.getPath('userData'),
+ hostVersion: app.getVersion(),
+ isEnabled: () => store?.getSettings().pluginSystemEnabled === true,
+ blockedPluginReason: (pluginKey) => pluginKillListService?.reason(pluginKey) ?? null,
+ refreshPlugins: () => pluginService?.refresh() ?? Promise.resolve()
+ })
+ const requestBundledPluginBootstrap = (): void => {
+ void bundledPluginBootstrap
+ .request()
+ .then((result) => {
+ for (const failure of result?.errors ?? []) {
+ console.warn(`[plugins] failed to publish bundled ${failure.pluginKey}:`, failure.error)
+ }
+ })
+ .catch((error) => {
+ console.warn('[plugins] failed to bootstrap bundled plugins:', error)
+ })
+ }
+ pluginKillListService.onChanged(() => {
+ void pluginService?.reconcileActivationState().catch((error) => {
+ console.warn('[plugins] failed to apply plugin safety-list refresh:', error)
+ })
+ })
+ store.onSettingsChanged((updates) => {
+ if (updates.pluginSystemEnabled === true) {
+ requestBundledPluginBootstrap()
+ requestOfficialMarketplaceSeed()
+ }
+ if (app.isPackaged && updates.pluginSystemEnabled === true) {
+ void pluginKillListService?.refresh().catch((error) => {
+ console.warn('[plugins] failed to refresh plugin safety list; using cached state:', error)
+ })
+ }
+ })
+ // Why: headless `orca serve` clients reach plugins through the runtime RPC
+ // methods, which resolve the service via this module-level setter. Consent
+ // over RPC uses the same hash-keyed write path as the desktop dialog.
+ setPluginServiceForRpc(pluginService, {
+ applyConsent: (request) =>
+ applyPluginConsent({ store: store!, pluginService: pluginService!, ...request }),
+ applyEnablement: (pluginKey, enabled) =>
+ applyPluginEnablement({ store: store!, pluginService: pluginService!, pluginKey, enabled })
+ })
+ // Lazy kernel: initialize() only discovers manifests — no worker forks, no
+ // panel reads. Zero plugin code runs before an explicit trigger.
+ void pluginService
+ .initialize()
+ .then(() => {
+ logStartupMilestone('plugin-system-initialized', {
+ durationMs: Number((performance.now() - pluginSystemStartupStartedAt).toFixed(2)),
+ installedPlugins: pluginService?.getDiscovered().length ?? 0
+ })
+ })
+ .catch((error) => {
+ console.warn('[plugins] failed to initialize plugin service:', error)
+ })
+ if (app.isPackaged && store?.getSettings().pluginSystemEnabled === true) {
+ void pluginKillListService.refresh().catch((error) => {
+ console.warn('[plugins] failed to refresh plugin safety list; using cached state:', error)
+ })
+ }
+ pluginService.onChanged((event) => {
+ if (
+ event.contentPacksChanged &&
+ setMainPluginLanguagePacks(pluginService?.contentPacks.languagePacks.list() ?? [])
+ ) {
+ void setMainUiLanguage(store!.getSettings().uiLanguage).then(() => rebuildAppMenu())
+ }
+ for (const window of BrowserWindow.getAllWindows()) {
+ if (!window.isDestroyed()) {
+ window.webContents.send('plugins:changed', event)
+ }
+ }
+ })
+ requestBundledPluginBootstrap()
+ requestOfficialMarketplaceSeed()
+ // v0 plugin event seams: agent status (hook pipeline tap) + worktree
+ // lifecycle (runtime tap). Server-side filtered per plugin subscription.
+ agentHookServer.subscribeEnrichedStatus((enriched) => {
+ pluginService?.emitEvent('agent.status.changed', {
+ worktreeId: enriched.worktreeId ?? null,
+ paneKey: enriched.paneKey,
+ state: enriched.payload.state,
+ receivedAt: enriched.receivedAt
+ })
+ })
+ runtimeService.onWorktreeLifecycle((event) => {
+ emitPluginWorktreeLifecycle(event)
+ })
starNag = new StarNagService(store, stats)
starNag.start()
starNag.registerIpcHandlers()
@@ -2577,6 +2740,16 @@ app.on('will-quit', (e) => {
// Why: stats.flush() must precede killAllPty() so still-running agents emit synthetic agent_stop events (killAllPty skips runtime.onPtyExit()).
starNag?.stop()
automations?.stop()
+ // 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.
+ setPluginServiceForRpc(null)
+ pluginKillListService = null
+ pluginMarketplaceService = null
+ pluginMarketplaceInstaller = null
+ const pluginHostShutdown = pluginService?.dispose() ?? Promise.resolve()
+ pluginService = null
setUnreadDockBadgeCount(0)
agentHookServer.stop()
// Why: cancels relay restart/reinstall timers and kills wsl.exe children deterministically, not via stdio-pipe teardown.
@@ -2623,7 +2796,8 @@ app.on('will-quit', (e) => {
{ name: 'daemon', promise: daemonTeardown },
{ name: 'runtime-rpc', promise: rpcStopAndClear },
{ name: 'watchers', promise: watcherShutdown },
- { name: 'emulator', promise: emulatorShutdown }
+ { name: 'emulator', promise: emulatorShutdown },
+ { name: 'plugin-hosts', promise: pluginHostShutdown }
])
.then((pendingTeardowns) => {
if (pendingTeardowns.length > 0) {
diff --git a/src/main/ipc/ephemeral-vm-recipe-context.ts b/src/main/ipc/ephemeral-vm-recipe-context.ts
index aba6c7307..c922d6f13 100644
--- a/src/main/ipc/ephemeral-vm-recipe-context.ts
+++ b/src/main/ipc/ephemeral-vm-recipe-context.ts
@@ -26,7 +26,11 @@ export type RecipeRepoResult =
| { ok: true; repo: Exclude, null | undefined> }
| { ok: false; message: string; doctor: (recipeId: string) => EphemeralVmRecipeDoctorResult }
-export function listRecipes(store: Store, repoId: string): EphemeralVmRecipeListResult {
+export function listRecipes(
+ store: Store,
+ repoId: string,
+ pluginRecipes: readonly OrcaVmRecipe[] = []
+): EphemeralVmRecipeListResult {
const repo = store.getRepo(repoId)
if (!repo || isFolderRepo(repo)) {
return {
@@ -50,12 +54,15 @@ export function listRecipes(store: Store, repoId: string): EphemeralVmRecipeList
return {
status: 'ok',
repoPath: repo.path,
- recipes: hooks?.environmentRecipes ?? [],
+ recipes: combineEphemeralVmRecipes(hooks?.environmentRecipes ?? [], pluginRecipes),
diagnostics: hooks?.environmentRecipeDiagnostics ?? []
}
}
-export function listRecipeCatalog(store: Store): EphemeralVmRecipeCatalogEntry[] {
+export function listRecipeCatalog(
+ store: Store,
+ pluginRecipes: readonly OrcaVmRecipe[] = []
+): EphemeralVmRecipeCatalogEntry[] {
return store
.getRepos()
.filter((repo) => isGitRepoKind(repo) && !isFolderRepo(repo) && !repo.connectionId)
@@ -65,7 +72,7 @@ export function listRecipeCatalog(store: Store): EphemeralVmRecipeCatalogEntry[]
repoId: repo.id,
repoName: repo.displayName,
repoPath: repo.path,
- recipes: hooks?.environmentRecipes ?? [],
+ recipes: combineEphemeralVmRecipes(hooks?.environmentRecipes ?? [], pluginRecipes),
diagnostics: hooks?.environmentRecipeDiagnostics ?? []
}
})
@@ -103,15 +110,41 @@ export function getRuntimeRecipeContext(
if (!repo.ok) {
throw new Error(repo.message)
}
- const recipe = (loadHooks(repo.repo.path)?.environmentRecipes ?? []).find(
- (entry) => entry.id === runtime.recipeId
- )
+ // Pre-snapshot runtimes can only be attributed to repo-owned recipes. Never
+ // substitute a later same-id plugin recipe for an older runtime lifecycle.
+ const recipe =
+ runtime.recipe ??
+ (loadHooks(repo.repo.path)?.environmentRecipes ?? []).find(
+ (entry) => entry.id === runtime.recipeId
+ )
if (!recipe) {
throw new Error(`Recipe not found: ${runtime.recipeId}`)
}
return { runtime, repo, recipe }
}
+export function resolveRecipeForRepo(
+ repoPath: string,
+ recipeId: string,
+ pluginRecipes: readonly OrcaVmRecipe[] = []
+): OrcaVmRecipe | null {
+ return (
+ combineEphemeralVmRecipes(loadHooks(repoPath)?.environmentRecipes ?? [], pluginRecipes).find(
+ (recipe) => recipe.id === recipeId
+ ) ?? null
+ )
+}
+
+/** Project-owned recipes are authoritative for their repository and shadow
+ * same-id global plugin recipes without disabling the rest of the pack. */
+export function combineEphemeralVmRecipes(
+ repoRecipes: readonly OrcaVmRecipe[],
+ pluginRecipes: readonly OrcaVmRecipe[]
+): OrcaVmRecipe[] {
+ const repoIds = new Set(repoRecipes.map((recipe) => recipe.id))
+ return [...repoRecipes, ...pluginRecipes.filter((recipe) => !repoIds.has(recipe.id))]
+}
+
function failedRecipeRepo(repoPath: string | null, message: string): RecipeRepoResult {
return {
ok: false,
diff --git a/src/main/ipc/ephemeral-vm-runtime-handlers.ts b/src/main/ipc/ephemeral-vm-runtime-handlers.ts
index f3ce3caec..dea6d8117 100644
--- a/src/main/ipc/ephemeral-vm-runtime-handlers.ts
+++ b/src/main/ipc/ephemeral-vm-runtime-handlers.ts
@@ -1,6 +1,5 @@
import { app, ipcMain } from 'electron'
import type { Store } from '../persistence'
-import { loadHooks } from '../hooks'
import {
listEphemeralVmRuntimes,
updateEphemeralVmRuntimeStatus
@@ -28,7 +27,7 @@ import {
disconnectRuntimeOwnedSshTarget,
removeRuntimeOwnedSshTarget
} from '../ephemeral-vm-runtime-ssh'
-import { getRecipeRepo, getRuntimeRecipeContext } from './ephemeral-vm-recipe-context'
+import { getRuntimeRecipeContext } from './ephemeral-vm-recipe-context'
import { invalidateRuntimeEnvironmentTransport } from './runtime-environments'
export type EphemeralVmCleanupCommandResult = {
@@ -74,30 +73,21 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void {
if (!runtime.repoId) {
throw new Error(`Ephemeral VM runtime has no repo id: ${args.runtimeId}`)
}
- const repo = getRecipeRepo(store, runtime.repoId)
- if (!repo.ok) {
+ let resolved: ReturnType
+ try {
+ resolved = getRuntimeRecipeContext(store, userDataPath, runtime.id)
+ } catch (error) {
return updateEphemeralVmRuntimeStatus(userDataPath, runtime.id, {
status: 'cleanup_failed',
cleanupStatus: 'failed',
cleanupLastAttemptAt: Date.now(),
- cleanupLastError: repo.message
- })
- }
- const recipe = (loadHooks(repo.repo.path)?.environmentRecipes ?? []).find(
- (entry) => entry.id === runtime.recipeId
- )
- if (!recipe) {
- return updateEphemeralVmRuntimeStatus(userDataPath, runtime.id, {
- status: 'cleanup_failed',
- cleanupStatus: 'failed',
- cleanupLastAttemptAt: Date.now(),
- cleanupLastError: `Recipe not found: ${runtime.recipeId}`
+ cleanupLastError: error instanceof Error ? error.message : String(error)
})
}
const result = await cleanupEphemeralVmRuntime({
userDataPath,
- repoPath: repo.repo.path,
- recipe,
+ repoPath: resolved.repo.repo.path,
+ recipe: resolved.recipe,
runtimeId: runtime.id
})
if (result.ok && runtime.runtimeEnvironmentId) {
@@ -217,7 +207,7 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void {
ipcMain.handle(
'ephemeralVm:getCleanupCommand',
- (_event, args: { runtimeId: string }): EphemeralVmCleanupCommandResult => {
+ async (_event, args: { runtimeId: string }): Promise => {
const userDataPath = app.getPath('userData')
const resolved = getRuntimeRecipeContext(store, userDataPath, args.runtimeId)
const payload = buildEphemeralVmRecipeCleanupPayload({
diff --git a/src/main/ipc/ephemeral-vm.test.ts b/src/main/ipc/ephemeral-vm.test.ts
index 45f980bad..8df189a4a 100644
--- a/src/main/ipc/ephemeral-vm.test.ts
+++ b/src/main/ipc/ephemeral-vm.test.ts
@@ -4,6 +4,7 @@ import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing'
import { listEnvironments } from '../../shared/runtime-environment-store'
+import { upsertEphemeralVmRuntime } from '../../shared/ephemeral-vm-runtime-store'
const handlers = new Map Promise | unknown>()
const {
@@ -92,6 +93,15 @@ function nodeCommand(scriptPath: string): string {
return `"${process.execPath}" "${scriptPath}"`
}
+function pluginServiceWithRecipes(
+ recipes: { pluginKey: string; recipe: Record }[]
+) {
+ return {
+ whenReady: vi.fn().mockResolvedValue(undefined),
+ contentPacks: { vmRecipes: { list: vi.fn(() => recipes) } }
+ }
+}
+
describe('registerEphemeralVmHandlers', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
@@ -198,6 +208,132 @@ describe('registerEphemeralVmHandlers', () => {
])
})
+ it('merges approved plugin recipes while repository recipes shadow matching ids', async () => {
+ const repoPath = makeDir('orca-ephemeral-vm-ipc-repo-')
+ writeFileSync(
+ join(repoPath, 'orca.yaml'),
+ [
+ 'environmentRecipes:',
+ ' - id: shared',
+ ' name: Repository Recipe',
+ ' create: repo-create'
+ ].join('\n')
+ )
+ const pluginService = pluginServiceWithRecipes([
+ {
+ pluginKey: 'orca-samples.recipes',
+ recipe: { id: 'shared', name: 'Plugin Shared', create: 'plugin-shared' }
+ },
+ {
+ pluginKey: 'orca-samples.recipes',
+ recipe: { id: 'global', name: 'Plugin Global', create: 'plugin-global' }
+ }
+ ])
+
+ registerEphemeralVmHandlers(makeStore(repoPath) as never, pluginService as never)
+ const result = (await handlers.get('ephemeralVm:listRecipes')?.(null, {
+ repoId: 'repo-1'
+ } as never)) as { recipes: { id: string; name: string }[] }
+
+ expect(pluginService.whenReady).toHaveBeenCalled()
+ expect(result.recipes).toMatchObject([
+ { id: 'shared', name: 'Repository Recipe' },
+ { id: 'global', name: 'Plugin Global' }
+ ])
+ })
+
+ it('uses an immutable plugin recipe snapshot after the plugin is removed', async () => {
+ const userDataPath = makeDir('orca-ephemeral-vm-ipc-user-data-')
+ const repoPath = makeDir('orca-ephemeral-vm-ipc-repo-')
+ getPathMock.mockReturnValue(userDataPath)
+ const startPath = join(repoPath, 'start.js')
+ const destroyPath = join(repoPath, 'destroy.js')
+ writeFileSync(
+ startPath,
+ `console.log(${JSON.stringify(
+ JSON.stringify({
+ schemaVersion: 1,
+ pairingCode: makePairingCode(),
+ projectRoot: '/workspace/repo'
+ })
+ )})`
+ )
+ writeFileSync(destroyPath, "require('fs').writeFileSync('plugin-cleaned.txt', 'yes')")
+ const registrations = [
+ {
+ pluginKey: 'orca-samples.recipes',
+ recipe: {
+ id: 'plugin-cloud',
+ name: 'Plugin Cloud',
+ create: nodeCommand(startPath),
+ destroy: nodeCommand(destroyPath)
+ }
+ }
+ ]
+ const pluginService = pluginServiceWithRecipes(registrations)
+ registerEphemeralVmHandlers(makeStore(repoPath) as never, pluginService as never)
+
+ const provisioned = (await handlers.get('ephemeralVm:provision')?.(null, {
+ repoId: 'repo-1',
+ recipeId: 'plugin-cloud'
+ } as never)) as { ok: true; runtime: { id: string; recipe?: { id: string } } }
+ registrations.splice(0)
+ const cleaned = await handlers.get('ephemeralVm:cleanup')?.(null, {
+ runtimeId: provisioned.runtime.id
+ } as never)
+
+ expect(provisioned.runtime.recipe).toMatchObject({ id: 'plugin-cloud' })
+ expect(cleaned).toEqual(expect.objectContaining({ status: 'cleaned' }))
+ expect(readFileSync(join(repoPath, 'plugin-cleaned.txt'), 'utf8')).toBe('yes')
+ })
+
+ it('never substitutes a later same-id plugin recipe for a legacy runtime', async () => {
+ const userDataPath = makeDir('orca-ephemeral-vm-ipc-user-data-')
+ const repoPath = makeDir('orca-ephemeral-vm-ipc-repo-')
+ getPathMock.mockReturnValue(userDataPath)
+ const pluginDestroyPath = join(repoPath, 'plugin-destroy.js')
+ writeFileSync(
+ pluginDestroyPath,
+ "require('fs').writeFileSync('plugin-destroy-ran.txt', 'unsafe')"
+ )
+ upsertEphemeralVmRuntime(userDataPath, {
+ id: 'legacy-runtime',
+ recipeId: 'shared-id',
+ repoId: 'repo-1',
+ status: 'running',
+ cleanupStatus: 'not_started',
+ createdAt: 1,
+ updatedAt: 1,
+ recipeResult: {
+ schemaVersion: 1,
+ pairingCode: makePairingCode(),
+ projectRoot: '/workspace/repo'
+ }
+ })
+ const pluginService = pluginServiceWithRecipes([
+ {
+ pluginKey: 'orca-samples.recipes',
+ recipe: {
+ id: 'shared-id',
+ name: 'Later Plugin Recipe',
+ create: 'create',
+ destroy: nodeCommand(pluginDestroyPath)
+ }
+ }
+ ])
+ registerEphemeralVmHandlers(makeStore(repoPath) as never, pluginService as never)
+
+ const cleaned = await handlers.get('ephemeralVm:cleanup')?.(null, {
+ runtimeId: 'legacy-runtime'
+ } as never)
+
+ expect(cleaned).toMatchObject({
+ status: 'cleanup_failed',
+ cleanupLastError: 'Recipe not found: shared-id'
+ })
+ expect(existsSync(join(repoPath, 'plugin-destroy-ran.txt'))).toBe(false)
+ })
+
it('provisions a recipe and persists the ephemeral runtime', async () => {
const userDataPath = makeDir('orca-ephemeral-vm-ipc-user-data-')
const repoPath = makeDir('orca-ephemeral-vm-ipc-repo-')
diff --git a/src/main/ipc/ephemeral-vm.ts b/src/main/ipc/ephemeral-vm.ts
index 9d071989c..f75c9c77d 100644
--- a/src/main/ipc/ephemeral-vm.ts
+++ b/src/main/ipc/ephemeral-vm.ts
@@ -1,6 +1,5 @@
import { app, ipcMain } from 'electron'
import type { Store } from '../persistence'
-import { loadHooks } from '../hooks'
import {
getEphemeralVmRecipeResultConnection,
getEphemeralVmRecipeResultWarnings,
@@ -27,9 +26,12 @@ import {
getRecipeRepo,
listRecipeCatalog,
listRecipes,
+ resolveRecipeForRepo,
type EphemeralVmRecipeCatalogEntry
} from './ephemeral-vm-recipe-context'
import { registerEphemeralVmRuntimeHandlers } from './ephemeral-vm-runtime-handlers'
+import type { PluginService } from '../plugins/plugin-service'
+import { getApprovedPluginVmRecipes } from '../plugins/plugin-approved-vm-recipes'
const activeProvisionControllers = new Map()
@@ -57,7 +59,7 @@ export type EphemeralVmProvisionIpcResult =
stdout: string
}
-export function registerEphemeralVmHandlers(store: Store): void {
+export function registerEphemeralVmHandlers(store: Store, pluginService?: PluginService): void {
ipcMain.removeHandler('ephemeralVm:listRecipes')
ipcMain.removeHandler('ephemeralVm:listRecipeCatalog')
ipcMain.removeHandler('ephemeralVm:doctor')
@@ -65,25 +67,32 @@ export function registerEphemeralVmHandlers(store: Store): void {
ipcMain.removeHandler('ephemeralVm:cancelProvision')
registerEphemeralVmRuntimeHandlers(store)
- ipcMain.handle('ephemeralVm:listRecipes', (_event, args: { repoId: string }) => {
- return listRecipes(store, args.repoId)
- })
-
- ipcMain.handle('ephemeralVm:listRecipeCatalog', (): EphemeralVmRecipeCatalogEntry[] => {
- return listRecipeCatalog(store)
+ ipcMain.handle('ephemeralVm:listRecipes', async (_event, args: { repoId: string }) => {
+ return listRecipes(store, args.repoId, await getApprovedPluginVmRecipes(pluginService))
})
+ ipcMain.handle(
+ 'ephemeralVm:listRecipeCatalog',
+ async (): Promise => {
+ return listRecipeCatalog(store, await getApprovedPluginVmRecipes(pluginService))
+ }
+ )
+
ipcMain.handle(
'ephemeralVm:doctor',
- (_event, args: { repoId: string; recipeId: string }): EphemeralVmRecipeDoctorResult => {
+ async (
+ _event,
+ args: { repoId: string; recipeId: string }
+ ): Promise => {
const repo = getRecipeRepo(store, args.repoId)
if (!repo.ok) {
return repo.doctor(args.recipeId)
}
+ const pluginRecipes = await getApprovedPluginVmRecipes(pluginService)
return doctorEphemeralVmRecipe({
repoPath: repo.repo.path,
recipeId: args.recipeId,
- recipes: loadHooks(repo.repo.path)?.environmentRecipes ?? [],
+ recipes: listRecipes(store, args.repoId, pluginRecipes).recipes,
localExecutionSupported: true
})
}
@@ -106,8 +115,10 @@ export function registerEphemeralVmHandlers(store: Store): void {
if (!repo.ok) {
return { ok: false, error: repo.message, stdout: '', stderr: '' }
}
- const recipe = (loadHooks(repo.repo.path)?.environmentRecipes ?? []).find(
- (entry) => entry.id === args.recipeId
+ const recipe = resolveRecipeForRepo(
+ repo.repo.path,
+ args.recipeId,
+ await getApprovedPluginVmRecipes(pluginService)
)
if (!recipe) {
return { ok: false, error: `Recipe not found: ${args.recipeId}`, stdout: '', stderr: '' }
diff --git a/src/main/ipc/keybindings.test.ts b/src/main/ipc/keybindings.test.ts
index 3f0aa37aa..ec8016287 100644
--- a/src/main/ipc/keybindings.test.ts
+++ b/src/main/ipc/keybindings.test.ts
@@ -75,6 +75,23 @@ describe('registerKeybindingHandlers', () => {
expect(authorizeExternalPathMock).toHaveBeenCalledWith(snapshot.path)
})
+ it('reconciles plugin command conflicts after a shortcut edit', () => {
+ const onChanged = vi.fn()
+ const setActionBindings = vi.fn(() => snapshot)
+ registerKeybindingHandlers({ setActionBindings } as never, onChanged)
+
+ expect(
+ getHandler('keybindings:setAction')(
+ {},
+ {
+ actionId: 'plugin:orca-samples.tasks/open',
+ bindings: ['Mod+Shift+T']
+ }
+ )
+ ).toBe(snapshot)
+ expect(onChanged).toHaveBeenCalledOnce()
+ })
+
it('authorizes the keybindings file before opening it outside Orca', async () => {
openPathMock.mockResolvedValue('')
registerKeybindingHandlers({ ensureFile: vi.fn(() => snapshot) } as never)
diff --git a/src/main/ipc/keybindings.ts b/src/main/ipc/keybindings.ts
index ae73c2b15..7a71f3116 100644
--- a/src/main/ipc/keybindings.ts
+++ b/src/main/ipc/keybindings.ts
@@ -13,7 +13,10 @@ function broadcastKeybindingsChanged(snapshot: KeybindingFileSnapshot): void {
rebuildAppMenu()
}
-export function registerKeybindingHandlers(service: KeybindingService): void {
+export function registerKeybindingHandlers(
+ service: KeybindingService,
+ onChanged?: () => void
+): void {
ipcMain.handle('keybindings:get', () => service.getSnapshot())
ipcMain.handle('keybindings:ensureFile', () => {
@@ -22,6 +25,7 @@ export function registerKeybindingHandlers(service: KeybindingService): void {
// workspace. Opening it in the editor still needs normal fs IPC access.
authorizeExternalPath(snapshot.path)
broadcastKeybindingsChanged(snapshot)
+ onChanged?.()
return snapshot
})
@@ -30,6 +34,7 @@ export function registerKeybindingHandlers(service: KeybindingService): void {
(_event, args: { actionId: KeybindingActionId; bindings: string[] | null }) => {
const snapshot = service.setActionBindings(args.actionId, args.bindings)
broadcastKeybindingsChanged(snapshot)
+ onChanged?.()
return snapshot
}
)
@@ -37,6 +42,7 @@ export function registerKeybindingHandlers(service: KeybindingService): void {
ipcMain.handle('keybindings:reload', () => {
const snapshot = service.reload()
broadcastKeybindingsChanged(snapshot)
+ onChanged?.()
return snapshot
})
diff --git a/src/main/ipc/plugin-marketplaces.test.ts b/src/main/ipc/plugin-marketplaces.test.ts
new file mode 100644
index 000000000..b1badba09
--- /dev/null
+++ b/src/main/ipc/plugin-marketplaces.test.ts
@@ -0,0 +1,172 @@
+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 type { PluginService } from '../plugins/plugin-service'
+
+type IpcHandler = (event: unknown, args?: unknown) => unknown
+
+const electronMocks = vi.hoisted(() => ({ handle: vi.fn() }))
+vi.mock('electron', () => ({ ipcMain: { handle: electronMocks.handle } }))
+
+import {
+ registerPluginMarketplaceHandlers,
+ type PluginMarketplaceHandlerServices
+} from './plugin-marketplaces'
+
+const SOURCE_ID = 'a'.repeat(32)
+const MARKETPLACE_COMMIT = 'b'.repeat(40)
+const PLUGIN_COMMIT = 'c'.repeat(40)
+const PLUGIN_KEY = 'orca-samples.demo'
+
+let handlers: Map
+
+function createServices(): PluginMarketplaceHandlerServices {
+ return {
+ 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 }])
+ } as unknown as PluginMarketplaceService,
+ installer: {
+ preview: vi.fn().mockResolvedValue({ pluginKey: PLUGIN_KEY }),
+ install: vi.fn().mockResolvedValue({ ok: true, pluginKey: PLUGIN_KEY }),
+ previewInstalledUpdate: vi.fn().mockResolvedValue({ pluginKey: PLUGIN_KEY }),
+ rollback: vi.fn().mockResolvedValue({ ok: true, pluginKey: PLUGIN_KEY })
+ } as unknown as PluginMarketplaceInstaller
+ }
+}
+
+function createPluginService(): PluginService {
+ return {
+ deactivatePlugin: vi.fn().mockResolvedValue(undefined),
+ refresh: vi.fn().mockResolvedValue(undefined)
+ } as unknown as PluginService
+}
+
+async function invoke(channel: string, args?: unknown): Promise {
+ const handler = handlers.get(channel)
+ if (!handler) {
+ throw new Error(`missing IPC handler: ${channel}`)
+ }
+ return handler({}, args)
+}
+
+beforeEach(() => {
+ handlers = new Map()
+ electronMocks.handle.mockReset()
+ electronMocks.handle.mockImplementation((channel: string, handler: IpcHandler) => {
+ handlers.set(channel, handler)
+ })
+})
+
+describe('plugin marketplace IPC authority', () => {
+ it('validates every mutating or plugin-selecting request strictly', async () => {
+ registerPluginMarketplaceHandlers(createPluginService(), createServices())
+
+ await expect(
+ invoke('plugins:addMarketplace', {
+ kind: 'git',
+ url: 'https://example.com/marketplace.git',
+ ref: 'main',
+ unexpected: true
+ })
+ ).rejects.toThrow()
+ await expect(
+ invoke('plugins:removeMarketplace', { sourceId: SOURCE_ID, unexpected: true })
+ ).rejects.toThrow()
+ await expect(
+ invoke('plugins:refreshMarketplaces', { sourceId: 'not-a-source' })
+ ).rejects.toThrow()
+ await expect(
+ invoke('plugins:previewMarketplacePlugin', {
+ marketplaceSourceId: SOURCE_ID,
+ pluginKey: '__proto__.demo'
+ })
+ ).rejects.toThrow()
+ await expect(
+ invoke('plugins:installMarketplacePlugin', {
+ marketplaceSourceId: SOURCE_ID,
+ marketplaceCommit: 'moving-ref',
+ pluginKey: PLUGIN_KEY,
+ resolvedCommit: PLUGIN_COMMIT
+ })
+ ).rejects.toThrow()
+ await expect(
+ invoke('plugins:previewMarketplaceUpdate', {
+ pluginKey: PLUGIN_KEY,
+ unexpected: true
+ })
+ ).rejects.toThrow()
+ await expect(
+ invoke('plugins:rollbackMarketplacePlugin', { pluginKey: 'bare-id' })
+ ).rejects.toThrow()
+ })
+
+ it('dispatches source listing, add, removal, and refresh operations', async () => {
+ const services = createServices()
+ registerPluginMarketplaceHandlers(createPluginService(), services)
+ const source = {
+ kind: 'git' as const,
+ url: 'https://example.com/marketplace.git',
+ ref: 'main'
+ }
+
+ await invoke('plugins:listMarketplaces')
+ await invoke('plugins:addMarketplace', source)
+ await invoke('plugins:removeMarketplace', { sourceId: SOURCE_ID })
+ await invoke('plugins:refreshMarketplaces', { sourceId: SOURCE_ID })
+ await invoke('plugins:refreshMarketplaces', {})
+ await invoke('plugins:listMarketplacePlugins')
+
+ expect(services.marketplace.listSources).toHaveBeenCalledTimes(2)
+ expect(services.marketplace.addSource).toHaveBeenCalledWith(source)
+ expect(services.marketplace.removeSource).toHaveBeenCalledWith(SOURCE_ID)
+ expect(services.marketplace.refreshSource).toHaveBeenCalledWith(SOURCE_ID)
+ expect(services.marketplace.refreshAll).toHaveBeenCalledOnce()
+ expect(services.marketplace.listPlugins).toHaveBeenCalledOnce()
+ })
+
+ it('refreshes discovery only after a successful install', async () => {
+ const services = createServices()
+ const pluginService = createPluginService()
+ registerPluginMarketplaceHandlers(pluginService, services)
+ const preview = {
+ marketplaceSourceId: SOURCE_ID,
+ marketplaceCommit: MARKETPLACE_COMMIT,
+ pluginKey: PLUGIN_KEY,
+ resolvedCommit: PLUGIN_COMMIT
+ }
+
+ await invoke('plugins:installMarketplacePlugin', preview)
+ expect(services.installer.install).toHaveBeenCalledWith(preview)
+ expect(pluginService.refresh).toHaveBeenCalledOnce()
+
+ vi.mocked(pluginService.refresh).mockClear()
+ vi.mocked(services.installer.install).mockResolvedValueOnce({ ok: false, error: 'failed' })
+ await invoke('plugins:installMarketplacePlugin', preview)
+ expect(pluginService.refresh).not.toHaveBeenCalled()
+ })
+
+ it('deactivates before rollback and refreshes discovery only on success', async () => {
+ const services = createServices()
+ const pluginService = createPluginService()
+ registerPluginMarketplaceHandlers(pluginService, services)
+
+ await invoke('plugins:rollbackMarketplacePlugin', { pluginKey: PLUGIN_KEY })
+
+ expect(pluginService.deactivatePlugin).toHaveBeenCalledWith(PLUGIN_KEY)
+ expect(services.installer.rollback).toHaveBeenCalledWith(PLUGIN_KEY)
+ expect(vi.mocked(pluginService.deactivatePlugin).mock.invocationCallOrder[0]).toBeLessThan(
+ vi.mocked(services.installer.rollback).mock.invocationCallOrder[0]
+ )
+ expect(pluginService.refresh).toHaveBeenCalledOnce()
+
+ vi.mocked(pluginService.refresh).mockClear()
+ vi.mocked(services.installer.rollback).mockResolvedValueOnce({ ok: false, error: 'failed' })
+ await invoke('plugins:rollbackMarketplacePlugin', { pluginKey: PLUGIN_KEY })
+ expect(pluginService.refresh).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/main/ipc/plugin-marketplaces.ts b/src/main/ipc/plugin-marketplaces.ts
new file mode 100644
index 000000000..8e27ec249
--- /dev/null
+++ b/src/main/ipc/plugin-marketplaces.ts
@@ -0,0 +1,76 @@
+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 type { PluginService } from '../plugins/plugin-service'
+
+export type PluginMarketplaceHandlerServices = {
+ marketplace: PluginMarketplaceService
+ installer: PluginMarketplaceInstaller
+}
+
+const sourceIdSchema = z.string().regex(PLUGIN_MARKETPLACE_SOURCE_ID_PATTERN)
+const removeMarketplaceSchema = z.strictObject({ sourceId: sourceIdSchema })
+const refreshMarketplaceSchema = z.strictObject({ sourceId: sourceIdSchema.optional() })
+const marketplacePluginSchema = z.strictObject({
+ marketplaceSourceId: sourceIdSchema,
+ pluginKey: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key')
+})
+const installMarketplacePluginSchema = marketplacePluginSchema.extend({
+ marketplaceCommit: z.string().regex(PLUGIN_COMMIT_PATTERN),
+ resolvedCommit: z.string().regex(PLUGIN_COMMIT_PATTERN)
+})
+const installedPluginSchema = z.strictObject({
+ pluginKey: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key')
+})
+
+export function registerPluginMarketplaceHandlers(
+ pluginService: PluginService,
+ services: PluginMarketplaceHandlerServices
+): void {
+ ipcMain.handle('plugins:listMarketplaces', () => services.marketplace.listSources())
+ ipcMain.handle('plugins:addMarketplace', async (_event, args: unknown) => {
+ const source = pluginMarketplaceGitSourceSchema.parse(args)
+ return services.marketplace.addSource(source)
+ })
+ ipcMain.handle('plugins:removeMarketplace', async (_event, args: unknown) => {
+ const { sourceId } = removeMarketplaceSchema.parse(args)
+ await services.marketplace.removeSource(sourceId)
+ return services.marketplace.listSources()
+ })
+ ipcMain.handle('plugins:refreshMarketplaces', async (_event, args: unknown) => {
+ const { sourceId } = refreshMarketplaceSchema.parse(args ?? {})
+ return sourceId
+ ? [await services.marketplace.refreshSource(sourceId)]
+ : services.marketplace.refreshAll()
+ })
+ ipcMain.handle('plugins:listMarketplacePlugins', () => 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:previewMarketplaceUpdate', async (_event, args: unknown) => {
+ const { pluginKey } = installedPluginSchema.parse(args)
+ return services.installer.previewInstalledUpdate(pluginKey)
+ })
+ ipcMain.handle('plugins:rollbackMarketplacePlugin', async (_event, args: unknown) => {
+ const { pluginKey } = installedPluginSchema.parse(args)
+ await pluginService.deactivatePlugin(pluginKey)
+ const result = await services.installer.rollback(pluginKey)
+ if (result.ok) {
+ await pluginService.refresh()
+ }
+ return result
+ })
+}
diff --git a/src/main/ipc/plugins.test.ts b/src/main/ipc/plugins.test.ts
new file mode 100644
index 000000000..cdbc8082e
--- /dev/null
+++ b/src/main/ipc/plugins.test.ts
@@ -0,0 +1,159 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { PluginLockfile } from '../../shared/plugins/plugin-install-lockfile'
+import type { PluginService } from '../plugins/plugin-service'
+import type { Store } from '../persistence'
+
+const electronMocks = vi.hoisted(() => ({ handle: vi.fn(), on: vi.fn() }))
+vi.mock('electron', () => ({
+ ipcMain: { handle: electronMocks.handle, on: electronMocks.on }
+}))
+
+import {
+ canRemoveInstalledPlugin,
+ parsePluginConsentArgs,
+ parsePluginInstallArgs,
+ registerPluginHandlers
+} from './plugins'
+
+beforeEach(() => {
+ electronMocks.handle.mockReset()
+ electronMocks.on.mockReset()
+})
+
+describe('plugin consent IPC schema', () => {
+ it('requires the fingerprint reviewed by the caller', () => {
+ expect(() =>
+ parsePluginConsentArgs({ pluginKey: 'orca-samples.demo', decision: 'approve' })
+ ).toThrow()
+ })
+
+ it('accepts an explicit reviewed fingerprint', () => {
+ expect(
+ parsePluginConsentArgs({
+ pluginKey: 'orca-samples.demo',
+ reviewedFingerprint: 'sha256-reviewed',
+ decision: 'approve'
+ })
+ ).toEqual({
+ pluginKey: 'orca-samples.demo',
+ reviewedFingerprint: 'sha256-reviewed',
+ decision: 'approve'
+ })
+ })
+})
+
+describe('plugin install IPC schema', () => {
+ it('requires a non-empty git ref', () => {
+ expect(() =>
+ parsePluginInstallArgs({ kind: 'git', url: 'https://example.com/plugin.git' })
+ ).toThrow()
+ expect(() =>
+ parsePluginInstallArgs({ kind: 'git', url: 'https://example.com/plugin.git', ref: ' ' })
+ ).toThrow()
+ })
+
+ it('accepts an explicit git ref', () => {
+ expect(
+ parsePluginInstallArgs({
+ kind: 'git',
+ url: 'https://example.com/plugin.git',
+ ref: ' v1.2.3 '
+ })
+ ).toEqual({ kind: 'git', url: 'https://example.com/plugin.git', ref: 'v1.2.3' })
+ })
+
+ it('accepts HTTPS and SSH git transports', () => {
+ expect(
+ parsePluginInstallArgs({
+ kind: 'git',
+ url: 'ssh://git@example.com/acme/plugin.git',
+ ref: 'main'
+ })
+ ).toEqual({
+ kind: 'git',
+ url: 'ssh://git@example.com/acme/plugin.git',
+ ref: 'main'
+ })
+ expect(
+ parsePluginInstallArgs({
+ kind: 'git',
+ url: 'git@example.com:acme/plugin.git',
+ ref: 'main'
+ })
+ ).toEqual({ kind: 'git', url: 'git@example.com:acme/plugin.git', ref: 'main' })
+ })
+
+ it('rejects executable helpers and embedded HTTPS credentials', () => {
+ expect(() =>
+ parsePluginInstallArgs({ kind: 'git', url: 'ext::sh -c calc', ref: 'main' })
+ ).toThrow()
+ expect(() =>
+ parsePluginInstallArgs({
+ kind: 'git',
+ url: 'https://user@example.com/plugin.git',
+ ref: 'main'
+ })
+ ).toThrow()
+ })
+})
+
+describe('plugin removal authority', () => {
+ it('allows installed rows but refuses dev overrides and unknown keys', () => {
+ const service = {
+ getDiscovered: () => [
+ { pluginKey: 'orca-samples.installed', isDev: false },
+ { pluginKey: 'orca-samples.dev', isDev: true }
+ ]
+ } as unknown as PluginService
+
+ expect(canRemoveInstalledPlugin(service, 'orca-samples.installed')).toBe(true)
+ expect(canRemoveInstalledPlugin(service, 'orca-samples.dev')).toBe(false)
+ expect(canRemoveInstalledPlugin(service, 'orca-samples.unknown')).toBe(false)
+ })
+
+ it('refuses bundled installs because startup would restore them', () => {
+ const service = {
+ getDiscovered: () => [{ pluginKey: 'stablyai.orca-theme', isDev: false }]
+ } as unknown as PluginService
+ const lock = {
+ version: 1,
+ plugins: {
+ 'stablyai.orca-theme': {
+ pluginKey: 'stablyai.orca-theme',
+ version: '1.0.0',
+ source: { kind: 'bundled', bundleId: 'stablyai.orca-theme' },
+ resolvedCommit: null,
+ contentHash: 'a'.repeat(64),
+ consentFingerprint: 'reviewed',
+ installedAt: 1
+ }
+ }
+ } satisfies PluginLockfile
+
+ expect(canRemoveInstalledPlugin(service, 'stablyai.orca-theme', lock)).toBe(false)
+ })
+})
+
+describe('plugin settings lifecycle authority', () => {
+ it('refreshes from the main-process settings listener without renderer follow-up', () => {
+ let settingsListener!: (updates: {
+ pluginSystemEnabled?: boolean
+ devPluginPaths?: string[]
+ }) => void
+ const store = {
+ onSettingsChanged: vi.fn((listener) => {
+ settingsListener = listener
+ return vi.fn()
+ })
+ } as unknown as Store
+ const service = {
+ setRuntimeDelegate: vi.fn(),
+ refresh: vi.fn().mockResolvedValue(undefined)
+ } as unknown as PluginService
+ registerPluginHandlers(store, service, null)
+
+ settingsListener({ pluginSystemEnabled: false })
+
+ expect(service.refresh).toHaveBeenCalledOnce()
+ })
+})
diff --git a/src/main/ipc/plugins.ts b/src/main/ipc/plugins.ts
new file mode 100644
index 000000000..29a788123
--- /dev/null
+++ b/src/main/ipc/plugins.ts
@@ -0,0 +1,257 @@
+import { ipcMain } 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 { getUserPluginsDir, getPluginsDataDir } from '../plugins/plugin-discovery'
+import {
+ installPluginFromGit,
+ installPluginFromLocalPath,
+ readPluginLockfile,
+ removeInstalledPlugin
+} from '../plugins/plugin-install'
+import { applyPluginConsent, applyPluginEnablement } from '../plugins/plugin-enablement'
+import { buildPluginList, type PluginListEntry } from '../plugins/plugin-list-projection'
+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 {
+ isAllowedPluginGitUrl,
+ type PluginLockfile
+} from '../../shared/plugins/plugin-install-lockfile'
+import {
+ registerPluginMarketplaceHandlers,
+ type PluginMarketplaceHandlerServices
+} from './plugin-marketplaces'
+
+export function parsePluginConsentArgs(args: unknown): z.infer {
+ return pluginConsentRequestSchema.parse(args)
+}
+
+const setEnabledArgsSchema = z.object({
+ pluginKey: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key'),
+ enabled: z.boolean()
+})
+
+const readPanelEntryArgsSchema = z.object({
+ pluginKey: z.string().min(1),
+ panelId: z.string().min(1)
+})
+
+const invokeCommandArgsSchema = z.object({
+ pluginKey: z.string().min(1),
+ commandId: z.string().min(1),
+ args: z.unknown().optional()
+})
+
+const installArgsSchema = z.discriminatedUnion('kind', [
+ z.object({ kind: z.literal('local-path'), path: z.string().min(1) }),
+ z.object({
+ kind: z.literal('git'),
+ url: z.string().trim().min(1).refine(isAllowedPluginGitUrl, 'git URL must use HTTPS or SSH'),
+ // Why: installs must stay reproducible even when callers bypass renderer validation.
+ ref: z.string().trim().min(1)
+ })
+])
+
+export function parsePluginInstallArgs(args: unknown): z.infer {
+ return installArgsSchema.parse(args)
+}
+
+const removeArgsSchema = z.object({
+ pluginKey: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key')
+})
+const logsArgsSchema = z.object({ pluginKey: z.string().min(1) })
+
+export async function listPluginsForClients(
+ pluginService: PluginService
+): Promise {
+ await pluginService.whenReady()
+ const lock = await readPluginLockfile(getUserPluginsDir(pluginService.options.userDataPath))
+ return buildPluginList(pluginService, lock)
+}
+
+export function canRemoveInstalledPlugin(
+ pluginService: PluginService,
+ pluginKey: string,
+ lock?: PluginLockfile
+): boolean {
+ return (
+ lock?.plugins[pluginKey]?.source.kind !== 'bundled' &&
+ pluginService.getDiscovered().some((plugin) => plugin.pluginKey === pluginKey && !plugin.isDev)
+ )
+}
+
+function rendererPanelOwner(webContentsId: number): string {
+ return `renderer:${webContentsId}`
+}
+
+export function registerPluginHandlers(
+ store: Store,
+ pluginService: PluginService,
+ runtime: OrcaRuntimeService | null,
+ marketplaceServices?: PluginMarketplaceHandlerServices
+): void {
+ // The runtime IS the delegate: the structural PluginRuntimeDelegate type
+ // keeps the facade electron-free while main binds the real service.
+ if (runtime) {
+ pluginService.setRuntimeDelegate(runtime)
+ }
+
+ store.onSettingsChanged((updates) => {
+ if ('pluginSystemEnabled' in updates || 'devPluginPaths' in updates) {
+ // Main owns plugin lifecycle. Renderer follow-up refreshes are UX only;
+ // a crashed or remote caller must not leave old workers authoritative.
+ void pluginService.refresh().catch((error) => {
+ console.warn('[plugins] failed to apply plugin settings change:', error)
+ })
+ }
+ })
+
+ // Why: startup discovery is fire-and-forget; every handler awaits it so an
+ // early renderer fetch can't observe the empty pre-discovery list.
+ ipcMain.handle('plugins:list', async () => listPluginsForClients(pluginService))
+ ipcMain.handle('plugins:listLanguagePacks', async () => {
+ await pluginService.whenReady()
+ return pluginService.contentPacks.languagePacks.list()
+ })
+ ipcMain.handle('plugins:consent', async (event, args: unknown) => {
+ await pluginService.whenReady()
+ const parsed = parsePluginConsentArgs(args)
+ await applyPluginConsent({
+ store,
+ pluginService,
+ pluginKey: parsed.pluginKey,
+ reviewedFingerprint: parsed.reviewedFingerprint,
+ decision: parsed.decision,
+ originWebContentsId: event.sender.id
+ })
+ return listPluginsForClients(pluginService)
+ })
+
+ ipcMain.handle('plugins:setEnabled', async (event, args: unknown) => {
+ await pluginService.whenReady()
+ const parsed = setEnabledArgsSchema.parse(args)
+ await applyPluginEnablement({
+ store,
+ pluginService,
+ pluginKey: parsed.pluginKey,
+ enabled: parsed.enabled,
+ originWebContentsId: event.sender.id
+ })
+ return listPluginsForClients(pluginService)
+ })
+
+ // Why: the renderer renders panel HTML via a sandboxed iframe srcdoc, so it
+ // needs (CSP-wrapped) file contents — never a file:// path — 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)
+ const entry = await pluginService.panels.open(ownerKey, parsed.pluginKey, parsed.panelId)
+ if (!ownerLease.isCurrent()) {
+ pluginService.panels.revokeOwner(ownerKey)
+ return null
+ }
+ return entry
+ }
+ )
+
+ // Panel-originated actions relayed by the renderer's postMessage bridge
+ // host. Capability enforcement happens in main, never in the renderer.
+ ipcMain.handle(
+ 'plugins:panelAction',
+ async (event, args: unknown): Promise => {
+ await pluginService.whenReady()
+ return pluginService.panels.execute(rendererPanelOwner(event.sender.id), args)
+ }
+ )
+
+ ipcMain.handle('plugins:invokeCommand', async (_event, args: unknown) => {
+ await pluginService.whenReady()
+ const parsed = invokeCommandArgsSchema.parse(args)
+ return pluginService.invokeCommand(parsed.pluginKey, parsed.commandId, parsed.args)
+ })
+
+ ipcMain.handle('plugins:install', async (_event, args: unknown) => {
+ await pluginService.whenReady()
+ const parsed = parsePluginInstallArgs(args)
+ const pluginsDir = getUserPluginsDir(pluginService.options.userDataPath)
+ const hostVersion = pluginService.options.hostVersion
+ const blockedPluginReason = (pluginKey: string): string | null =>
+ pluginService.options.getPluginKillListEntry?.(pluginKey)?.reason ?? null
+ const result =
+ parsed.kind === 'local-path'
+ ? await installPluginFromLocalPath({
+ pluginsDir,
+ sourcePath: parsed.path,
+ hostVersion,
+ blockedPluginReason
+ })
+ : await installPluginFromGit({
+ pluginsDir,
+ url: parsed.url,
+ ref: parsed.ref,
+ hostVersion,
+ blockedPluginReason
+ })
+ if (result.ok) {
+ await pluginService.refresh()
+ }
+ return result
+ })
+
+ ipcMain.handle('plugins:remove', async (event, args: unknown) => {
+ await pluginService.whenReady()
+ const parsed = removeArgsSchema.parse(args)
+ const pluginsDir = getUserPluginsDir(pluginService.options.userDataPath)
+ const lock = await readPluginLockfile(pluginsDir)
+ if (!canRemoveInstalledPlugin(pluginService, parsed.pluginKey, lock)) {
+ throw new Error(`cannot remove protected or non-installed plugin ${parsed.pluginKey}`)
+ }
+ await pluginService.deactivatePlugin(parsed.pluginKey)
+ await removeInstalledPlugin({
+ pluginsDir,
+ pluginsDataDir: getPluginsDataDir(pluginService.options.userDataPath),
+ pluginKey: parsed.pluginKey
+ })
+ // Drop the stale consent so a later reinstall re-prompts from scratch.
+ const settings = store.getSettings()
+ const consents = { ...settings.pluginConsents }
+ delete consents[parsed.pluginKey]
+ const disabledPlugins = normalizePluginIdList(settings.disabledPlugins).filter(
+ (pluginKey) => pluginKey !== parsed.pluginKey
+ )
+ store.updateSettings(
+ { pluginConsents: consents, disabledPlugins },
+ { notifyListeners: true, originWebContentsId: event.sender.id }
+ )
+ await pluginService.refresh()
+ return listPluginsForClients(pluginService)
+ })
+
+ ipcMain.handle('plugins:getLogs', async (_event, args: unknown) => {
+ const parsed = logsArgsSchema.parse(args)
+ return pluginService.getLogs(parsed.pluginKey)
+ })
+
+ // Re-discover after settings edits (feature flag, dev paths) — the
+ // renderer calls this right after updating those settings.
+ ipcMain.handle('plugins:refresh', async () => {
+ await pluginService.refresh()
+ return listPluginsForClients(pluginService)
+ })
+ if (marketplaceServices) {
+ registerPluginMarketplaceHandlers(pluginService, marketplaceServices)
+ }
+}
diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts
index d2b36bdf0..51a417f7a 100644
--- a/src/main/ipc/register-core-handlers.test.ts
+++ b/src/main/ipc/register-core-handlers.test.ts
@@ -532,7 +532,7 @@ describe('registerCoreHandlers', () => {
expect(registerFilesystemHandlersMock).toHaveBeenCalledWith(store)
expect(registerRuntimeHandlersMock).toHaveBeenCalledWith(runtime)
expect(registerRuntimeEnvironmentHandlersMock).toHaveBeenCalledWith(store)
- expect(registerEphemeralVmHandlersMock).toHaveBeenCalledWith(store)
+ expect(registerEphemeralVmHandlersMock).toHaveBeenCalledWith(store, undefined)
expect(registerAiVaultHandlersMock).toHaveBeenCalledWith(
expect.objectContaining({
getAdditionalCodexHomePaths: getAdditionalAiVaultCodexHomePaths,
diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts
index 2c834255e..25ddf0677 100644
--- a/src/main/ipc/register-core-handlers.ts
+++ b/src/main/ipc/register-core-handlers.ts
@@ -48,6 +48,7 @@ import { registerTelemetryHandlers } from './telemetry'
import { registerBrowserHandlers } from './browser'
import { registerShellHandlers } from './shell'
import { registerPetHandlers } from './pet'
+import { registerPluginHandlers } from './plugins'
import { registerUIHandlers, setTrustedUIRendererWebContentsId } from './ui'
import { registerEmulatorFrameStreamHandlers } from './emulator-frame-stream'
import { registerEmulatorVideoStreamHandlers } from './emulator-video-stream'
@@ -86,6 +87,8 @@ import {
prepareRuntimeAiVaultSessionResume,
scanRuntimeAiVaultSessions
} from '../ai-vault/runtime-session-scanner'
+import type { PluginService } from '../plugins/plugin-service'
+import type { PluginMarketplaceHandlerServices } from './plugin-marketplaces'
let registered = false
@@ -115,7 +118,9 @@ export function registerCoreHandlers(
agentAwakeService?: AgentAwakeService,
crashReports?: CrashReportStore,
keybindings?: KeybindingService,
- lifecycleOptions: CoreHandlerLifecycleOptions = {}
+ lifecycleOptions: CoreHandlerLifecycleOptions = {},
+ pluginService?: PluginService,
+ marketplaceServices?: PluginMarketplaceHandlerServices
): void {
// Why: on macOS the app can stay alive after all windows close, then
// openMainWindow() is called again on 'activate'. ipcMain.handle() throws
@@ -175,7 +180,12 @@ export function registerCoreHandlers(
registerAutomationHandlers(store, automations)
}
if (keybindings) {
- registerKeybindingHandlers(keybindings)
+ registerKeybindingHandlers(keybindings, () => {
+ void pluginService?.reconcileActivationState()
+ })
+ }
+ if (pluginService) {
+ registerPluginHandlers(store, pluginService, runtime, marketplaceServices)
}
registerTelemetryHandlers(store)
registerOrcaProfileHandlers(store, {
@@ -201,7 +211,7 @@ export function registerCoreHandlers(
registerFilesystemWatcherHandlers()
registerRuntimeHandlers(runtime)
registerRuntimeEnvironmentHandlers(store)
- registerEphemeralVmHandlers(store)
+ registerEphemeralVmHandlers(store, pluginService)
registerAiVaultHandlers({
getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths,
prepareSessionResume: lifecycleOptions.prepareAiVaultSessionResume,
diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts
index 01bbcbd12..71ca362f0 100644
--- a/src/main/ipc/settings.test.ts
+++ b/src/main/ipc/settings.test.ts
@@ -384,6 +384,27 @@ describe('registerSettingsHandlers', () => {
)
})
+ it('does not accept plugin authority grants from generic renderer settings IPC', async () => {
+ store.getSettings.mockReturnValue({ pluginConsents: {}, disabledPlugins: [] })
+ store.updateSettings.mockReturnValue({ pluginConsents: {}, disabledPlugins: [] })
+ registerSettingsHandlers(store as never)
+
+ const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as (
+ _event: unknown,
+ args: unknown
+ ) => Promise
+
+ await handler(settingsInvokeEvent, {
+ pluginConsents: { 'orca-samples.demo': 'sha256-forged' },
+ disabledPlugins: ['orca-samples.demo']
+ })
+
+ expect(store.updateSettings).toHaveBeenCalledWith(
+ {},
+ { notifyListeners: true, originWebContentsId: 1 }
+ )
+ })
+
it('normalizes terminal scrollback row updates and drops legacy byte updates', async () => {
store.getSettings.mockReturnValue({ terminalScrollbackRows: 5_000 })
store.updateSettings.mockReturnValue({ terminalScrollbackRows: 50_000 })
diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts
index c4e37b9c0..0166f4ad5 100644
--- a/src/main/ipc/settings.ts
+++ b/src/main/ipc/settings.ts
@@ -37,6 +37,10 @@ function sanitizeRendererSettingsUpdate(args: Partial): Partial<
const { terminalScrollbackBytes: _legacyScrollbackBytes, ...sanitizedArgs } =
args as LegacyTerminalScrollbackSettingsUpdate
void _legacyScrollbackBytes
+ // Plugin consent and enablement are main-owned authority state. Renderer
+ // writes must pass the dedicated reviewed-fingerprint handlers.
+ delete sanitizedArgs.pluginConsents
+ delete sanitizedArgs.disabledPlugins
return sanitizedArgs
}
diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts
index d41a92f90..43a0fbfbf 100644
--- a/src/main/ipc/worktrees.ts
+++ b/src/main/ipc/worktrees.ts
@@ -95,7 +95,7 @@ import {
isENOENT,
registerWorktreeRootsForRepo
} from './filesystem-auth'
-import type { OrcaRuntimeService } from '../runtime/orca-runtime'
+import type { OrcaRuntimeService, RuntimeWorktreeLifecycleEvent } from '../runtime/orca-runtime'
import { killAllProcessesForWorktree } from '../runtime/worktree-teardown'
import { clearProviderPtyState, getLocalPtyProvider, getSshPtyProvider } from './pty'
import { findExistingWorktreeSymlinkPaths, removeWorktreeLinkedPaths } from './worktree-symlinks'
@@ -987,7 +987,8 @@ function buildDisconnectedDetectedWorktrees(
export function registerWorktreeHandlers(
mainWindow: BrowserWindow,
store: Store,
- runtime: OrcaRuntimeService
+ runtime: OrcaRuntimeService,
+ options?: { onWorktreeLifecycle?: (event: RuntimeWorktreeLifecycleEvent) => void }
): void {
// Remove previously registered handlers so re-register works when macOS re-activates and creates a new window.
ipcMain.removeHandler('worktrees:listAll')
@@ -1285,6 +1286,13 @@ export function registerWorktreeHandlers(
notifyWorktreesChanged(mainWindow, repo.id)
}
+ options?.onWorktreeLifecycle?.({
+ kind: 'created',
+ worktreeId: result.worktree.id,
+ path: result.worktree.path,
+ branch: result.worktree.branch
+ })
+
return result
})
}
@@ -1902,7 +1910,13 @@ export function registerWorktreeHandlers(
})()
worktreeRemovalsInFlight.set(inFlightKey, { optionsKey, promise: removal })
try {
- return await removal
+ const result = await removal
+ options?.onWorktreeLifecycle?.({
+ kind: 'removed',
+ worktreeId: args.worktreeId,
+ path: parseWorktreeId(args.worktreeId).worktreePath
+ })
+ return result
} finally {
if (worktreeRemovalsInFlight.get(inFlightKey)?.promise === removal) {
worktreeRemovalsInFlight.delete(inFlightKey)
diff --git a/src/main/keybindings/keybinding-file.test.ts b/src/main/keybindings/keybinding-file.test.ts
index 4fd295d4c..a722e2c5e 100644
--- a/src/main/keybindings/keybinding-file.test.ts
+++ b/src/main/keybindings/keybinding-file.test.ts
@@ -92,6 +92,32 @@ describe('keybinding-file', () => {
})
})
+ it('preserves valid plugin overrides while rejecting malformed plugin action IDs', () => {
+ writeFileSync(
+ filePath,
+ JSON.stringify({
+ keybindings: {
+ 'plugin:orca-samples.tasks/open': 'Mod+Shift+T',
+ 'plugin:tasks/open': 'Mod+Alt+T'
+ }
+ }),
+ 'utf8'
+ )
+
+ const snapshot = readKeybindingFile(filePath, 'linux')
+ expect(snapshot.overrides).toEqual({
+ 'plugin:orca-samples.tasks/open': ['Mod+Shift+T']
+ })
+ expect(snapshot.diagnostics).toMatchObject([
+ { severity: 'warning', actionId: 'plugin:tasks/open' }
+ ])
+
+ writeKeybindingOverride(filePath, 'linux', 'plugin:orca-samples.tasks/open', [])
+ expect(readKeybindingFile(filePath, 'linux').overrides).toEqual({
+ 'plugin:orca-samples.tasks/open': []
+ })
+ })
+
it('ignores invalid, unknown, and conflicting manual edits', () => {
writeFileSync(
filePath,
diff --git a/src/main/persistence-right-sidebar-tab.test.ts b/src/main/persistence-right-sidebar-tab.test.ts
new file mode 100644
index 000000000..fcaf48afc
--- /dev/null
+++ b/src/main/persistence-right-sidebar-tab.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it, vi } from 'vitest'
+
+// Why: persistence.ts touches electron at import time; a minimal stub keeps
+// this normalizer test focused instead of booting the full Store fixture.
+vi.mock('electron', () => ({
+ app: {
+ getPath: () => '/tmp/orca-persistence-right-sidebar-tab-test'
+ },
+ safeStorage: {
+ isEncryptionAvailable: () => false,
+ encryptString: (plaintext: string) => Buffer.from(plaintext, 'utf-8'),
+ decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8')
+ }
+}))
+
+import { normalizeRightSidebarTab } from './persistence'
+
+describe('normalizeRightSidebarTab', () => {
+ it.each(['explorer', 'search', 'vault', 'workspaces', 'source-control', 'checks', 'ports'])(
+ 'preserves the built-in %s tab',
+ (tab) => {
+ expect(normalizeRightSidebarTab(tab)).toBe(tab)
+ }
+ )
+
+ // Regression: pr-checks was missing from the allow-list, so the folder
+ // PR Checks tab silently reset to Explorer on every app restart.
+ it('preserves the folder-only pr-checks tab across restarts', () => {
+ expect(normalizeRightSidebarTab('pr-checks')).toBe('pr-checks')
+ })
+
+ it('preserves well-formed plugin panel tabs', () => {
+ expect(normalizeRightSidebarTab('plugin:orca-samples.my-plugin/dashboard')).toBe(
+ 'plugin:orca-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:My_Plugin/Panel!')).toBe('explorer')
+ })
+
+ it('normalizes unknown values to the default tab', () => {
+ expect(normalizeRightSidebarTab('bogus')).toBe('explorer')
+ expect(normalizeRightSidebarTab(undefined)).toBe('explorer')
+ expect(normalizeRightSidebarTab(42)).toBe('explorer')
+ })
+})
diff --git a/src/main/persistence.ts b/src/main/persistence.ts
index 48495b7a1..593ba050b 100644
--- a/src/main/persistence.ts
+++ b/src/main/persistence.ts
@@ -72,6 +72,7 @@ import {
normalizeProjectRuntimePreference
} from '../shared/project-execution-runtime'
import { projectHostSetupProjectionFromRepos } from '../shared/project-host-setup-projection'
+import { isPluginPanelTabKey } from '../shared/plugins/plugin-manifest'
import type { GitRemoteIdentity } from '../shared/git-remote-identity'
import {
buildTaskSourceContextFromRepo,
@@ -781,18 +782,24 @@ function normalizeProjectOrderBy(projectOrderBy: unknown): PersistedState['ui'][
return getDefaultUIState().projectOrderBy
}
-function normalizeRightSidebarTab(tab: unknown): PersistedState['ui']['rightSidebarTab'] {
+export function normalizeRightSidebarTab(tab: unknown): PersistedState['ui']['rightSidebarTab'] {
if (
tab === 'explorer' ||
tab === 'search' ||
tab === 'vault' ||
tab === 'workspaces' ||
+ tab === 'pr-checks' ||
tab === 'source-control' ||
tab === 'checks' ||
tab === 'ports'
) {
return tab
}
+ // Why: plugin tabs are open-ended `plugin:./` keys; validate the
+ // shape so a persisted plugin tab doesn't reset to Explorer on restart.
+ if (typeof tab === 'string' && isPluginPanelTabKey(tab)) {
+ return tab
+ }
return getDefaultUIState().rightSidebarTab
}
diff --git a/src/main/plugins/plugin-activation-policy.ts b/src/main/plugins/plugin-activation-policy.ts
new file mode 100644
index 000000000..9ef9bd59b
--- /dev/null
+++ b/src/main/plugins/plugin-activation-policy.ts
@@ -0,0 +1,26 @@
+import {
+ getPluginActivationState,
+ type PluginConsentLists
+} from '../../shared/plugins/plugin-consent-state'
+import type { ValidDiscoveredPlugin } from './plugin-discovery'
+
+export function snapshotPluginConsentLists(source: {
+ getPluginConsents: () => Record
+ getDisabledPlugins: () => string[]
+}): PluginConsentLists {
+ return {
+ pluginConsents: source.getPluginConsents(),
+ disabledPlugins: source.getDisabledPlugins()
+ }
+}
+
+export function isPluginApproved(
+ enabled: boolean,
+ plugin: ValidDiscoveredPlugin,
+ lists: PluginConsentLists
+): boolean {
+ return (
+ enabled &&
+ getPluginActivationState(plugin.pluginKey, plugin.consentFingerprint, lists) === 'approved'
+ )
+}
diff --git a/src/main/plugins/plugin-approved-vm-recipes.ts b/src/main/plugins/plugin-approved-vm-recipes.ts
new file mode 100644
index 000000000..ab193a528
--- /dev/null
+++ b/src/main/plugins/plugin-approved-vm-recipes.ts
@@ -0,0 +1,12 @@
+import type { OrcaVmRecipe } from '../../shared/types'
+import type { PluginService } from './plugin-service'
+
+export async function getApprovedPluginVmRecipes(
+ pluginService?: PluginService
+): Promise {
+ if (!pluginService) {
+ return []
+ }
+ await pluginService.whenReady()
+ return pluginService.contentPacks.vmRecipes.list().map(({ recipe }) => recipe)
+}
diff --git a/src/main/plugins/plugin-artifact-validation.ts b/src/main/plugins/plugin-artifact-validation.ts
new file mode 100644
index 000000000..00e508b35
--- /dev/null
+++ b/src/main/plugins/plugin-artifact-validation.ts
@@ -0,0 +1,193 @@
+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'
+
+export type PluginArtifactValidationResult = { ok: true } | { ok: false; error: string }
+
+export const PLUGIN_PANEL_ENTRY_MAX_BYTES = 10 * 1024 * 1024
+export const PLUGIN_WORKER_ENTRY_MAX_BYTES = 50 * 1024 * 1024
+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
+
+type DeclaredArtifact =
+ | { label: string; path: string; kind: 'file'; maxBytes: number }
+ | { label: string; path: string; kind: 'directory' }
+
+function declaredArtifactPaths(manifest: PluginManifest): DeclaredArtifact[] {
+ return [
+ ...(manifest.icon
+ ? [
+ {
+ label: 'icon',
+ path: manifest.icon,
+ kind: 'file' as const,
+ maxBytes: PLUGIN_ICON_MAX_BYTES
+ }
+ ]
+ : []),
+ ...(manifest.main
+ ? [
+ {
+ label: 'worker entry',
+ path: manifest.main,
+ kind: 'file' as const,
+ maxBytes: PLUGIN_WORKER_ENTRY_MAX_BYTES
+ }
+ ]
+ : []),
+ ...manifest.contributes.panels.map((panel) => ({
+ label: `panel "${panel.id}" entry`,
+ path: panel.entry,
+ kind: 'file' as const,
+ maxBytes: PLUGIN_PANEL_ENTRY_MAX_BYTES
+ })),
+ ...manifest.contributes.languagePacks.map((languagePack) => ({
+ label: `language pack "${languagePack.locale}"`,
+ path: languagePack.path,
+ kind: 'file' as const,
+ maxBytes: PLUGIN_LANGUAGE_PACK_MAX_BYTES
+ })),
+ ...manifest.contributes.vmRecipes.map((recipe) => ({
+ label: 'VM recipe',
+ path: recipe.path,
+ kind: 'file' as const,
+ maxBytes: PLUGIN_VM_RECIPE_MAX_BYTES
+ })),
+ ...manifest.contributes.agents.map((agent) => ({
+ label: 'agent profile',
+ path: agent.path,
+ kind: 'file' as const,
+ maxBytes: PLUGIN_AGENT_PROFILE_MAX_BYTES
+ }))
+ ]
+}
+
+export async function resolveContainedPluginArtifact(
+ rootDir: string,
+ relativePath: string,
+ maxBytes = PLUGIN_WORKER_ENTRY_MAX_BYTES
+): Promise {
+ const rootReal = await realpath(resolve(rootDir))
+ return resolvePathFromRealRoot(rootDir, rootReal, relativePath, 'file', maxBytes)
+}
+
+export async function readContainedPluginArtifactText(
+ rootDir: string,
+ relativePath: string,
+ maxBytes: number
+): Promise {
+ const artifact = await resolveContainedPluginArtifact(rootDir, relativePath, maxBytes)
+ const chunks: Buffer[] = []
+ let totalBytes = 0
+ for await (const chunk of createReadStream(artifact)) {
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
+ totalBytes += bytes.byteLength
+ if (totalBytes > maxBytes) {
+ throw new Error(`exceeds the ${maxBytes}-byte artifact limit`)
+ }
+ chunks.push(bytes)
+ }
+ return Buffer.concat(chunks, totalBytes).toString('utf8')
+}
+
+async function resolvePathFromRealRoot(
+ rootDir: string,
+ rootReal: string,
+ relativePath: string,
+ kind: 'file' | 'directory',
+ maxBytes?: number
+): Promise {
+ const artifactReal = await realpath(resolve(rootDir, ...relativePath.split(/[\\/]/)))
+ const fromRoot = relative(rootReal, artifactReal)
+ if (
+ fromRoot.length === 0 ||
+ isAbsolute(fromRoot) ||
+ fromRoot === '..' ||
+ fromRoot.startsWith(`..${sep}`)
+ ) {
+ throw new Error('resolves outside the plugin directory')
+ }
+ const artifactStat = await stat(artifactReal)
+ if (kind === 'file' && !artifactStat.isFile()) {
+ throw new Error('is not a regular file')
+ }
+ if (kind === 'directory' && !artifactStat.isDirectory()) {
+ throw new Error('is not a directory')
+ }
+ if (kind === 'file' && maxBytes !== undefined && artifactStat.size > maxBytes) {
+ throw new Error(`exceeds the ${maxBytes}-byte artifact limit`)
+ }
+ return artifactReal
+}
+
+/** Presence and containment checks are bounded by the manifest's declared artifacts. */
+export async function validateDeclaredPluginArtifacts(
+ rootDir: string,
+ manifest: PluginManifest
+): Promise {
+ const artifacts = declaredArtifactPaths(manifest)
+ if (artifacts.length === 0) {
+ return { ok: true }
+ }
+ const seen = new Set()
+ let rootReal: string
+ try {
+ rootReal = await realpath(resolve(rootDir))
+ } catch (error) {
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
+ }
+ for (const artifact of artifacts) {
+ if (seen.has(artifact.path)) {
+ continue
+ }
+ seen.add(artifact.path)
+ try {
+ await resolvePathFromRealRoot(
+ rootDir,
+ rootReal,
+ artifact.path,
+ artifact.kind,
+ artifact.kind === 'file' ? artifact.maxBytes : undefined
+ )
+ } catch (error) {
+ return {
+ ok: false,
+ error: `${artifact.label} ${artifact.path}: ${error instanceof Error ? error.message : String(error)}`
+ }
+ }
+ }
+ return { ok: true }
+}
+
+/** Parses declared VM recipe artifacts at the immutable install boundary. */
+export async function validatePluginInstallContent(
+ rootDir: string,
+ manifest: PluginManifest
+): Promise {
+ const vmRecipeIds = new Set()
+ for (const contribution of manifest.contributes.vmRecipes) {
+ try {
+ const recipe = parsePluginVmRecipeArtifact(
+ await readContainedPluginArtifactText(
+ rootDir,
+ contribution.path,
+ PLUGIN_VM_RECIPE_MAX_BYTES
+ )
+ )
+ if (vmRecipeIds.has(recipe.id)) {
+ throw new Error(`duplicate VM recipe id "${recipe.id}"`)
+ }
+ vmRecipeIds.add(recipe.id)
+ } catch (error) {
+ return {
+ ok: false,
+ error: `VM recipe ${contribution.path}: ${error instanceof Error ? error.message : String(error)}`
+ }
+ }
+ }
+ return { ok: true }
+}
diff --git a/src/main/plugins/plugin-atomic-file-write.test.ts b/src/main/plugins/plugin-atomic-file-write.test.ts
new file mode 100644
index 000000000..11407f7f7
--- /dev/null
+++ b/src/main/plugins/plugin-atomic-file-write.test.ts
@@ -0,0 +1,187 @@
+import { mkdtemp, readFile, readdir, rm, stat } 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 * as FsPromises from 'node:fs/promises'
+import {
+ renamePluginFileWithWindowsRetry,
+ writePluginFileAtomically
+} from './plugin-atomic-file-write'
+
+// Windows AV/indexer locks cannot be provoked on CI, so queue the errno codes instead.
+const locks = vi.hoisted(() => ({ codes: [] as string[] }))
+
+vi.mock('node:fs/promises', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ rename: async (source: string, target: string) => {
+ const code = locks.codes.shift()
+ if (!code) {
+ return actual.rename(source, target)
+ }
+ throw Object.assign(new Error(`simulated ${code}`), { code })
+ }
+ }
+})
+
+function withPlatform(platform: string): () => void {
+ const original = Object.getOwnPropertyDescriptor(process, 'platform')
+ Object.defineProperty(process, 'platform', { value: platform, configurable: true })
+ return () => {
+ if (original) {
+ Object.defineProperty(process, 'platform', original)
+ }
+ }
+}
+
+describe('writePluginFileAtomically', () => {
+ let dir: string
+
+ beforeEach(async () => {
+ dir = await mkdtemp(join(tmpdir(), 'orca-plugin-atomic-'))
+ })
+
+ afterEach(async () => {
+ await rm(dir, { recursive: true, force: true })
+ })
+
+ it('writes contents and leaves no temp file behind', async () => {
+ const target = join(dir, 'current')
+ await writePluginFileAtomically(target, 'abc123')
+ expect(await readFile(target, 'utf8')).toBe('abc123')
+ expect(await readdir(dir)).toEqual(['current'])
+ })
+
+ it('replaces an existing file', async () => {
+ const target = join(dir, 'plugins.lock.json')
+ await writePluginFileAtomically(target, 'first')
+ await writePluginFileAtomically(target, 'second')
+ expect(await readFile(target, 'utf8')).toBe('second')
+ expect(await readdir(dir)).toEqual(['plugins.lock.json'])
+ })
+
+ it('applies the requested mode', async () => {
+ const target = join(dir, 'provenance.json')
+ await writePluginFileAtomically(target, '{}', { mode: 0o600 })
+ const mode = (await stat(target)).mode & 0o777
+ // Windows does not model POSIX permission bits.
+ if (process.platform !== 'win32') {
+ expect(mode).toBe(0o600)
+ }
+ })
+
+ it('cleans up the temp file when the write fails', async () => {
+ await expect(writePluginFileAtomically(join(dir, 'missing', 'x'), 'v')).rejects.toThrow()
+ expect(await readdir(dir)).toEqual([])
+ })
+
+ it('cleans up the temp file when the rename gives up', async () => {
+ const restorePlatform = withPlatform('win32')
+ locks.codes = Array.from({ length: 6 }, () => 'EPERM')
+ try {
+ await expect(writePluginFileAtomically(join(dir, 'current'), 'v')).rejects.toMatchObject({
+ code: 'EPERM'
+ })
+ expect(await readdir(dir)).toEqual([])
+ } finally {
+ restorePlatform()
+ locks.codes = []
+ }
+ })
+
+ it('runs concurrent writers to one target without leaking temp files', async () => {
+ const target = join(dir, 'sources.json')
+ await Promise.all(
+ Array.from({ length: 8 }, (_unused, index) =>
+ writePluginFileAtomically(target, `value-${index}`)
+ )
+ )
+ expect(await readdir(dir)).toEqual(['sources.json'])
+ expect(await readFile(target, 'utf8')).toMatch(/^value-\d$/)
+ })
+})
+
+describe('renamePluginFileWithWindowsRetry', () => {
+ it('renames when the source exists', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'orca-plugin-rename-'))
+ try {
+ await writePluginFileAtomically(join(dir, 'from'), 'payload')
+ await renamePluginFileWithWindowsRetry(join(dir, 'from'), join(dir, 'to'))
+ expect(await readFile(join(dir, 'to'), 'utf8')).toBe('payload')
+ } finally {
+ await rm(dir, { recursive: true, force: true })
+ }
+ })
+
+ it('rethrows a non-retryable error', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'orca-plugin-rename-'))
+ try {
+ await expect(
+ renamePluginFileWithWindowsRetry(join(dir, 'absent'), join(dir, 'to'))
+ ).rejects.toMatchObject({ code: 'ENOENT' })
+ } finally {
+ await rm(dir, { recursive: true, force: true })
+ }
+ })
+
+ describe('on Windows', () => {
+ let dir: string
+ let restorePlatform: () => void
+
+ beforeEach(async () => {
+ dir = await mkdtemp(join(tmpdir(), 'orca-plugin-rename-win-'))
+ restorePlatform = withPlatform('win32')
+ })
+
+ afterEach(async () => {
+ restorePlatform()
+ locks.codes = []
+ await rm(dir, { recursive: true, force: true })
+ })
+
+ it.each(['EPERM', 'EACCES', 'EBUSY'])('retries past a transient %s lock', async (code) => {
+ await writePluginFileAtomically(join(dir, 'from'), 'payload')
+ locks.codes = [code, code]
+ await renamePluginFileWithWindowsRetry(join(dir, 'from'), join(dir, 'to'))
+ expect(await readFile(join(dir, 'to'), 'utf8')).toBe('payload')
+ expect(locks.codes).toEqual([])
+ })
+
+ it('gives up after the last delay rather than looping forever', async () => {
+ await writePluginFileAtomically(join(dir, 'from'), 'payload')
+ // One more lock than there are delays, so the loop must exit on the bound.
+ locks.codes = Array.from({ length: 6 }, () => 'EBUSY')
+ await expect(
+ renamePluginFileWithWindowsRetry(join(dir, 'from'), join(dir, 'to'))
+ ).rejects.toMatchObject({ code: 'EBUSY' })
+ expect(locks.codes).toEqual([])
+ })
+
+ it('rethrows a non-retryable code without retrying', async () => {
+ await writePluginFileAtomically(join(dir, 'from'), 'payload')
+ locks.codes = ['ENOSPC', 'ENOSPC']
+ await expect(
+ renamePluginFileWithWindowsRetry(join(dir, 'from'), join(dir, 'to'))
+ ).rejects.toMatchObject({ code: 'ENOSPC' })
+ expect(locks.codes).toEqual(['ENOSPC'])
+ })
+ })
+
+ it('does not retry off Windows', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'orca-plugin-rename-posix-'))
+ const restorePlatform = withPlatform('linux')
+ try {
+ await writePluginFileAtomically(join(dir, 'from'), 'payload')
+ locks.codes = ['EPERM', 'EPERM']
+ await expect(
+ renamePluginFileWithWindowsRetry(join(dir, 'from'), join(dir, 'to'))
+ ).rejects.toMatchObject({ code: 'EPERM' })
+ expect(locks.codes).toEqual(['EPERM'])
+ } finally {
+ restorePlatform()
+ locks.codes = []
+ await rm(dir, { recursive: true, force: true })
+ }
+ })
+})
diff --git a/src/main/plugins/plugin-atomic-file-write.ts b/src/main/plugins/plugin-atomic-file-write.ts
new file mode 100644
index 000000000..e87b824f3
--- /dev/null
+++ b/src/main/plugins/plugin-atomic-file-write.ts
@@ -0,0 +1,55 @@
+import { randomUUID } from 'node:crypto'
+import { rename, rm, writeFile } from 'node:fs/promises'
+
+/**
+ * Atomic write for plugin state files (lockfile, provenance, pointers, caches).
+ *
+ * Why the retry: on Windows the rename can fail with EPERM/EACCES/EBUSY while
+ * antivirus or an indexer holds the target open (issue #1507). The repo's
+ * existing `renameFileWithWindowsRetry` covers the same hazard but is sync;
+ * every plugin write path is async, so the backoff parks on a timer instead.
+ */
+
+const WINDOWS_RENAME_RETRY_DELAYS_MS = [50, 100, 150, 200, 250]
+
+export type PluginAtomicWriteOptions = { mode?: number }
+
+export async function writePluginFileAtomically(
+ target: string,
+ contents: string,
+ options?: PluginAtomicWriteOptions
+): Promise {
+ // Unique temp name so concurrent writers in one plugins dir cannot collide.
+ const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`
+ try {
+ await writeFile(temporary, contents, { encoding: 'utf8', mode: options?.mode })
+ await renamePluginFileWithWindowsRetry(temporary, target)
+ } finally {
+ await rm(temporary, { force: true }).catch(() => undefined)
+ }
+}
+
+export async function renamePluginFileWithWindowsRetry(
+ source: string,
+ target: string
+): Promise {
+ for (let attempt = 0; ; attempt += 1) {
+ try {
+ await rename(source, target)
+ return
+ } catch (error) {
+ const code = (error as NodeJS.ErrnoException).code
+ const retryable = code === 'EPERM' || code === 'EACCES' || code === 'EBUSY'
+ if (
+ process.platform !== 'win32' ||
+ !retryable ||
+ attempt >= WINDOWS_RENAME_RETRY_DELAYS_MS.length
+ ) {
+ throw error
+ }
+ await new Promise((resolve) =>
+ setTimeout(resolve, WINDOWS_RENAME_RETRY_DELAYS_MS[attempt])
+ )
+ }
+ }
+}
diff --git a/src/main/plugins/plugin-audit-log.test.ts b/src/main/plugins/plugin-audit-log.test.ts
new file mode 100644
index 000000000..ab70de89d
--- /dev/null
+++ b/src/main/plugins/plugin-audit-log.test.ts
@@ -0,0 +1,38 @@
+import { mkdtemp, rm, stat } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { PluginAuditLog } from './plugin-audit-log'
+
+const roots: string[] = []
+
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+describe('PluginAuditLog retention', () => {
+ it('rotates bounded segments while preserving recent entries across the boundary', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'orca-plugin-audit-'))
+ roots.push(root)
+ const audit = new PluginAuditLog(root, { maxBytes: 240 })
+
+ for (let index = 0; index < 8; index += 1) {
+ await audit.record({
+ ts: index,
+ actor: 'plugin:orca-samples.demo',
+ method: 'storage.set',
+ summary: `key=${index}`,
+ outcome: 'ok'
+ })
+ }
+
+ await expect(stat(join(root, 'audit.log'))).resolves.toMatchObject({
+ isFile: expect.any(Function)
+ })
+ await expect(stat(join(root, 'audit.log.1'))).resolves.toMatchObject({
+ isFile: expect.any(Function)
+ })
+ const recent = await audit.readRecent(3)
+ expect(recent.map((entry) => entry.ts)).toEqual([5, 6, 7])
+ })
+})
diff --git a/src/main/plugins/plugin-audit-log.ts b/src/main/plugins/plugin-audit-log.ts
new file mode 100644
index 000000000..0b5f4cd30
--- /dev/null
+++ b/src/main/plugins/plugin-audit-log.ts
@@ -0,0 +1,86 @@
+import { appendFile, mkdir, readFile, rename, rm, stat } from 'node:fs/promises'
+import { dirname, join } from 'node:path'
+
+/**
+ * Append-only audit trail for host-API mutations performed on a plugin's
+ * behalf, with actor `plugin:`. One JSONL file so support and
+ * enterprise policy tooling can replay exactly what plugins did through the
+ * gated API. (Honest scope: worker code acting through its own Node access
+ * bypasses this — stated in the consent UI.) Mutation intents are awaited
+ * before their handler runs so an API-mediated write cannot outrun the log.
+ */
+
+export type PluginAuditEntry = {
+ ts: number
+ actor: `plugin:${string}`
+ method: string
+ /** Bounded summary — never full params (they may contain user content). */
+ summary: string
+ outcome: 'attempt' | 'ok' | 'error'
+}
+
+export class PluginAuditLog {
+ private readonly filePath: string
+ private readonly rotatedFilePath: string
+ private readonly maxBytes: number
+ private writeChain: Promise = Promise.resolve()
+ private fileBytes: number | null = null
+
+ constructor(pluginsDataDir: string, options: { maxBytes?: number } = {}) {
+ this.filePath = join(pluginsDataDir, 'audit.log')
+ this.rotatedFilePath = join(pluginsDataDir, 'audit.log.1')
+ this.maxBytes = options.maxBytes ?? 10 * 1024 * 1024
+ }
+
+ record(entry: PluginAuditEntry): Promise {
+ const write = this.writeChain.then(async () => {
+ await mkdir(dirname(this.filePath), { recursive: true })
+ const line = `${JSON.stringify(entry)}\n`
+ if (this.fileBytes === null) {
+ this.fileBytes = await stat(this.filePath).then(
+ (file) => file.size,
+ () => 0
+ )
+ }
+ const lineBytes = Buffer.byteLength(line, 'utf8')
+ if (this.fileBytes > 0 && this.fileBytes + lineBytes > this.maxBytes) {
+ await rm(this.rotatedFilePath, { force: true })
+ await rename(this.filePath, this.rotatedFilePath).catch((error) => {
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
+ throw error
+ }
+ })
+ this.fileBytes = 0
+ }
+ await appendFile(this.filePath, line, 'utf8')
+ this.fileBytes += lineBytes
+ })
+ // Keep the serialization chain usable after a failed append while still
+ // exposing this write's failure to the mutation chokepoint.
+ this.writeChain = write.catch(() => undefined)
+ return write
+ }
+
+ async flush(): Promise {
+ await this.writeChain
+ }
+
+ async readRecent(limit = 200): Promise {
+ try {
+ const [rotated, current] = await Promise.all(
+ [this.rotatedFilePath, this.filePath].map((path) => readFile(path, 'utf8').catch(() => ''))
+ )
+ const text = rotated + current
+ const lines = text.split('\n').filter((line) => line.length > 0)
+ return lines.slice(-limit).flatMap((line) => {
+ try {
+ return [JSON.parse(line) as PluginAuditEntry]
+ } catch {
+ return []
+ }
+ })
+ } catch {
+ return []
+ }
+ }
+}
diff --git a/src/main/plugins/plugin-bundled-bootstrap-coordinator.test.ts b/src/main/plugins/plugin-bundled-bootstrap-coordinator.test.ts
new file mode 100644
index 000000000..ab6543bc6
--- /dev/null
+++ b/src/main/plugins/plugin-bundled-bootstrap-coordinator.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { PluginBundledBootstrapResult } from './plugin-bundled-bootstrap'
+import { PluginBundledBootstrapCoordinator } from './plugin-bundled-bootstrap-coordinator'
+
+const unchanged: PluginBundledBootstrapResult = {
+ installed: [],
+ unchanged: ['stablyai.orca-theme'],
+ errors: []
+}
+
+describe('PluginBundledBootstrapCoordinator', () => {
+ it('skips disabled requests and refreshes discovery only after publication', async () => {
+ let enabled = false
+ const bootstrap = vi
+ .fn()
+ .mockResolvedValueOnce(unchanged)
+ .mockResolvedValueOnce({
+ installed: ['stablyai.orca-theme'],
+ unchanged: [],
+ errors: []
+ })
+ const refreshPlugins = vi.fn().mockResolvedValue(undefined)
+ const coordinator = new PluginBundledBootstrapCoordinator({
+ root: 'resources',
+ userDataPath: 'user-data',
+ hostVersion: '1.4.0',
+ isEnabled: () => enabled,
+ refreshPlugins,
+ bootstrap
+ })
+
+ await expect(coordinator.request()).resolves.toBeNull()
+ enabled = true
+ await expect(coordinator.request()).resolves.toEqual(unchanged)
+ expect(refreshPlugins).not.toHaveBeenCalled()
+ await coordinator.request()
+ expect(refreshPlugins).toHaveBeenCalledOnce()
+ })
+
+ it('serializes overlapping startup and feature-toggle requests', async () => {
+ let active = 0
+ let maximumActive = 0
+ let releaseFirst: (() => void) | undefined
+ const firstGate = new Promise((resolve) => {
+ releaseFirst = resolve
+ })
+ const bootstrap = vi.fn(async (): Promise => {
+ active += 1
+ maximumActive = Math.max(maximumActive, active)
+ if (bootstrap.mock.calls.length === 1) {
+ await firstGate
+ }
+ active -= 1
+ return unchanged
+ })
+ const coordinator = new PluginBundledBootstrapCoordinator({
+ root: 'resources',
+ userDataPath: 'user-data',
+ hostVersion: '1.4.0',
+ isEnabled: () => true,
+ refreshPlugins: vi.fn().mockResolvedValue(undefined),
+ bootstrap
+ })
+
+ const first = coordinator.request()
+ const second = coordinator.request()
+ await vi.waitFor(() => expect(bootstrap).toHaveBeenCalledTimes(1))
+ releaseFirst?.()
+ await Promise.all([first, second])
+
+ expect(bootstrap).toHaveBeenCalledTimes(2)
+ expect(maximumActive).toBe(1)
+ })
+})
diff --git a/src/main/plugins/plugin-bundled-bootstrap-coordinator.ts b/src/main/plugins/plugin-bundled-bootstrap-coordinator.ts
new file mode 100644
index 000000000..74fae9b63
--- /dev/null
+++ b/src/main/plugins/plugin-bundled-bootstrap-coordinator.ts
@@ -0,0 +1,48 @@
+import {
+ bootstrapBundledPlugins,
+ type PluginBundledBootstrapResult
+} from './plugin-bundled-bootstrap'
+
+type PluginBundledBootstrapRequest = Parameters[0]
+
+export class PluginBundledBootstrapCoordinator {
+ private readonly options: PluginBundledBootstrapRequest & {
+ isEnabled: () => boolean
+ refreshPlugins: () => Promise
+ bootstrap?: typeof bootstrapBundledPlugins
+ }
+ private pending: Promise = Promise.resolve()
+
+ constructor(options: PluginBundledBootstrapCoordinator['options']) {
+ this.options = options
+ }
+
+ request(): Promise {
+ const run = this.pending.then(() => this.runOnce())
+ // Why: feature-toggle and startup requests can overlap; preserve their
+ // order even when one resource read fails.
+ this.pending = run.then(
+ () => undefined,
+ () => undefined
+ )
+ return run
+ }
+
+ private async runOnce(): Promise {
+ if (!this.options.isEnabled()) {
+ return null
+ }
+ const result = await (this.options.bootstrap ?? bootstrapBundledPlugins)({
+ root: this.options.root,
+ userDataPath: this.options.userDataPath,
+ hostVersion: this.options.hostVersion,
+ ...(this.options.blockedPluginReason
+ ? { blockedPluginReason: this.options.blockedPluginReason }
+ : {})
+ })
+ if (result.installed.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
new file mode 100644
index 000000000..d5e2e64bd
--- /dev/null
+++ b/src/main/plugins/plugin-bundled-bootstrap.test.ts
@@ -0,0 +1,134 @@
+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 { hashPluginTree } from './plugin-content-hash'
+import { readPluginLockfile } from './plugin-install'
+import { bootstrapBundledPlugins, resolveBundledPluginRoot } from './plugin-bundled-bootstrap'
+
+const roots: string[] = []
+
+async function tempRoot(prefix: string): Promise {
+ const root = await mkdtemp(join(tmpdir(), prefix))
+ roots.push(root)
+ return root
+}
+
+async function writeBundle(root: string, name = 'Skills'): Promise<{ path: string; hash: string }> {
+ const path = 'stablyai.orca-skills'
+ const pluginRoot = join(root, path)
+ await mkdir(pluginRoot, { recursive: true })
+ await writeFile(
+ join(pluginRoot, 'orca-plugin.json'),
+ JSON.stringify({
+ manifestVersion: 1,
+ id: 'orca-skills',
+ publisher: 'stablyai',
+ name,
+ version: '1.0.0',
+ engines: { orca: '>=1.0.0' },
+ pluginApi: 1,
+ capabilities: []
+ })
+ )
+ const hashed = await hashPluginTree(pluginRoot)
+ if (!hashed.ok) {
+ throw new Error(hashed.error)
+ }
+ return { path, hash: hashed.hash }
+}
+
+async function writeIndex(root: string, path: string, contentHash: string): Promise {
+ await writeFile(
+ join(root, 'bundled-plugins.json'),
+ JSON.stringify({
+ version: 1,
+ plugins: [{ pluginKey: 'stablyai.orca-skills', path, contentHash }]
+ })
+ )
+}
+
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+describe('bundled plugin bootstrap', () => {
+ it('installs release-indexed content once and keeps unchanged startup work bounded', 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 expect(
+ bootstrapBundledPlugins({ root, userDataPath, hostVersion: '1.4.0' })
+ ).resolves.toEqual({ installed: ['stablyai.orca-skills'], unchanged: [], errors: [] })
+ await expect(
+ bootstrapBundledPlugins({ root, userDataPath, hostVersion: '1.4.0' })
+ ).resolves.toEqual({ installed: [], unchanged: ['stablyai.orca-skills'], errors: [] })
+ })
+
+ it('publishes an updated immutable bundle only when the indexed hash matches', async () => {
+ const root = await tempRoot('orca-bundled-resources-')
+ const userDataPath = await tempRoot('orca-bundled-user-data-')
+ const first = await writeBundle(root)
+ await writeIndex(root, first.path, first.hash)
+ await bootstrapBundledPlugins({ root, userDataPath, hostVersion: '1.4.0' })
+ const second = await writeBundle(root, 'Updated Skills')
+ await writeIndex(root, second.path, second.hash)
+
+ const updated = await bootstrapBundledPlugins({ root, userDataPath, hostVersion: '1.4.0' })
+
+ expect(updated).toEqual({ installed: ['stablyai.orca-skills'], unchanged: [], errors: [] })
+ const lock = await readPluginLockfile(join(userDataPath, 'plugins'))
+ expect(lock.plugins['stablyai.orca-skills']?.contentHash).toBe(second.hash)
+ })
+
+ it('repairs a missing or modified bundled current version', 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 versionDir = join(userDataPath, 'plugins', 'stablyai.orca-skills', bundle.hash)
+ await writeFile(join(versionDir, 'orca-plugin.json'), '{}')
+
+ await expect(
+ bootstrapBundledPlugins({ root, userDataPath, hostVersion: '1.4.0' })
+ ).resolves.toEqual({ installed: ['stablyai.orca-skills'], unchanged: [], 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: [] })
+ })
+
+ it('refuses mismatched release hashes before publication', 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, 'f'.repeat(64))
+
+ const result = await bootstrapBundledPlugins({ root, userDataPath, hostVersion: '1.4.0' })
+
+ expect(result.installed).toEqual([])
+ expect(result.errors[0]?.error).toContain('does not match its release index')
+ expect((await readPluginLockfile(join(userDataPath, 'plugins'))).plugins).toEqual({})
+ })
+
+ it('resolves packaged and development resource roots without platform separators', () => {
+ expect(
+ resolveBundledPluginRoot({
+ isPackaged: true,
+ resourcesPath: join('app', 'resources'),
+ appPath: join('repo', 'app')
+ })
+ ).toBe(join('app', 'resources', 'plugins', 'launch'))
+ expect(
+ resolveBundledPluginRoot({
+ isPackaged: false,
+ resourcesPath: join('app', 'resources'),
+ appPath: join('repo', 'app')
+ })
+ ).toBe(join('repo', 'app', 'resources', 'plugins', 'launch'))
+ })
+})
diff --git a/src/main/plugins/plugin-bundled-bootstrap.ts b/src/main/plugins/plugin-bundled-bootstrap.ts
new file mode 100644
index 000000000..a7cfbbc17
--- /dev/null
+++ b/src/main/plugins/plugin-bundled-bootstrap.ts
@@ -0,0 +1,154 @@
+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 { hashPluginTree } from './plugin-content-hash'
+
+export const BUNDLED_PLUGIN_INDEX_FILENAME = 'bundled-plugins.json'
+const BUNDLED_PLUGIN_INDEX_MAX_BYTES = 64 * 1024
+
+const bundledPluginIndexSchema = z
+ .object({
+ version: z.literal(1),
+ plugins: z
+ .array(
+ z
+ .object({
+ pluginKey: z
+ .string()
+ .refine(isQualifiedPluginKey, 'invalid qualified plugin identity')
+ .refine(isOfficialPluginIdentity, 'bundled plugins must use an official identity'),
+ path: pluginRelativeDirectorySchema,
+ contentHash: z.string().regex(/^[0-9a-f]{64}$/)
+ })
+ .strict()
+ )
+ .max(32)
+ })
+ .strict()
+ .superRefine((index, ctx) => {
+ const keys = new Set()
+ for (const [entryIndex, plugin] of index.plugins.entries()) {
+ if (keys.has(plugin.pluginKey)) {
+ ctx.addIssue({
+ code: 'custom',
+ path: ['plugins', entryIndex, 'pluginKey'],
+ message: 'duplicate bundled plugin identity'
+ })
+ }
+ keys.add(plugin.pluginKey)
+ }
+ })
+
+export type PluginBundledBootstrapResult = {
+ installed: string[]
+ unchanged: string[]
+ errors: { pluginKey: string; error: string }[]
+}
+
+export function resolveBundledPluginRoot(options: {
+ isPackaged: boolean
+ resourcesPath: string
+ appPath: string
+}): string {
+ return options.isPackaged
+ ? join(options.resourcesPath, 'plugins', 'launch')
+ : join(options.appPath, 'resources', 'plugins', 'launch')
+}
+
+async function readBundledPluginIndex(
+ root: string
+): Promise> {
+ const indexPath = join(root, BUNDLED_PLUGIN_INDEX_FILENAME)
+ const metadata = await stat(indexPath)
+ if (!metadata.isFile() || metadata.size > BUNDLED_PLUGIN_INDEX_MAX_BYTES) {
+ throw new Error(`bundled plugin index exceeds ${BUNDLED_PLUGIN_INDEX_MAX_BYTES} bytes`)
+ }
+ return bundledPluginIndexSchema.parse(JSON.parse(await readFile(indexPath, 'utf8')))
+}
+
+async function resolveBundlePath(root: string, path: string): Promise {
+ const [resolvedRoot, resolvedPath] = await Promise.all([
+ realpath(root),
+ realpath(join(root, path))
+ ])
+ const fromRoot = relative(resolvedRoot, resolvedPath)
+ if (!fromRoot || fromRoot === '..' || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) {
+ throw new Error('bundled plugin path escapes the resource root')
+ }
+ return resolvedPath
+}
+
+async function bundledInstallIsIntact(
+ pluginsDir: string,
+ pluginKey: string,
+ contentHash: string
+): Promise {
+ const pluginDir = join(pluginsDir, pluginKey)
+ if ((await readPluginCurrentPointer(pluginDir).catch(() => null)) !== contentHash) {
+ return false
+ }
+ const hashed = await hashPluginTree(join(pluginDir, contentHash))
+ return hashed.ok && hashed.hash === contentHash
+}
+
+export async function bootstrapBundledPlugins(options: {
+ root: string
+ userDataPath: string
+ hostVersion: string
+ blockedPluginReason?: (pluginKey: string) => string | null
+}): Promise {
+ const index = await readBundledPluginIndex(options.root)
+ const pluginsDir = getUserPluginsDir(options.userDataPath)
+ const lock = await readPluginLockfile(pluginsDir)
+ const result: PluginBundledBootstrapResult = { installed: [], unchanged: [], errors: [] }
+ for (const entry of index.plugins) {
+ const locked = lock.plugins[entry.pluginKey]
+ if (
+ locked?.source.kind === 'bundled' &&
+ locked.source.bundleId === entry.pluginKey &&
+ locked.contentHash === entry.contentHash &&
+ (await bundledInstallIsIntact(pluginsDir, entry.pluginKey, entry.contentHash))
+ ) {
+ result.unchanged.push(entry.pluginKey)
+ continue
+ }
+ try {
+ const sourcePath = await resolveBundlePath(options.root, entry.path)
+ const inspection = await inspectPluginInstallTree({
+ rootDir: sourcePath,
+ hostVersion: options.hostVersion,
+ expectedPluginKey: entry.pluginKey
+ })
+ if (!inspection.ok) {
+ throw new Error(inspection.error)
+ }
+ if (inspection.contentHash !== entry.contentHash) {
+ throw new Error('bundled plugin content does not match its release index')
+ }
+ const installed = await installBundledPlugin({
+ pluginsDir,
+ sourcePath,
+ hostVersion: options.hostVersion,
+ expectedPluginKey: entry.pluginKey,
+ blockedPluginReason: options.blockedPluginReason
+ })
+ if (!installed.ok) {
+ throw new Error(installed.error)
+ }
+ result.installed.push(entry.pluginKey)
+ } catch (error) {
+ result.errors.push({
+ pluginKey: entry.pluginKey,
+ error: error instanceof Error ? error.message : String(error)
+ })
+ }
+ }
+ return result
+}
diff --git a/src/main/plugins/plugin-command-invocation.ts b/src/main/plugins/plugin-command-invocation.ts
new file mode 100644
index 000000000..28ff762bf
--- /dev/null
+++ b/src/main/plugins/plugin-command-invocation.ts
@@ -0,0 +1,13 @@
+import type { ValidDiscoveredPlugin } from './plugin-discovery'
+
+export function assertPluginWorkerCommand(plugin: ValidDiscoveredPlugin, commandId: string): void {
+ const command = plugin.manifest.contributes.commands.find((entry) => entry.id === commandId)
+ if (!command) {
+ throw new Error(`plugin ${plugin.pluginKey} does not contribute command ${commandId}`)
+ }
+ // Declarative aliases are renderer-owned and must never cross the worker
+ // activation boundary, even if a compromised renderer invokes IPC directly.
+ if (command.action !== undefined) {
+ throw new Error(`plugin ${plugin.pluginKey} command ${commandId} is a built-in action alias`)
+ }
+}
diff --git a/src/main/plugins/plugin-command-registry.test.ts b/src/main/plugins/plugin-command-registry.test.ts
new file mode 100644
index 000000000..e05c63514
--- /dev/null
+++ b/src/main/plugins/plugin-command-registry.test.ts
@@ -0,0 +1,180 @@
+import { describe, expect, it } from 'vitest'
+import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint'
+import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest'
+import type { ValidDiscoveredPlugin } from './plugin-discovery'
+import { PluginCommandRegistry } from './plugin-command-registry'
+
+function commandPlugin(
+ id: string,
+ contributes: {
+ commands: Record[]
+ keybindings?: Record[]
+ }
+): ValidDiscoveredPlugin {
+ const manifest = pluginManifestSchema.parse({
+ manifestVersion: 1,
+ id,
+ publisher: 'orca-samples',
+ name: id,
+ version: '1.0.0',
+ engines: { orca: '>=1.0.0' },
+ pluginApi: 1,
+ ...(contributes.commands.some((command) => command.action === undefined)
+ ? { main: 'worker.js' }
+ : {}),
+ contributes,
+ capabilities: []
+ })
+ return {
+ pluginKey: `orca-samples.${id}`,
+ rootDir: `/plugins/${id}`,
+ manifest,
+ consentFingerprint: fingerprintPluginConsent(manifest, `content-${id}`),
+ consentContentHash: `content-${id}`,
+ contentHash: `content-${id}`,
+ isDev: false
+ }
+}
+
+describe('PluginCommandRegistry', () => {
+ it('retains pending previews and exposes only approved commands', () => {
+ const plugin = commandPlugin('aliases', {
+ commands: [{ id: 'tasks', title: 'Open Tasks', action: 'view.tasks' }],
+ keybindings: [{ command: 'tasks', key: 'mod+alt+t' }]
+ })
+ const registry = new PluginCommandRegistry()
+
+ registry.reconcile([plugin], () => false)
+ expect(registry.list()).toEqual([])
+ expect(registry.preview(plugin.pluginKey)).toEqual([
+ {
+ pluginKey: plugin.pluginKey,
+ id: 'tasks',
+ title: 'Open Tasks',
+ context: 'global',
+ handler: { type: 'built-in', action: 'view.tasks' },
+ keybindings: [{ key: 'Mod+Alt+T', when: 'global' }]
+ }
+ ])
+
+ registry.reconcile([plugin], () => true)
+ expect(registry.list()).toHaveLength(1)
+ })
+
+ it('projects worker commands and inherited worktree keybinding context', () => {
+ const plugin = commandPlugin('worker', {
+ commands: [{ id: 'create', title: 'Create Task', context: 'worktree' }],
+ keybindings: [{ command: 'create', key: 'Mod+Shift+A' }]
+ })
+ const registry = new PluginCommandRegistry()
+
+ registry.reconcile([plugin], () => true)
+
+ expect(registry.list()).toMatchObject([
+ {
+ context: 'worktree',
+ handler: { type: 'worker' },
+ keybindings: [{ key: 'Mod+Shift+A', when: 'worktree' }]
+ }
+ ])
+ })
+
+ it('errors approved plugins whose keybindings overlap', () => {
+ const global = commandPlugin('global', {
+ commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }],
+ 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' }]
+ })
+ const registry = new PluginCommandRegistry()
+
+ registry.reconcile([global, worktree], () => true)
+
+ expect(registry.list()).toEqual([])
+ expect(registry.error(global.pluginKey)).toContain('conflicts')
+ expect(registry.error(worktree.pluginKey)).toContain('conflicts')
+ })
+
+ it('uses saved effective bindings to recover conflicting plugins', () => {
+ const first = commandPlugin('first', {
+ commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }],
+ keybindings: [{ command: 'tasks', key: 'Mod+Alt+T' }]
+ })
+ const second = commandPlugin('second', {
+ commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }],
+ keybindings: [{ command: 'tasks', key: 'Mod+Alt+T' }]
+ })
+ const registry = new PluginCommandRegistry()
+
+ registry.reconcile(
+ [first, second],
+ () => true,
+ { 'plugin:orca-samples.first/tasks': ['Mod+Shift+T'] },
+ 'linux'
+ )
+
+ expect(registry.list()).toHaveLength(2)
+ expect(registry.error(first.pluginKey)).toBeNull()
+ expect(registry.error(second.pluginKey)).toBeNull()
+ })
+
+ it('rejects conflicting saved bindings within one plugin', () => {
+ const plugin = commandPlugin('aliases', {
+ commands: [
+ { id: 'tasks', title: 'Tasks', action: 'view.tasks' },
+ { id: 'sidebar', title: 'Sidebar', action: 'sidebar.left.toggle' }
+ ]
+ })
+ const registry = new PluginCommandRegistry()
+
+ registry.reconcile(
+ [plugin],
+ () => true,
+ {
+ 'plugin:orca-samples.aliases/tasks': ['Mod+Alt+T'],
+ 'plugin:orca-samples.aliases/sidebar': ['Mod+Alt+T']
+ },
+ 'linux'
+ )
+
+ expect(registry.list()).toEqual([])
+ expect(registry.error(plugin.pluginKey)).toContain('conflicts')
+ })
+
+ it('detects cross-platform Mod and physical Ctrl conflicts', () => {
+ const portable = commandPlugin('portable', {
+ commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }],
+ keybindings: [{ command: 'tasks', key: 'Mod+Alt+T' }]
+ })
+ const physical = commandPlugin('physical', {
+ commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }],
+ keybindings: [{ command: 'tasks', key: 'Ctrl+Alt+T' }]
+ })
+ const registry = new PluginCommandRegistry()
+
+ registry.reconcile([portable, physical], () => true, {}, 'linux')
+
+ expect(registry.list()).toEqual([])
+ expect(registry.error(portable.pluginKey)).toContain('conflicts')
+ expect(registry.error(physical.pluginKey)).toContain('conflicts')
+ })
+
+ 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' }],
+ keybindings: [{ command: 'tasks', key: 'Mod+Alt+T' }]
+ })
+ const second = commandPlugin('second', {
+ commands: [{ id: 'tasks', title: 'Tasks', context: 'worktree', action: 'view.tasks' }],
+ keybindings: [{ command: 'tasks', key: 'Mod+Alt+T' }]
+ })
+ const registry = new PluginCommandRegistry()
+
+ registry.reconcile([first, second], (plugin) => plugin === first)
+
+ expect(registry.list()).toHaveLength(1)
+ expect(registry.error(first.pluginKey)).toBeNull()
+ })
+})
diff --git a/src/main/plugins/plugin-command-registry.ts b/src/main/plugins/plugin-command-registry.ts
new file mode 100644
index 000000000..3b3af499e
--- /dev/null
+++ b/src/main/plugins/plugin-command-registry.ts
@@ -0,0 +1,167 @@
+import {
+ pluginCommandKeybindingActionId,
+ type PluginCommandAliasActionId
+} from '../../shared/plugins/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'
+import {
+ isInvalidDiscoveredPlugin,
+ type DiscoveredPlugin,
+ type ValidDiscoveredPlugin
+} from './plugin-discovery'
+
+export type PluginCommandKeybinding = {
+ key: string
+ when: 'global' | 'worktree'
+}
+
+export type PluginCommandRegistration = {
+ pluginKey: string
+ id: string
+ title: string
+ context: 'global' | 'worktree'
+ handler: { type: 'built-in'; action: PluginCommandAliasActionId } | { type: 'worker' }
+ keybindings: PluginCommandKeybinding[]
+}
+
+type CommandOwner = {
+ pluginKey: string
+ context: PluginCommandKeybinding['when']
+ key: string
+}
+
+export class PluginCommandRegistry {
+ private active: PluginCommandRegistration[] = []
+ private readonly previews = new Map()
+ private readonly errors = new Map()
+
+ list(): readonly PluginCommandRegistration[] {
+ return this.active
+ }
+
+ preview(pluginKey: string): readonly PluginCommandRegistration[] {
+ return this.previews.get(pluginKey) ?? []
+ }
+
+ error(pluginKey: string): string | null {
+ return this.errors.get(pluginKey) ?? null
+ }
+
+ reconcile(
+ discovered: readonly DiscoveredPlugin[],
+ isApproved: (plugin: ValidDiscoveredPlugin) => boolean,
+ overrides: KeybindingOverrides = {},
+ platform: NodeJS.Platform = process.platform
+ ): void {
+ const candidates = discovered.filter(
+ (plugin): plugin is ValidDiscoveredPlugin =>
+ !isInvalidDiscoveredPlugin(plugin) && plugin.manifest.contributes.commands.length > 0
+ )
+ const registrations = candidates.map((plugin) => ({
+ pluginKey: plugin.pluginKey,
+ approved: isApproved(plugin),
+ commands: registrationsForManifest(plugin.pluginKey, plugin.manifest)
+ }))
+
+ this.previews.clear()
+ this.errors.clear()
+ for (const plugin of registrations) {
+ this.previews.set(plugin.pluginKey, plugin.commands)
+ }
+
+ const approved = registrations.filter((plugin) => plugin.approved)
+ const chordOwners = new Map()
+ for (const plugin of approved) {
+ for (const command of plugin.commands) {
+ for (const keybinding of effectiveCommandKeybindings(command, overrides)) {
+ const identity = getKeybindingConflictIdentity(keybinding.key, platform)
+ const owners = chordOwners.get(identity) ?? []
+ owners.push({
+ pluginKey: plugin.pluginKey,
+ context: keybinding.when,
+ key: keybinding.key
+ })
+ chordOwners.set(identity, owners)
+ }
+ }
+ }
+
+ const conflicted = new Set()
+ for (const owners of chordOwners.values()) {
+ for (let index = 0; index < owners.length; index += 1) {
+ for (let compared = index + 1; compared < owners.length; compared += 1) {
+ const first = owners[index]!
+ const second = owners[compared]!
+ if (!contextsOverlap(first.context, second.context)) {
+ continue
+ }
+ conflicted.add(first.pluginKey)
+ conflicted.add(second.pluginKey)
+ this.errors.set(
+ first.pluginKey,
+ `plugin keybinding ${first.key} conflicts with another plugin`
+ )
+ this.errors.set(
+ second.pluginKey,
+ `plugin keybinding ${second.key} conflicts with another plugin`
+ )
+ }
+ }
+ }
+
+ this.active = approved
+ .filter((plugin) => !conflicted.has(plugin.pluginKey))
+ .flatMap((plugin) => plugin.commands)
+ }
+}
+
+function effectiveCommandKeybindings(
+ command: PluginCommandRegistration,
+ overrides: KeybindingOverrides
+): PluginCommandKeybinding[] {
+ const override = overrides[pluginCommandKeybindingActionId(command.pluginKey, command.id)]
+ if (!Array.isArray(override)) {
+ return command.keybindings
+ }
+ return override.map((key) => ({ key, when: command.context }))
+}
+
+function registrationsForManifest(
+ pluginKey: string,
+ manifest: PluginManifest
+): PluginCommandRegistration[] {
+ return manifest.contributes.commands.map((command) => ({
+ pluginKey,
+ id: command.id,
+ title: command.title,
+ context: command.context ?? 'global',
+ handler:
+ command.action === undefined
+ ? { type: 'worker' as const }
+ : { type: 'built-in' as const, action: command.action as PluginCommandAliasActionId },
+ keybindings: keybindingsForCommand(command, manifest.contributes.keybindings)
+ }))
+}
+
+function keybindingsForCommand(
+ command: PluginCommandContribution,
+ keybindings: readonly PluginKeybindingContribution[]
+): PluginCommandKeybinding[] {
+ return keybindings
+ .filter((keybinding) => keybinding.command === command.id)
+ .map((keybinding) => ({
+ key: keybinding.key,
+ when: keybinding.when ?? command.context ?? 'global'
+ }))
+}
+
+function contextsOverlap(
+ first: PluginCommandKeybinding['when'],
+ second: PluginCommandKeybinding['when']
+): boolean {
+ return first === 'global' || second === 'global' || first === second
+}
diff --git a/src/main/plugins/plugin-content-hash.ts b/src/main/plugins/plugin-content-hash.ts
new file mode 100644
index 000000000..719c23fcb
--- /dev/null
+++ b/src/main/plugins/plugin-content-hash.ts
@@ -0,0 +1,129 @@
+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'
+
+/**
+ * Deterministic hash of a plugin's file tree. The hash names the immutable
+ * install directory (`/plugins/.//`), so two
+ * installs of identical content share a name and a mutated install is
+ * detectable. Hashes relative paths + file bytes in sorted order; symlinks
+ * are refused outright (installed trees must be self-contained).
+ */
+
+const MAX_PLUGIN_FILES = 2_000
+const MAX_PLUGIN_TOTAL_BYTES = 50 * 1024 * 1024
+
+type PluginFile = { path: string; size: number }
+
+export type PluginTreeHashResult =
+ | { ok: true; hash: string; fileCount: number; totalBytes: number }
+ | { ok: false; error: string }
+
+async function collectFiles(
+ root: string,
+ dir: string,
+ files: PluginFile[],
+ counters: { entries: number; bytes: number }
+): Promise {
+ const entries = await readdir(dir, { withFileTypes: true })
+ // Why: localeCompare ordering varies with host locale/ICU data; content
+ // addresses must sort identically on macOS, Linux, and Windows.
+ entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0))
+ for (const entry of entries) {
+ if (dir === root && entry.name === '.git') {
+ continue
+ }
+ const segmentError = pluginPathSegmentError(entry.name)
+ if (segmentError) {
+ return `unsafe plugin path segment "${entry.name}": ${segmentError}`
+ }
+ const full = join(dir, entry.name)
+ const stat = await lstat(full)
+ counters.entries += 1
+ if (counters.entries > MAX_PLUGIN_FILES) {
+ return `plugin exceeds the ${MAX_PLUGIN_FILES}-entry limit`
+ }
+ if (stat.isSymbolicLink()) {
+ return `symlink not allowed in plugin content: ${relative(root, full)}`
+ }
+ if (stat.isDirectory()) {
+ const error = await collectFiles(root, full, files, counters)
+ if (error) {
+ return error
+ }
+ } else if (stat.isFile()) {
+ counters.bytes += stat.size
+ if (counters.bytes > MAX_PLUGIN_TOTAL_BYTES) {
+ return `plugin exceeds the ${MAX_PLUGIN_TOTAL_BYTES}-byte limit`
+ }
+ files.push({ path: full, size: stat.size })
+ } else {
+ return `unsupported plugin entry type: ${relative(root, full)}`
+ }
+ }
+ return null
+}
+
+async function hashFileBounded(
+ hash: ReturnType,
+ file: PluginFile
+): Promise {
+ let bytesRead = 0
+ for await (const chunk of createReadStream(file.path)) {
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
+ bytesRead += bytes.byteLength
+ if (bytesRead > file.size || bytesRead > MAX_PLUGIN_TOTAL_BYTES) {
+ throw new Error(`plugin file changed while hashing: ${file.path}`)
+ }
+ hash.update(bytes)
+ }
+ if (bytesRead !== file.size) {
+ throw new Error(`plugin file changed while hashing: ${file.path}`)
+ }
+ return bytesRead
+}
+
+export async function hashPluginTree(root: string): Promise {
+ const files: PluginFile[] = []
+ try {
+ const counters = { entries: 0, bytes: 0 }
+ const error = await collectFiles(root, root, files, counters)
+ if (error) {
+ return { ok: false, error }
+ }
+ const hash = createHash('sha256')
+ // Why: every record is length-framed so path/content delimiters inside a
+ // plugin file cannot make two different trees share one hash preimage.
+ hash.update('orca-plugin-tree-v1\0')
+ let totalBytes = 0
+ for (const file of files) {
+ totalBytes += file.size
+ if (totalBytes > MAX_PLUGIN_TOTAL_BYTES) {
+ return { ok: false, error: `plugin exceeds the ${MAX_PLUGIN_TOTAL_BYTES}-byte limit` }
+ }
+ // Normalize separators so the same tree hashes identically on Windows.
+ const rel = relative(root, file.path).replaceAll('\\', '/')
+ hashLength(hash, Buffer.byteLength(rel, 'utf8'))
+ hash.update(rel, 'utf8')
+ hashLength(hash, file.size)
+ await hashFileBounded(hash, file)
+ }
+ // Hex (not base64) because the hash becomes a directory name.
+ return {
+ ok: true,
+ hash: hash.digest('hex'),
+ fileCount: files.length,
+ totalBytes
+ }
+ } catch (error) {
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
+ }
+}
+
+function hashLength(hash: ReturnType, length: number): void {
+ const framedLength = Buffer.allocUnsafe(8)
+ framedLength.writeBigUInt64BE(BigInt(length))
+ hash.update(framedLength)
+}
diff --git a/src/main/plugins/plugin-content-integrity.ts b/src/main/plugins/plugin-content-integrity.ts
new file mode 100644
index 000000000..19f68cae6
--- /dev/null
+++ b/src/main/plugins/plugin-content-integrity.ts
@@ -0,0 +1,58 @@
+import { hashPluginTree } from './plugin-content-hash'
+
+export type HashAddressedPluginContent = {
+ rootDir: string
+ contentHash: string | null
+}
+
+export type PluginContentIntegrityResult = { ok: true } | { ok: false; error: string }
+
+/** Dev trees are intentionally mutable; installed hash-addressed trees are not. */
+export async function verifyHashAddressedPluginContent(
+ plugin: HashAddressedPluginContent
+): Promise {
+ if (plugin.contentHash === null) {
+ return { ok: true }
+ }
+ const actual = await hashPluginTree(plugin.rootDir)
+ if (!actual.ok) {
+ return { ok: false, error: actual.error }
+ }
+ const matchesCurrentHash = actual.hash === plugin.contentHash
+ // Early P0 installs used a 128-bit SHA-256 prefix as the directory name.
+ // Honor that existing address while all new installs use the full digest.
+ const matchesLegacyPrefix =
+ plugin.contentHash.length === 32 && actual.hash.startsWith(plugin.contentHash)
+ if (!matchesCurrentHash && !matchesLegacyPrefix) {
+ return {
+ ok: false,
+ error: `content hash mismatch (expected ${plugin.contentHash}, got ${actual.hash})`
+ }
+ }
+ return { ok: true }
+}
+
+/** Deduplicates the first lazy verification for each discovered install. */
+export class PluginContentVerifier {
+ private readonly verifications = new Map>()
+
+ clear(): void {
+ this.verifications.clear()
+ }
+
+ async verify(plugin: HashAddressedPluginContent & { pluginKey: string }): Promise {
+ // Why: a refresh can replace one same-key install while its old hash is
+ // still being verified. Cache by immutable content identity, never key.
+ const identity = JSON.stringify([plugin.pluginKey, plugin.rootDir, plugin.contentHash])
+ let verification = this.verifications.get(identity)
+ if (!verification) {
+ verification = verifyHashAddressedPluginContent(plugin)
+ this.verifications.set(identity, verification)
+ }
+ const result = await verification
+ if (!result.ok) {
+ this.verifications.delete(identity)
+ throw new Error(`plugin ${plugin.pluginKey} failed integrity verification: ${result.error}`)
+ }
+ }
+}
diff --git a/src/main/plugins/plugin-content-pack-registry.test.ts b/src/main/plugins/plugin-content-pack-registry.test.ts
new file mode 100644
index 000000000..fe58cd3cc
--- /dev/null
+++ b/src/main/plugins/plugin-content-pack-registry.test.ts
@@ -0,0 +1,108 @@
+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 { PluginContentPackRegistry } from './plugin-content-pack-registry'
+import type { ValidDiscoveredPlugin } from './plugin-discovery'
+
+const roots: string[] = []
+
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+describe('PluginContentPackRegistry', () => {
+ it('activates all contributions from a plugin atomically', async () => {
+ const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-content-pack-registry-'))
+ roots.push(rootDir)
+ await mkdir(join(rootDir, 'locales'))
+ await Promise.all([
+ writeFile(
+ join(rootDir, 'locales', 'invalid.json'),
+ JSON.stringify({ settings: { title: 42 } })
+ ),
+ writeFile(join(rootDir, 'locales', 'valid.json'), JSON.stringify({ settings: 'Ajustes' }))
+ ])
+ const manifest = pluginManifestSchema.parse({
+ manifestVersion: 1,
+ id: 'mixed-content',
+ publisher: 'orca-samples',
+ name: 'Mixed Content',
+ version: '1.0.0',
+ engines: { orca: '>=1.0.0' },
+ pluginApi: 1,
+ contributes: {
+ languagePacks: [
+ { locale: 'es', path: 'locales/valid.json' },
+ { locale: 'pt-BR', path: 'locales/invalid.json' }
+ ]
+ },
+ capabilities: []
+ })
+ const plugin: ValidDiscoveredPlugin = {
+ pluginKey: 'orca-samples.mixed-content',
+ rootDir,
+ manifest,
+ consentFingerprint: fingerprintPluginConsent(manifest),
+ contentHash: null,
+ isDev: true
+ }
+ const registry = new PluginContentPackRegistry(new PluginContentVerifier())
+
+ await registry.reconcile([plugin], () => true)
+
+ expect(registry.error(plugin.pluginKey)).toContain('string or object')
+ expect(registry.languagePacks.list()).toEqual([])
+ })
+
+ it('rolls back valid packs when a VM recipe from the same plugin is invalid', async () => {
+ const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-content-pack-vm-'))
+ roots.push(rootDir)
+ await Promise.all([mkdir(join(rootDir, 'locales')), mkdir(join(rootDir, 'recipes'))])
+ await Promise.all([
+ writeFile(join(rootDir, 'locales', 'valid.json'), JSON.stringify({ settings: 'Ajustes' })),
+ writeFile(
+ join(rootDir, 'recipes', 'invalid.json'),
+ JSON.stringify({ schemaVersion: 1, id: 'bad', name: 'Bad', create: 'create', resume: 'up' })
+ )
+ ])
+ const manifest = pluginManifestSchema.parse({
+ manifestVersion: 1,
+ id: 'mixed-recipes',
+ publisher: 'orca-samples',
+ name: 'Mixed Recipes',
+ version: '1.0.0',
+ engines: { orca: '>=1.0.0' },
+ pluginApi: 1,
+ contributes: {
+ languagePacks: [{ locale: 'es', path: 'locales/valid.json' }],
+ vmRecipes: [{ path: 'recipes/invalid.json' }]
+ },
+ capabilities: []
+ })
+ const content = await hashPluginTree(rootDir)
+ if (!content.ok) {
+ throw new Error(content.error)
+ }
+ const plugin: ValidDiscoveredPlugin = {
+ pluginKey: 'orca-samples.mixed-recipes',
+ rootDir,
+ manifest,
+ consentFingerprint: fingerprintPluginConsent(manifest, content.hash),
+ consentContentHash: content.hash,
+ contentHash: null,
+ isDev: true
+ }
+ const registry = new PluginContentPackRegistry(new PluginContentVerifier())
+
+ await registry.reconcile([plugin], () => true)
+
+ expect(registry.error(plugin.pluginKey)).toContain('suspend and resume')
+ expect(registry.languagePacks.list()).toEqual([])
+ expect(registry.vmRecipes.list()).toEqual([])
+ })
+})
diff --git a/src/main/plugins/plugin-content-pack-registry.ts b/src/main/plugins/plugin-content-pack-registry.ts
new file mode 100644
index 000000000..c3901090c
--- /dev/null
+++ b/src/main/plugins/plugin-content-pack-registry.ts
@@ -0,0 +1,95 @@
+import type { PluginContentVerifier } from './plugin-content-integrity'
+import {
+ isInvalidDiscoveredPlugin,
+ type DiscoveredPlugin,
+ type ValidDiscoveredPlugin
+} from './plugin-discovery'
+import { PluginLanguagePackRegistry } from './plugin-language-pack-registry'
+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'
+
+export class PluginContentPackRegistry {
+ readonly languagePacks: PluginLanguagePackRegistry
+ readonly vmRecipes: PluginVmRecipeRegistry
+ readonly commands: PluginCommandRegistry
+ private readonly activationErrors = new Map()
+
+ constructor(contentVerifier: PluginContentVerifier) {
+ this.languagePacks = new PluginLanguagePackRegistry(contentVerifier)
+ this.vmRecipes = new PluginVmRecipeRegistry()
+ this.commands = new PluginCommandRegistry()
+ }
+
+ async reconcile(
+ discovered: readonly DiscoveredPlugin[],
+ isApproved: (plugin: ValidDiscoveredPlugin) => boolean,
+ keybindings: KeybindingOverrides = {}
+ ): Promise {
+ const approvedKeys = new Set(
+ discovered
+ .filter((plugin): plugin is ValidDiscoveredPlugin => !isInvalidDiscoveredPlugin(plugin))
+ .filter(isApproved)
+ .map((plugin) => plugin.pluginKey)
+ )
+ const excluded = new Set()
+ this.activationErrors.clear()
+
+ await Promise.all(
+ discovered.map(async (plugin) => {
+ if (
+ isInvalidDiscoveredPlugin(plugin) ||
+ !approvedKeys.has(plugin.pluginKey) ||
+ plugin.manifest.contributes.vmRecipes.length > 0
+ ) {
+ return
+ }
+ try {
+ await verifyInstructionalPluginContent(plugin)
+ } catch (error) {
+ excluded.add(plugin.pluginKey)
+ this.activationErrors.set(
+ plugin.pluginKey,
+ error instanceof Error ? error.message : String(error)
+ )
+ }
+ })
+ )
+
+ while (true) {
+ const approveAtomically = (plugin: ValidDiscoveredPlugin): boolean =>
+ approvedKeys.has(plugin.pluginKey) && !excluded.has(plugin.pluginKey)
+ await Promise.all([
+ this.languagePacks.reconcile(discovered, approveAtomically),
+ this.vmRecipes.reconcile(discovered, approveAtomically),
+ this.commands.reconcile(discovered, approveAtomically, keybindings)
+ ])
+
+ let foundNewError = false
+ for (const pluginKey of approvedKeys) {
+ const error = this.registryError(pluginKey)
+ if (error && !excluded.has(pluginKey)) {
+ excluded.add(pluginKey)
+ this.activationErrors.set(pluginKey, error)
+ foundNewError = true
+ }
+ }
+ if (!foundNewError) {
+ break
+ }
+ }
+ }
+
+ error(pluginKey: string): string | null {
+ return this.activationErrors.get(pluginKey) ?? this.registryError(pluginKey)
+ }
+
+ private registryError(pluginKey: string): string | null {
+ return (
+ this.languagePacks.error(pluginKey) ??
+ this.vmRecipes.error(pluginKey) ??
+ this.commands.error(pluginKey)
+ )
+ }
+}
diff --git a/src/main/plugins/plugin-content-safety.test.ts b/src/main/plugins/plugin-content-safety.test.ts
new file mode 100644
index 000000000..eedeb42ef
--- /dev/null
+++ b/src/main/plugins/plugin-content-safety.test.ts
@@ -0,0 +1,305 @@
+import { mkdtemp, mkdir, rm, symlink, truncate, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { 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 {
+ PLUGIN_PANEL_ENTRY_MAX_BYTES,
+ validateDeclaredPluginArtifacts,
+ validatePluginInstallContent
+} from './plugin-artifact-validation'
+import { hashPluginTree } from './plugin-content-hash'
+import { verifyHashAddressedPluginContent } from './plugin-content-integrity'
+import { PluginService } from './plugin-service'
+
+const roots: string[] = []
+
+async function tempRoot(): Promise {
+ const root = await mkdtemp(join(tmpdir(), 'orca-plugin-content-test-'))
+ roots.push(root)
+ return root
+}
+
+type ManifestOverrides = Omit, 'contributes'> & {
+ contributes?: Partial
+}
+
+function manifest(overrides: ManifestOverrides = {}): PluginManifest {
+ const { contributes, ...manifestOverrides } = overrides
+ return pluginManifestSchema.parse({
+ manifestVersion: 1,
+ id: 'demo',
+ publisher: 'orca-samples',
+ name: 'Demo',
+ version: '1.0.0',
+ engines: { orca: '>=1.0.0' },
+ pluginApi: 1,
+ capabilities: [],
+ ...manifestOverrides,
+ contributes
+ })
+}
+
+afterEach(async () => {
+ vi.restoreAllMocks()
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+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([
+ writeFile(join(root, 'locales', 'pt-BR.json'), '{}'),
+ writeFile(join(root, 'recipes', 'vm.json'), '{}')
+ ])
+ const pluginManifest = manifest({
+ contributes: {
+ languagePacks: [{ locale: 'pt-BR', path: 'locales/pt-BR.json' }],
+ vmRecipes: [{ path: 'recipes/vm.json' }],
+ agents: [{ path: 'agent.json' }]
+ }
+ })
+
+ await expect(validateDeclaredPluginArtifacts(root, pluginManifest)).resolves.toEqual({
+ ok: true
+ })
+ })
+
+ it('requires declared files to exist and be regular files', async () => {
+ const root = await tempRoot()
+ await mkdir(join(root, 'panel.html'))
+
+ const result = await validateDeclaredPluginArtifacts(
+ root,
+ manifest({
+ main: 'missing-worker.js',
+ contributes: {
+ panels: [{ id: 'panel', title: 'Panel', entry: 'panel.html' }],
+ commands: [],
+ events: []
+ }
+ })
+ )
+
+ expect(result).toMatchObject({ ok: false })
+ })
+
+ it('refuses a panel reached through an escaping directory link or junction', async () => {
+ const root = await tempRoot()
+ const outsideDir = await tempRoot()
+ const userDataPath = await tempRoot()
+ await writeFile(join(outsideDir, 'panel.html'), 'outside
')
+ await symlink(
+ outsideDir,
+ join(root, 'escape'),
+ process.platform === 'win32' ? 'junction' : 'dir'
+ )
+ const pluginManifest = manifest({
+ contributes: {
+ panels: [{ id: 'panel', title: 'Panel', entry: 'escape/panel.html' }],
+ commands: [],
+ events: []
+ }
+ })
+ await writeFile(join(root, 'orca-plugin.json'), JSON.stringify(pluginManifest))
+
+ await expect(validateDeclaredPluginArtifacts(root, pluginManifest)).resolves.toMatchObject({
+ ok: false
+ })
+
+ const pluginKey = `${pluginManifest.publisher}.${pluginManifest.id}`
+ const service = new PluginService({
+ userDataPath,
+ hostVersion: '1.4.0',
+ isPluginSystemEnabled: () => true,
+ getDisabledPlugins: () => [],
+ getPluginConsents: () => ({
+ [pluginKey]: fingerprintPluginConsent(pluginManifest)
+ }),
+ getDevPluginPaths: () => [root]
+ })
+ try {
+ await service.initialize()
+ await expect(service.panels.readEntry(pluginKey, 'panel')).resolves.toBeNull()
+ } finally {
+ await service.dispose()
+ }
+ })
+
+ it('rejects a panel artifact too large to mount safely in a renderer', async () => {
+ const root = await tempRoot()
+ const panelPath = join(root, 'panel.html')
+ await writeFile(panelPath, '')
+ await truncate(panelPath, PLUGIN_PANEL_ENTRY_MAX_BYTES + 1)
+ const pluginManifest = manifest({
+ contributes: {
+ panels: [{ id: 'panel', title: 'Panel', entry: 'panel.html' }],
+ commands: [],
+ events: []
+ }
+ })
+
+ await expect(validateDeclaredPluginArtifacts(root, pluginManifest)).resolves.toMatchObject({
+ ok: false,
+ error: expect.stringContaining('artifact limit')
+ })
+ })
+
+ it('parses VM recipes at the immutable install boundary', async () => {
+ const root = await tempRoot()
+ await mkdir(join(root, 'recipes'))
+ await writeFile(
+ join(root, 'recipes', 'invalid.json'),
+ JSON.stringify({
+ schemaVersion: 1,
+ id: 'cloud',
+ name: 'Cloud',
+ create: 'create',
+ suspend: 'suspend'
+ })
+ )
+ const pluginManifest = manifest({
+ contributes: { vmRecipes: [{ path: 'recipes/invalid.json' }] }
+ })
+
+ await expect(validateDeclaredPluginArtifacts(root, pluginManifest)).resolves.toEqual({
+ ok: true
+ })
+ await expect(validatePluginInstallContent(root, pluginManifest)).resolves.toMatchObject({
+ ok: false,
+ error: expect.stringContaining('suspend and resume')
+ })
+ })
+
+ it('rejects duplicate VM recipe ids at the immutable install boundary', async () => {
+ const root = await tempRoot()
+ await mkdir(join(root, 'recipes'))
+ const recipe = JSON.stringify({
+ schemaVersion: 1,
+ id: 'cloud',
+ name: 'Cloud',
+ create: 'create'
+ })
+ await Promise.all([
+ writeFile(join(root, 'recipes', 'one.json'), recipe),
+ writeFile(join(root, 'recipes', 'two.json'), recipe)
+ ])
+ const pluginManifest = manifest({
+ contributes: {
+ vmRecipes: [{ path: 'recipes/one.json' }, { path: 'recipes/two.json' }]
+ }
+ })
+
+ await expect(validatePluginInstallContent(root, pluginManifest)).resolves.toMatchObject({
+ ok: false,
+ error: expect.stringContaining('duplicate VM recipe id "cloud"')
+ })
+ })
+})
+
+describe('hash-addressed plugin content', () => {
+ it('uses unambiguous framing for paths and file contents', async () => {
+ const first = await tempRoot()
+ const second = await tempRoot()
+ await writeFile(join(first, 'a'), Buffer.from('x\0b\0y'))
+ await Promise.all([writeFile(join(second, 'a'), 'x'), writeFile(join(second, 'b'), 'y')])
+
+ const [firstHash, secondHash] = await Promise.all([
+ hashPluginTree(first),
+ hashPluginTree(second)
+ ])
+
+ expect(firstHash).toMatchObject({ ok: true })
+ expect(secondHash).toMatchObject({ ok: true })
+ if (firstHash.ok && secondHash.ok) {
+ expect(firstHash.hash).not.toBe(secondHash.hash)
+ }
+ })
+
+ it('hashes and bounds nested .git directories as plugin content', async () => {
+ const root = await tempRoot()
+ const nestedGit = join(root, 'vendor', '.git')
+ await mkdir(nestedGit, { recursive: true })
+ const entry = join(nestedGit, 'main.mjs')
+ await writeFile(entry, 'export default function activate() {}')
+ const initial = await hashPluginTree(root)
+ await writeFile(entry, 'export default function activate() { throw new Error("changed") }')
+ const changed = await hashPluginTree(root)
+
+ expect(initial).toMatchObject({ ok: true })
+ expect(changed).toMatchObject({ ok: true })
+ if (initial.ok && changed.ok) {
+ expect(changed.hash).not.toBe(initial.hash)
+ }
+
+ await truncate(entry, 50 * 1024 * 1024 + 1)
+ await expect(hashPluginTree(root)).resolves.toMatchObject({
+ ok: false,
+ error: expect.stringContaining('byte limit')
+ })
+ })
+
+ it('does not let host locale collation change a content address', async () => {
+ const root = await tempRoot()
+ await writeFile(join(root, 'alpha.txt'), 'a')
+ await writeFile(join(root, 'zulu.txt'), 'z')
+ const expected = await hashPluginTree(root)
+ vi.spyOn(String.prototype, 'localeCompare').mockImplementation(() => -1)
+
+ const withDifferentCollation = await hashPluginTree(root)
+
+ expect(withDifferentCollation).toEqual(expected)
+ })
+
+ it.skipIf(process.platform === 'win32')(
+ 'rejects Windows-reserved tree entries cross-platform',
+ async () => {
+ const root = await tempRoot()
+ await writeFile(join(root, 'CON.txt'), 'reserved')
+
+ await expect(hashPluginTree(root)).resolves.toMatchObject({ ok: false })
+ }
+ )
+
+ it('detects content changed after its address was computed', async () => {
+ const root = await tempRoot()
+ const entry = join(root, 'panel.html')
+ await writeFile(entry, 'original
')
+ const initial = await hashPluginTree(root)
+ expect(initial.ok).toBe(true)
+ if (!initial.ok) {
+ return
+ }
+ expect(initial.hash).toMatch(/^[0-9a-f]{64}$/)
+ await expect(
+ verifyHashAddressedPluginContent({
+ rootDir: root,
+ contentHash: initial.hash.slice(0, 32)
+ })
+ ).resolves.toEqual({ ok: true })
+
+ await writeFile(entry, 'tampered
')
+
+ await expect(
+ verifyHashAddressedPluginContent({ rootDir: root, contentHash: initial.hash })
+ ).resolves.toMatchObject({ ok: false })
+ })
+
+ it('rejects an oversized sparse file before reading it into memory', async () => {
+ const root = await tempRoot()
+ const oversized = join(root, 'oversized.bin')
+ await writeFile(oversized, '')
+ await truncate(oversized, 50 * 1024 * 1024 + 1)
+
+ await expect(hashPluginTree(root)).resolves.toMatchObject({
+ ok: false,
+ error: expect.stringContaining('byte limit')
+ })
+ })
+})
diff --git a/src/main/plugins/plugin-current-pointer.ts b/src/main/plugins/plugin-current-pointer.ts
new file mode 100644
index 000000000..c299bed98
--- /dev/null
+++ b/src/main/plugins/plugin-current-pointer.ts
@@ -0,0 +1,49 @@
+import { createReadStream } from 'node:fs'
+import { rm } from 'node:fs/promises'
+import { join } from 'node:path'
+import { writePluginFileAtomically } from './plugin-atomic-file-write'
+
+export const PLUGIN_CURRENT_POINTER_FILENAME = 'current'
+export const PLUGIN_CURRENT_POINTER_MAX_BYTES = 128
+
+/** Reads the tiny hash pointer through a cap so discovery cannot allocate a
+ * corrupt sparse file during startup. Missing pointers resolve to null. */
+export async function readPluginCurrentPointer(pluginDir: string): Promise {
+ const target = join(pluginDir, PLUGIN_CURRENT_POINTER_FILENAME)
+ const chunks: Buffer[] = []
+ let totalBytes = 0
+ try {
+ for await (const chunk of createReadStream(target)) {
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
+ totalBytes += bytes.byteLength
+ if (totalBytes > PLUGIN_CURRENT_POINTER_MAX_BYTES) {
+ throw new Error('current-version pointer exceeds its size limit')
+ }
+ chunks.push(bytes)
+ }
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
+ return null
+ }
+ throw error
+ }
+ return Buffer.concat(chunks, totalBytes).toString('utf8').trim()
+}
+
+export async function writePluginCurrentPointer(
+ pluginDir: string,
+ contentHash: string
+): Promise {
+ await writePluginFileAtomically(join(pluginDir, PLUGIN_CURRENT_POINTER_FILENAME), contentHash)
+}
+
+export async function restorePluginCurrentPointer(
+ pluginDir: string,
+ previousContentHash: string | null
+): Promise {
+ if (previousContentHash === null) {
+ await rm(join(pluginDir, PLUGIN_CURRENT_POINTER_FILENAME), { force: true })
+ return
+ }
+ await writePluginCurrentPointer(pluginDir, previousContentHash)
+}
diff --git a/src/main/plugins/plugin-dev-watcher.test.ts b/src/main/plugins/plugin-dev-watcher.test.ts
new file mode 100644
index 000000000..77996fe3b
--- /dev/null
+++ b/src/main/plugins/plugin-dev-watcher.test.ts
@@ -0,0 +1,64 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { PluginDevWatcher } from './plugin-dev-watcher'
+
+afterEach(() => {
+ vi.useRealTimers()
+})
+
+describe('PluginDevWatcher', () => {
+ it('contains asynchronous watcher errors and requests a retrying refresh', async () => {
+ vi.useFakeTimers()
+ let onEvent!: (error: Error | null) => void
+ const unsubscribe = vi.fn().mockResolvedValue(undefined)
+ const subscribePath = vi.fn(async (_path, callback: typeof onEvent) => {
+ onEvent = callback
+ return { unsubscribe }
+ })
+ const devWatcher = new PluginDevWatcher(subscribePath)
+ const refresh = vi.fn()
+ const onWatcherError = vi.fn()
+ devWatcher.start(['/plugins/demo'], refresh, onWatcherError)
+ await vi.waitFor(() => expect(subscribePath).toHaveBeenCalledOnce())
+
+ expect(() => onEvent(new Error('watch failed'))).not.toThrow()
+ await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledOnce())
+ expect(onWatcherError).toHaveBeenCalledOnce()
+ vi.advanceTimersByTime(300)
+ expect(refresh).toHaveBeenCalledOnce()
+
+ devWatcher.dispose()
+ })
+
+ it('unsubscribes a subscription that resolves after disposal', async () => {
+ let resolveSubscription!: (value: { unsubscribe: () => Promise }) => void
+ const unsubscribe = vi.fn().mockResolvedValue(undefined)
+ const subscribePath = vi.fn(
+ () =>
+ new Promise<{ unsubscribe: () => Promise }>((resolve) => {
+ resolveSubscription = resolve
+ })
+ )
+ const devWatcher = new PluginDevWatcher(subscribePath)
+
+ devWatcher.start(['/plugins/demo'], vi.fn())
+ devWatcher.dispose()
+ resolveSubscription({ unsubscribe })
+
+ await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledOnce())
+ })
+
+ it('does not spin refreshes when a missing path cannot be subscribed', async () => {
+ vi.useFakeTimers()
+ const subscribePath = vi.fn().mockRejectedValue(new Error('missing path'))
+ const refresh = vi.fn()
+ const onWatcherError = vi.fn()
+ const devWatcher = new PluginDevWatcher(subscribePath)
+
+ devWatcher.start(['/plugins/missing'], refresh, onWatcherError)
+ await vi.waitFor(() => expect(onWatcherError).toHaveBeenCalledOnce())
+ vi.advanceTimersByTime(10_000)
+
+ expect(refresh).not.toHaveBeenCalled()
+ devWatcher.dispose()
+ })
+})
diff --git a/src/main/plugins/plugin-dev-watcher.ts b/src/main/plugins/plugin-dev-watcher.ts
new file mode 100644
index 000000000..ea77def41
--- /dev/null
+++ b/src/main/plugins/plugin-dev-watcher.ts
@@ -0,0 +1,108 @@
+import {
+ subscribeViaWatcherProcess,
+ type WatcherProcessSubscription
+} from '../ipc/parcel-watcher-process'
+
+type SubscribePluginPath = (
+ path: string,
+ onEvent: (error: Error | null) => void,
+ onInterruption: () => void
+) => Promise
+
+const subscribePluginPath: SubscribePluginPath = (path, onEvent, onInterruption) =>
+ subscribeViaWatcherProcess(
+ path,
+ (error) => onEvent(error),
+ {},
+ {
+ onInterruption,
+ onTerminalError: onEvent
+ }
+ )
+
+/** Owns debounced manifest/panel refresh watchers for mutable dev plugins. */
+export class PluginDevWatcher {
+ private readonly subscriptions: WatcherProcessSubscription[] = []
+ private refreshTimer: ReturnType | null = null
+ private generation = 0
+
+ constructor(private readonly subscribePath: SubscribePluginPath = subscribePluginPath) {}
+
+ start(devPaths: readonly string[], refresh: () => void, onWatcherError?: () => void): void {
+ const generation = ++this.generation
+ for (const devPath of devPaths) {
+ let subscription: WatcherProcessSubscription | null = null
+ let failedBeforeReady = false
+ const fail = (): void => {
+ if (generation !== this.generation) {
+ return
+ }
+ failedBeforeReady = true
+ if (subscription) {
+ this.removeSubscription(subscription)
+ void subscription.unsubscribe()
+ }
+ onWatcherError?.()
+ this.scheduleRefresh(refresh)
+ }
+ void this.subscribePath(
+ devPath,
+ (error) => {
+ if (error) {
+ fail()
+ } else if (generation === this.generation) {
+ this.scheduleRefresh(refresh)
+ }
+ },
+ () => {
+ if (generation === this.generation) {
+ // The watcher process recovered, but changes during the gap were
+ // lost, so refresh the complete plugin projection once.
+ this.scheduleRefresh(refresh)
+ }
+ }
+ )
+ .then((created) => {
+ subscription = created
+ if (generation !== this.generation || failedBeforeReady) {
+ void created.unsubscribe()
+ return
+ }
+ this.subscriptions.push(created)
+ })
+ .catch(() => {
+ if (generation === this.generation) {
+ onWatcherError?.()
+ }
+ })
+ }
+ }
+
+ dispose(): void {
+ this.generation += 1
+ if (this.refreshTimer) {
+ clearTimeout(this.refreshTimer)
+ this.refreshTimer = null
+ }
+ for (const subscription of this.subscriptions.splice(0)) {
+ void subscription.unsubscribe()
+ }
+ }
+
+ private removeSubscription(subscription: WatcherProcessSubscription): void {
+ const index = this.subscriptions.indexOf(subscription)
+ if (index >= 0) {
+ this.subscriptions.splice(index, 1)
+ }
+ }
+
+ private scheduleRefresh(refresh: () => void): void {
+ if (this.refreshTimer) {
+ clearTimeout(this.refreshTimer)
+ }
+ this.refreshTimer = setTimeout(() => {
+ this.refreshTimer = null
+ refresh()
+ }, 300)
+ }
+}
diff --git a/src/main/plugins/plugin-discovery.test.ts b/src/main/plugins/plugin-discovery.test.ts
new file mode 100644
index 000000000..685a622e9
--- /dev/null
+++ b/src/main/plugins/plugin-discovery.test.ts
@@ -0,0 +1,129 @@
+import { mkdir, mkdtemp, rm, truncate, writeFile } from 'node:fs/promises'
+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'
+
+const roots: string[] = []
+
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+async function tempPluginsDir(): Promise {
+ const root = await mkdtemp(join(tmpdir(), 'orca-plugin-discovery-'))
+ roots.push(root)
+ return root
+}
+
+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 hash = 'a'.repeat(64)
+ const versionDir = join(pluginsDir, installedKey, hash)
+ await mkdir(versionDir, { recursive: true })
+ await writeFile(join(pluginsDir, installedKey, 'current'), hash)
+ await writeFile(
+ join(versionDir, 'orca-plugin.json'),
+ JSON.stringify({
+ manifestVersion: 1,
+ id: 'different',
+ publisher: 'orca-samples',
+ name: 'Different',
+ version: '1.0.0',
+ engines: { orca: '>=1.0.0' },
+ pluginApi: 1,
+ contributes: { panels: [], commands: [], events: [] },
+ capabilities: []
+ })
+ )
+
+ const [plugin] = await discoverPlugins({ pluginsDir, devPluginPaths: [], hostVersion: '1.4.0' })
+
+ expect(plugin && isInvalidDiscoveredPlugin(plugin)).toBe(true)
+ expect(plugin?.pluginKey).toBe(installedKey)
+ expect(plugin && 'error' in plugin ? plugin.error : '').toContain('does not match')
+ })
+
+ it('keeps a removable qualified identity when the current pointer is missing', async () => {
+ const pluginsDir = await tempPluginsDir()
+ const installedKey = 'orca-samples.broken'
+ await mkdir(join(pluginsDir, installedKey), { recursive: true })
+
+ const [plugin] = await discoverPlugins({ pluginsDir, devPluginPaths: [], hostVersion: '1.4.0' })
+
+ expect(plugin && isInvalidDiscoveredPlugin(plugin)).toBe(true)
+ expect(plugin?.pluginKey).toBe(installedKey)
+ })
+
+ it('rejects an oversized current pointer without an unbounded startup read', async () => {
+ const pluginsDir = await tempPluginsDir()
+ const installedKey = 'orca-samples.broken'
+ const pluginDir = join(pluginsDir, installedKey)
+ await mkdir(pluginDir, { recursive: true })
+ const pointer = join(pluginDir, 'current')
+ await writeFile(pointer, '')
+ await truncate(pointer, PLUGIN_CURRENT_POINTER_MAX_BYTES + 1)
+
+ const [plugin] = await discoverPlugins({ pluginsDir, devPluginPaths: [], hostVersion: '1.4.0' })
+
+ expect(plugin && isInvalidDiscoveredPlugin(plugin)).toBe(true)
+ expect(plugin?.pluginKey).toBe(installedKey)
+ })
+})
+
+describe('instructional plugin discovery identity', () => {
+ it('changes dev consent when a VM recipe command changes', async () => {
+ const pluginsDir = await tempPluginsDir()
+ const devRoot = await tempPluginsDir()
+ await mkdir(join(devRoot, 'recipes'))
+ await writeFile(
+ join(devRoot, 'orca-plugin.json'),
+ JSON.stringify({
+ manifestVersion: 1,
+ id: 'recipes',
+ publisher: 'orca-samples',
+ name: 'Recipes',
+ version: '1.0.0',
+ engines: { orca: '>=1.0.0' },
+ pluginApi: 1,
+ contributes: { vmRecipes: [{ path: 'recipes/cloud.json' }] },
+ capabilities: []
+ })
+ )
+ const recipePath = join(devRoot, 'recipes', 'cloud.json')
+ await writeFile(
+ recipePath,
+ JSON.stringify({ schemaVersion: 1, id: 'cloud', name: 'Cloud', create: 'create-v1' })
+ )
+ const [first] = await discoverPlugins({
+ pluginsDir,
+ devPluginPaths: [devRoot],
+ hostVersion: '1.4.0'
+ })
+ await writeFile(
+ recipePath,
+ JSON.stringify({ schemaVersion: 1, id: 'cloud', name: 'Cloud', create: 'create-v2' })
+ )
+ const [second] = await discoverPlugins({
+ pluginsDir,
+ devPluginPaths: [devRoot],
+ hostVersion: '1.4.0'
+ })
+
+ expect(first && !isInvalidDiscoveredPlugin(first)).toBe(true)
+ expect(second && !isInvalidDiscoveredPlugin(second)).toBe(true)
+ if (
+ !first ||
+ !second ||
+ isInvalidDiscoveredPlugin(first) ||
+ isInvalidDiscoveredPlugin(second)
+ ) {
+ return
+ }
+ expect(second.consentContentHash).not.toBe(first.consentContentHash)
+ expect(second.consentFingerprint).not.toBe(first.consentFingerprint)
+ })
+})
diff --git a/src/main/plugins/plugin-discovery.ts b/src/main/plugins/plugin-discovery.ts
new file mode 100644
index 000000000..bffd31ef5
--- /dev/null
+++ b/src/main/plugins/plugin-discovery.ts
@@ -0,0 +1,254 @@
+import { readdir } 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_MANIFEST_FILENAME,
+ isQualifiedPluginKey,
+ parsePluginManifest,
+ qualifiedPluginKey,
+ satisfiesOrcaEngineRange,
+ type PluginManifest
+} from '../../shared/plugins/plugin-manifest'
+import {
+ fingerprintPluginConsent,
+ hasInstructionalPluginContributions
+} from '../../shared/plugins/plugin-consent-fingerprint'
+import { validateDeclaredPluginArtifacts } from './plugin-artifact-validation'
+import { readPluginManifestText } from './plugin-manifest-file'
+import { readPluginCurrentPointer } from './plugin-current-pointer'
+import { hashPluginTree } from './plugin-content-hash'
+
+export { PLUGIN_CURRENT_POINTER_FILENAME } from './plugin-current-pointer'
+
+const INSTALLED_PLUGIN_DISCOVERY_CONCURRENCY = 8
+
+/**
+ * Discovery over the hash-addressed install layout:
+ *
+ * /plugins/./current ← text file naming the hash
+ * /plugins/.// ← immutable install tree
+ *
+ * plus dev-mode plugins loaded straight from arbitrary local directories.
+ * Discovery reads manifests and checks only their declared artifact paths —
+ * never plugin bytes or whole trees. Full content hashing stays lazy so
+ * startup cost is bounded by installed plugins plus declared entries.
+ */
+
+export type ValidDiscoveredPlugin = {
+ /** Qualified `.` key. */
+ pluginKey: string
+ rootDir: string
+ manifest: PluginManifest
+ /** Fingerprint of the capabilities and trusted-worker execution tier. */
+ consentFingerprint: string
+ /** Immutable tree identity included in consent for instructional packs. */
+ consentContentHash?: string | null
+ /** Content hash the install dir is named by; null for dev plugins. */
+ contentHash: string | null
+ isDev: boolean
+}
+
+export type InvalidDiscoveredPlugin = {
+ pluginKey?: string
+ rootDir: string
+ error: string
+ isDev: boolean
+}
+
+export type DiscoveredPlugin = ValidDiscoveredPlugin | InvalidDiscoveredPlugin
+
+export function isInvalidDiscoveredPlugin(
+ plugin: DiscoveredPlugin
+): plugin is InvalidDiscoveredPlugin {
+ return 'error' in plugin
+}
+
+export function getUserPluginsDir(userDataPath: string): string {
+ return join(userDataPath, 'plugins')
+}
+
+export function getPluginsDataDir(userDataPath: string): string {
+ return join(userDataPath, 'plugins-data')
+}
+
+async function readManifestDir(
+ rootDir: string,
+ hostVersion: string,
+ isDev: boolean,
+ installedContentHash?: string
+): Promise {
+ let rawText: string
+ try {
+ rawText = await readPluginManifestText(rootDir)
+ } catch (error) {
+ return {
+ rootDir,
+ error:
+ error instanceof Error && error.message.includes('exceeds')
+ ? error.message
+ : `missing ${PLUGIN_MANIFEST_FILENAME}`,
+ isDev
+ }
+ }
+ let raw: unknown
+ try {
+ raw = JSON.parse(rawText)
+ } catch (error) {
+ return {
+ rootDir,
+ error: `invalid JSON in ${PLUGIN_MANIFEST_FILENAME}: ${error instanceof Error ? error.message : String(error)}`,
+ isDev
+ }
+ }
+ const parsed = parsePluginManifest(raw)
+ if (!parsed.ok) {
+ return { rootDir, error: `invalid manifest: ${parsed.error}`, isDev }
+ }
+ const manifest = parsed.manifest
+ const pluginKey = qualifiedPluginKey(manifest)
+ if (!satisfiesOrcaEngineRange(hostVersion, manifest.engines.orca)) {
+ return {
+ pluginKey,
+ rootDir,
+ error: `requires Orca ${manifest.engines.orca} (this is ${hostVersion})`,
+ isDev
+ }
+ }
+ const artifacts = await validateDeclaredPluginArtifacts(rootDir, manifest)
+ if (!artifacts.ok) {
+ return {
+ pluginKey,
+ rootDir,
+ error: `invalid declared artifact: ${artifacts.error}`,
+ isDev
+ }
+ }
+ let consentContentIdentity: string | undefined
+ if (installedContentHash && hasInstructionalPluginContributions(manifest)) {
+ consentContentIdentity = installedContentHash
+ }
+ if (isDev && hasInstructionalPluginContributions(manifest)) {
+ const treeHash = await hashPluginTree(rootDir)
+ if (!treeHash.ok) {
+ return { pluginKey, rootDir, error: treeHash.error, isDev }
+ }
+ consentContentIdentity = treeHash.hash
+ }
+ return {
+ pluginKey,
+ rootDir,
+ manifest,
+ consentFingerprint: fingerprintPluginConsent(manifest, consentContentIdentity),
+ consentContentHash: consentContentIdentity ?? null,
+ contentHash: null,
+ isDev
+ }
+}
+
+async function readInstalledPlugin(
+ pluginDir: string,
+ dirName: string,
+ hostVersion: string
+): Promise {
+ let contentHash: string
+ try {
+ contentHash = (await readPluginCurrentPointer(pluginDir)) ?? ''
+ } catch {
+ return {
+ pluginKey: dirName,
+ rootDir: pluginDir,
+ error: 'missing current-version pointer',
+ isDev: false
+ }
+ }
+ // The pointer names a sibling directory; refuse anything path-like so a
+ // corrupted pointer cannot address content outside the plugin dir.
+ if (!PLUGIN_CONTENT_HASH_PATTERN.test(contentHash)) {
+ return {
+ pluginKey: dirName,
+ rootDir: pluginDir,
+ error: 'corrupt current-version pointer',
+ isDev: false
+ }
+ }
+ const versionDir = join(pluginDir, contentHash)
+ const discovered = await readManifestDir(versionDir, hostVersion, false, contentHash)
+ if (isInvalidDiscoveredPlugin(discovered)) {
+ return { ...discovered, pluginKey: dirName }
+ }
+ // Why: the directory name is the install key (and the uninstall target); a
+ // mismatched manifest identity would let two dirs claim the same plugin.
+ if (discovered.pluginKey !== dirName) {
+ return {
+ pluginKey: dirName,
+ rootDir: versionDir,
+ error: `manifest identity "${discovered.pluginKey}" does not match install directory "${dirName}"`,
+ isDev: false
+ }
+ }
+ return { ...discovered, contentHash }
+}
+
+async function readInstalledPlugins(
+ pluginsDir: string,
+ entries: readonly Dirent[],
+ hostVersion: string
+): Promise {
+ const results = Array.from({ length: entries.length }) as DiscoveredPlugin[]
+ let nextIndex = 0
+ const readers = Array.from(
+ { length: Math.min(INSTALLED_PLUGIN_DISCOVERY_CONCURRENCY, entries.length) },
+ async () => {
+ while (nextIndex < entries.length) {
+ const index = nextIndex++
+ const entry = entries[index]!
+ results[index] = await readInstalledPlugin(
+ join(pluginsDir, entry.name),
+ entry.name,
+ hostVersion
+ )
+ }
+ }
+ )
+ await Promise.all(readers)
+ return results
+}
+
+export async function discoverPlugins(options: {
+ pluginsDir: string
+ devPluginPaths: readonly string[]
+ hostVersion: string
+}): Promise {
+ const discovered: DiscoveredPlugin[] = []
+ let entries: Dirent[] = []
+ try {
+ entries = await readdir(options.pluginsDir, { withFileTypes: true })
+ } catch {
+ // A missing plugins dir just means no plugins are installed yet.
+ }
+ const installedEntries = entries.filter(
+ (entry) => entry.isDirectory() && isQualifiedPluginKey(entry.name)
+ )
+ // Installed manifests are independent immutable trees. Read them in
+ // a bounded pool so startup latency stays low without exhausting handles.
+ discovered.push(
+ ...(await readInstalledPlugins(options.pluginsDir, installedEntries, options.hostVersion))
+ )
+ for (const devPath of options.devPluginPaths) {
+ const plugin = await readManifestDir(devPath, options.hostVersion, true)
+ // A dev path that duplicates an installed plugin's identity wins — that
+ // is the point of dev mode — but two dev paths must not collide.
+ if (!isInvalidDiscoveredPlugin(plugin)) {
+ const collision = discovered.find(
+ (existing) =>
+ !isInvalidDiscoveredPlugin(existing) && existing.pluginKey === plugin.pluginKey
+ )
+ if (collision) {
+ discovered.splice(discovered.indexOf(collision), 1)
+ }
+ }
+ discovered.push(plugin)
+ }
+ return discovered
+}
diff --git a/src/main/plugins/plugin-enablement.test.ts b/src/main/plugins/plugin-enablement.test.ts
new file mode 100644
index 000000000..0ce3013d8
--- /dev/null
+++ b/src/main/plugins/plugin-enablement.test.ts
@@ -0,0 +1,132 @@
+import { tmpdir } from 'node:os'
+import { describe, expect, it, vi } from 'vitest'
+import { getDefaultSettings } from '../../shared/constants'
+import type { GlobalSettings } from '../../shared/types'
+import { pluginManifestSchema } from '../../shared/plugins/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 manifest = pluginManifestSchema.parse({
+ manifestVersion: 1,
+ id: 'demo',
+ publisher: 'orca-samples',
+ name: 'Demo',
+ version: '1.0.0',
+ engines: { orca: '>=1.0.0' },
+ pluginApi: 1,
+ contributes: {},
+ capabilities: []
+})
+
+function createStore(): {
+ store: Store
+ getSettings: () => GlobalSettings
+ updateSettings: ReturnType
+} {
+ let settings = getDefaultSettings(tmpdir())
+ const updateSettings = vi.fn((updates: Partial) => {
+ settings = { ...settings, ...updates }
+ })
+ return {
+ store: { getSettings: () => settings, updateSettings } as unknown as Store,
+ getSettings: () => settings,
+ updateSettings
+ }
+}
+
+function createPluginService(
+ getFingerprint: () => string,
+ overrides: Partial = {}
+): PluginService {
+ return {
+ findValidPlugin: (requestedKey: string) =>
+ requestedKey === pluginKey
+ ? {
+ pluginKey,
+ rootDir: tmpdir(),
+ manifest,
+ consentFingerprint: getFingerprint(),
+ consentContentHash: null,
+ contentHash: null,
+ isDev: true,
+ ...overrides
+ }
+ : null,
+ reconcileActivationState: vi.fn().mockResolvedValue(undefined)
+ } as unknown as PluginService
+}
+
+describe('applyPluginConsent', () => {
+ it('stores approval only for the fingerprint the user reviewed', async () => {
+ const harness = createStore()
+ const pluginService = createPluginService(() => 'sha256-reviewed')
+
+ await applyPluginConsent({
+ store: harness.store,
+ pluginService,
+ pluginKey,
+ reviewedFingerprint: 'sha256-reviewed',
+ decision: 'approve'
+ })
+
+ expect(harness.getSettings().pluginConsents[pluginKey]).toBe('sha256-reviewed')
+ expect(harness.getSettings().disabledPlugins).not.toContain(pluginKey)
+ })
+
+ it('rejects a stale review after a same-key plugin update without writing settings', async () => {
+ const harness = createStore()
+ let currentFingerprint = 'sha256-reviewed-v1'
+ const pluginService = createPluginService(() => currentFingerprint)
+ currentFingerprint = 'sha256-current-v2'
+
+ await expect(
+ applyPluginConsent({
+ store: harness.store,
+ pluginService,
+ pluginKey,
+ reviewedFingerprint: 'sha256-reviewed-v1',
+ decision: 'approve'
+ })
+ ).rejects.toThrow('changed since its permissions were reviewed')
+
+ expect(harness.updateSettings).not.toHaveBeenCalled()
+ expect(harness.getSettings().pluginConsents[pluginKey]).toBeUndefined()
+ })
+
+ it('allows a stale dialog to keep the newer plugin disabled', async () => {
+ const harness = createStore()
+ const pluginService = createPluginService(() => 'sha256-current-v2')
+
+ await applyPluginConsent({
+ store: harness.store,
+ pluginService,
+ pluginKey,
+ reviewedFingerprint: 'sha256-reviewed-v1',
+ decision: 'keep-disabled'
+ })
+
+ expect(harness.getSettings().disabledPlugins).toContain(pluginKey)
+ expect(pluginService.reconcileActivationState).toHaveBeenCalledOnce()
+ })
+})
+
+describe('applyPluginEnablement', () => {
+ it('does not persist unknown plugin identities', async () => {
+ const harness = createStore()
+ const pluginService = createPluginService(() => 'sha256-current')
+
+ await expect(
+ applyPluginEnablement({
+ store: harness.store,
+ pluginService,
+ pluginKey: 'orca-samples.unknown',
+ enabled: false
+ })
+ ).rejects.toThrow('unknown plugin')
+
+ expect(harness.updateSettings).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/main/plugins/plugin-enablement.ts b/src/main/plugins/plugin-enablement.ts
new file mode 100644
index 000000000..44349ef08
--- /dev/null
+++ b/src/main/plugins/plugin-enablement.ts
@@ -0,0 +1,87 @@
+import {
+ normalizePluginConsents,
+ normalizePluginIdList
+} from '../../shared/plugins/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'
+
+/**
+ * Single write path for consent + enablement. Consent is recorded as
+ * (qualified key → consent fingerprint) — never a bare id — so a capability
+ * expansion or addition of trusted Node code requires re-consent. The desktop
+ * IPC handlers and the headless RPC methods both route through here.
+ */
+
+export type PluginConsentDecision = PluginConsentRequest['decision']
+
+/** Records the user's consent-dialog answer. Approving stores the CURRENT
+ * consent fingerprint and clears any disable; declining disables so the plugin
+ * never re-prompts on later launches. */
+export async function applyPluginConsent(input: {
+ store: Store
+ pluginService: PluginService
+ pluginKey: PluginConsentRequest['pluginKey']
+ reviewedFingerprint: PluginConsentRequest['reviewedFingerprint']
+ decision: PluginConsentRequest['decision']
+ originWebContentsId?: number
+}): Promise {
+ const { store, pluginService, pluginKey } = input
+ const plugin = pluginService.findValidPlugin(pluginKey)
+ if (!plugin) {
+ throw new Error(`cannot record consent for unknown plugin ${pluginKey}`)
+ }
+ // Why: a same-key install can change while the dialog is open; never apply a
+ // decision to capabilities or a worker trust tier the user did not review.
+ if (input.decision === 'approve' && plugin.consentFingerprint !== input.reviewedFingerprint) {
+ throw new Error(`plugin ${pluginKey} changed since its permissions were reviewed`)
+ }
+ if (input.decision === 'approve') {
+ // Why: IPC and serve callers can bypass the renderer dialog, so main must
+ // prove every instructional byte is still reviewable before enabling it.
+ await verifyInstructionalPluginContent(plugin)
+ }
+ const settings = store.getSettings()
+ const disabled = new Set(normalizePluginIdList(settings.disabledPlugins))
+ const consents = normalizePluginConsents(settings.pluginConsents)
+ if (input.decision === 'approve') {
+ consents[pluginKey] = plugin.consentFingerprint
+ disabled.delete(pluginKey)
+ } else {
+ disabled.add(pluginKey)
+ }
+ store.updateSettings(
+ { disabledPlugins: [...disabled], pluginConsents: consents },
+ { notifyListeners: true, originWebContentsId: input.originWebContentsId }
+ )
+ await pluginService.reconcileActivationState()
+}
+
+/** Enables/disables an already-consented plugin. Enabling never bypasses
+ * consent: with missing or stale consent the plugin stays pending and the
+ * caller must run the consent flow instead. */
+export async function applyPluginEnablement(input: {
+ store: Store
+ pluginService: PluginService
+ pluginKey: string
+ enabled: boolean
+ originWebContentsId?: number
+}): Promise {
+ const { store, pluginService, pluginKey, enabled } = input
+ if (!pluginService.findValidPlugin(pluginKey)) {
+ throw new Error(`cannot change enablement for unknown plugin ${pluginKey}`)
+ }
+ const settings = store.getSettings()
+ const disabled = new Set(normalizePluginIdList(settings.disabledPlugins))
+ if (enabled) {
+ disabled.delete(pluginKey)
+ } else {
+ disabled.add(pluginKey)
+ }
+ store.updateSettings(
+ { disabledPlugins: [...disabled] },
+ { notifyListeners: true, originWebContentsId: input.originWebContentsId }
+ )
+ await pluginService.reconcileActivationState()
+}
diff --git a/src/main/plugins/plugin-event-bus.ts b/src/main/plugins/plugin-event-bus.ts
new file mode 100644
index 000000000..cee9e7825
--- /dev/null
+++ b/src/main/plugins/plugin-event-bus.ts
@@ -0,0 +1,42 @@
+import { PLUGIN_EVENT_PAYLOAD_SCHEMAS } from '../../shared/plugins/plugin-events'
+import type { PluginEventName } from '../../shared/plugins/plugin-manifest'
+
+/**
+ * Server-side event filtering: plugins receive only events they subscribed
+ * to (manifest `contributes.events` or a runtime `events.subscribe` call) —
+ * never a firehose. Manifest subscriptions are durable activation triggers;
+ * dynamic subscriptions live only as long as the worker that made them.
+ */
+
+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
+ }
+
+ /** Dynamic subscriptions die with the worker that registered them. */
+ clear(pluginKey: string): void {
+ this.dynamicSubscriptions.delete(pluginKey)
+ }
+
+ /** Validates and bounds an event payload before it reaches any plugin. */
+ 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
new file mode 100644
index 000000000..15aac71b3
--- /dev/null
+++ b/src/main/plugins/plugin-event-delivery.ts
@@ -0,0 +1,48 @@
+import type { PluginEventName } from '../../shared/plugins/plugin-manifest'
+import {
+ isInvalidDiscoveredPlugin,
+ type DiscoveredPlugin,
+ type ValidDiscoveredPlugin
+} from './plugin-discovery'
+import type { PluginEventBus } from './plugin-event-bus'
+import type { PluginWorkerController } from './plugin-worker-controller'
+
+export function deliverPluginEvent(options: {
+ event: PluginEventName
+ payload: unknown
+ plugins: readonly DiscoveredPlugin[]
+ eventBus: PluginEventBus
+ workerController: PluginWorkerController
+ isRuntimeApproved: (plugin: ValidDiscoveredPlugin) => boolean
+ logWarning: (pluginKey: string, line: string) => void
+}): void {
+ const projected = options.eventBus.projectPayload(options.event, options.payload)
+ if (!projected.ok) {
+ return
+ }
+ for (const plugin of options.plugins) {
+ if (isInvalidDiscoveredPlugin(plugin) || !options.isRuntimeApproved(plugin)) {
+ continue
+ }
+ const manifestSubscribed = plugin.manifest.contributes.events.some(
+ (subscription) => subscription.on === options.event
+ )
+ if (manifestSubscribed && plugin.manifest.main) {
+ void options.workerController
+ .ensure(plugin)
+ .then((handle) => handle.deliverEvent(options.event, projected.payload))
+ .catch((error) => {
+ options.logWarning(
+ plugin.pluginKey,
+ `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
+ )
+ }
+ }
+}
diff --git a/src/main/plugins/plugin-git-repository.ts b/src/main/plugins/plugin-git-repository.ts
new file mode 100644
index 000000000..e8870f9e7
--- /dev/null
+++ b/src/main/plugins/plugin-git-repository.ts
@@ -0,0 +1,57 @@
+import { execFile } from 'node:child_process'
+import { promisify } from 'node:util'
+import {
+ isAllowedPluginGitUrl,
+ PLUGIN_COMMIT_PATTERN
+} from '../../shared/plugins/plugin-install-lockfile'
+
+const execFileAsync = promisify(execFile)
+const PLUGIN_GIT_TIMEOUT_MS = 120_000
+
+/** Runs system Git with argv-only invocation so credential helpers and SSH
+ * remotes work without exposing an executable remote-helper surface. */
+export async function runPluginGit(args: string[], cwd: string): Promise {
+ const { stdout } = await execFileAsync('git', args, {
+ cwd,
+ timeout: PLUGIN_GIT_TIMEOUT_MS,
+ windowsHide: true,
+ env: {
+ ...process.env,
+ // Existing non-interactive helpers and SSH agents still work, but a
+ // background marketplace refresh can never hang on a terminal prompt.
+ GIT_TERMINAL_PROMPT: '0'
+ }
+ })
+ return stdout.trim()
+}
+
+/** Checks out one Git ref into an empty destination and returns exact HEAD. */
+export async function checkoutPluginGitSource(input: {
+ url: string
+ ref: string
+ destination: string
+ workingDirectory: string
+}): Promise {
+ if (!isAllowedPluginGitUrl(input.url)) {
+ throw new Error('plugin Git URL must use HTTPS or SSH')
+ }
+ const ref = input.ref.trim()
+ if (PLUGIN_COMMIT_PATTERN.test(ref)) {
+ await runPluginGit(['init', '--quiet', input.destination], input.workingDirectory)
+ await runPluginGit(['remote', 'add', 'origin', input.url], input.destination)
+ await runPluginGit(['fetch', '--quiet', '--depth', '1', 'origin', ref], input.destination)
+ await runPluginGit(['checkout', '--quiet', 'FETCH_HEAD'], input.destination)
+ } else {
+ const args = ['clone', '--quiet', '--depth', '1']
+ if (ref.length > 0) {
+ args.push('--branch', ref)
+ }
+ args.push('--', input.url, input.destination)
+ await runPluginGit(args, input.workingDirectory)
+ }
+ const resolvedCommit = await runPluginGit(['rev-parse', 'HEAD'], input.destination)
+ if (!PLUGIN_COMMIT_PATTERN.test(resolvedCommit)) {
+ throw new Error('Git resolved an invalid commit identity')
+ }
+ return resolvedCommit
+}
diff --git a/src/main/plugins/plugin-host-call-adapter.ts b/src/main/plugins/plugin-host-call-adapter.ts
new file mode 100644
index 000000000..39c26eabd
--- /dev/null
+++ b/src/main/plugins/plugin-host-call-adapter.ts
@@ -0,0 +1,57 @@
+import { z } from 'zod'
+import { isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest'
+import type { PluginPanelActionOutcome } from '../../shared/plugins/plugin-panel-bridge'
+import { executePluginHostCall, type ExecutePluginHostCallInput } from './plugin-host-methods'
+
+const pluginHostCallRequestSchema = z
+ .object({
+ method: z.string().min(1).max(128),
+ params: z.unknown().optional()
+ })
+ .strict()
+
+export type PluginHostCallRequest = z.infer
+
+export function isPluginHostCallRequest(request: unknown): request is PluginHostCallRequest {
+ return pluginHostCallRequestSchema.safeParse(request).success
+}
+
+export type PluginHostCallPolicy = Pick<
+ ExecutePluginHostCallInput,
+ 'grantedCapabilities' | 'services' | 'audit'
+>
+
+export type ResolvePluginHostCallPolicy = (
+ pluginKey: string
+) => PluginHostCallPolicy | Promise
+
+/** Validates the transport envelope, resolves all authority host-side, then
+ * enters the one capability/schema/audit execution chokepoint. */
+export async function executePluginHostCallRequest(input: {
+ /** Qualified identity already authenticated by the owning transport. */
+ pluginKey: string
+ request: unknown
+ viaPanel: boolean
+ resolvePolicy: ResolvePluginHostCallPolicy
+}): Promise {
+ if (!isQualifiedPluginKey(input.pluginKey)) {
+ return { ok: false, code: 'invalid_request', error: 'invalid qualified plugin key' }
+ }
+ const parsed = pluginHostCallRequestSchema.safeParse(input.request)
+ if (!parsed.success) {
+ return { ok: false, code: 'invalid_request', error: 'malformed plugin host call request' }
+ }
+ let policy: PluginHostCallPolicy
+ try {
+ policy = await input.resolvePolicy(input.pluginKey)
+ } catch {
+ return { ok: false, code: 'unavailable', error: 'plugin host policy is not available' }
+ }
+ return executePluginHostCall({
+ pluginId: input.pluginKey,
+ method: parsed.data.method,
+ params: parsed.data.params,
+ viaPanel: input.viaPanel,
+ ...policy
+ })
+}
diff --git a/src/main/plugins/plugin-host-conformance.test.ts b/src/main/plugins/plugin-host-conformance.test.ts
new file mode 100644
index 000000000..bc43dd741
--- /dev/null
+++ b/src/main/plugins/plugin-host-conformance.test.ts
@@ -0,0 +1,366 @@
+import { describe, expect, it, vi } from 'vitest'
+import { PLUGIN_HOST_API_V0 } from '../../shared/plugins/plugin-host-api'
+import type { PluginCapabilityKind } from '../../shared/plugins/plugin-capabilities'
+import {
+ admitPluginPanelCall,
+ createPluginPanelCallAdmission
+} from '../../shared/plugins/plugin-panel-call-admission'
+import type { PluginPanelActionOutcome } from '../../shared/plugins/plugin-panel-bridge'
+import type { MethodHandler } from '../../relay/dispatcher'
+import {
+ RELAY_PLUGIN_PANEL_HOST_CALL_METHOD,
+ RELAY_PLUGIN_WORKER_HOST_CALL_METHOD,
+ registerRelayPluginHostCallHandlers
+} from '../../relay/plugin-host-call-handler'
+import {
+ executePluginHostCallRequest,
+ type PluginHostCallPolicy,
+ type ResolvePluginHostCallPolicy
+} from './plugin-host-call-adapter'
+import type { PluginHostServices } from './plugin-host-methods'
+
+const PLUGIN_KEY = 'orca-samples.demo'
+const WORKTREE_ID = 'repo-id::/Users/private/orca'
+const TERMINAL_ID = 'terminal:local:one'
+
+type HostCallAdapter = (request: unknown, viaPanel: boolean) => Promise
+
+function createServices(): PluginHostServices {
+ return {
+ resolveActiveWorktreeContext: vi.fn().mockResolvedValue({
+ worktreeId: WORKTREE_ID,
+ branch: 'main',
+ displayName: 'Orca',
+ path: '/Users/private/orca'
+ }),
+ listWorktreeTerminals: vi
+ .fn()
+ .mockResolvedValue([{ id: TERMINAL_ID, title: '/home/private/orca' }]),
+ sendTerminalText: vi.fn().mockResolvedValue({ accepted: true }),
+ dispatchPluginNotification: vi.fn().mockResolvedValue({ delivered: true }),
+ storage: {
+ get: vi.fn().mockReturnValue('stored'),
+ set: vi.fn().mockReturnValue({ ok: true }),
+ delete: vi.fn(),
+ keys: vi.fn().mockReturnValue(['alpha'])
+ },
+ secrets: {
+ get: vi.fn().mockReturnValue({ ok: true, value: 'secret' }),
+ set: vi.fn().mockReturnValue({ ok: true }),
+ delete: vi.fn()
+ },
+ settings: {
+ getAll: vi.fn().mockReturnValue({ theme: 'dark' }),
+ set: vi.fn().mockReturnValue({ ok: true })
+ },
+ subscribeEvents: vi.fn().mockImplementation((_pluginKey, events) => events)
+ }
+}
+
+function createPolicy(
+ grantedCapabilities: readonly PluginCapabilityKind[] | null,
+ services: PluginHostServices = createServices(),
+ audit = { record: vi.fn().mockResolvedValue(undefined) }
+): PluginHostCallPolicy {
+ return { grantedCapabilities, services, audit }
+}
+
+function createAdapters(
+ resolvePolicy: ResolvePluginHostCallPolicy,
+ limits?: { maxBytes?: number; maxMessages?: number; perMs?: number }
+): Record {
+ const relayHandlers = new Map()
+ registerRelayPluginHostCallHandlers(
+ { onRequest: (method, handler) => relayHandlers.set(method, handler) },
+ (context) => (context.clientId === 1 ? PLUGIN_KEY : null),
+ resolvePolicy,
+ { panelAdmission: createPluginPanelCallAdmission({ limits, now: () => 0 }) }
+ )
+ const desktopAdmission = createPluginPanelCallAdmission({ limits, now: () => 0 })
+ return {
+ 'desktop-main': async (request, viaPanel) => {
+ if (viaPanel) {
+ const admissionRefusal = admitPluginPanelCall(desktopAdmission, PLUGIN_KEY, request)
+ if (admissionRefusal) {
+ return admissionRefusal
+ }
+ }
+ return executePluginHostCallRequest({
+ pluginKey: PLUGIN_KEY,
+ request,
+ viaPanel,
+ resolvePolicy
+ })
+ },
+ relay: async (request, viaPanel) => {
+ const registeredMethod = viaPanel
+ ? RELAY_PLUGIN_PANEL_HOST_CALL_METHOD
+ : RELAY_PLUGIN_WORKER_HOST_CALL_METHOD
+ return (await relayHandlers.get(registeredMethod)!(request as Record, {
+ clientId: 1,
+ isStale: () => false
+ })) as PluginPanelActionOutcome
+ }
+ }
+}
+
+const successParams: Record = {
+ 'workspace.readContext': {},
+ 'terminal.sendText': { terminalId: TERMINAL_ID, text: 'echo hi', enter: true },
+ 'notifications.show': { title: 'Hello' },
+ 'storage.get': { key: 'alpha' },
+ 'storage.set': { key: 'alpha', value: 1 },
+ 'storage.delete': { key: 'alpha' },
+ 'storage.keys': {},
+ 'secrets.get': { key: 'token' },
+ 'secrets.set': { key: 'token', value: 'secret' },
+ 'secrets.delete': { key: 'token' },
+ 'settings.get': {},
+ 'settings.set': { key: 'theme', value: 'dark' },
+ 'events.subscribe': { events: ['worktree.created'] }
+}
+
+describe('plugin host main/relay conformance', () => {
+ it('runs a granted success through both transports for all 13 v0 methods', async () => {
+ expect(PLUGIN_HOST_API_V0).toHaveLength(13)
+ expect(Object.keys(successParams).sort()).toEqual(
+ PLUGIN_HOST_API_V0.map((entry) => entry.name).sort()
+ )
+ expect(PLUGIN_HOST_API_V0.every((entry) => entry.stability === 'experimental')).toBe(true)
+ expect(PLUGIN_HOST_API_V0.every((entry) => entry.scope.length > 0)).toBe(true)
+
+ for (const spec of PLUGIN_HOST_API_V0) {
+ const policy = createPolicy([spec.capability])
+ const resolvePolicy = vi.fn().mockResolvedValue(policy)
+ const outcomes = await Promise.all(
+ Object.values(createAdapters(resolvePolicy)).map((adapter) =>
+ adapter({ method: spec.name, params: successParams[spec.name] }, spec.panel)
+ )
+ )
+ expect(outcomes, spec.name).toHaveLength(2)
+ expect(outcomes[0], spec.name).toEqual(outcomes[1])
+ expect(outcomes[0], spec.name).toMatchObject({ ok: true })
+ }
+ })
+
+ it('projects workspace context without host paths on main and relay', async () => {
+ const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['workspace:read']))
+ for (const adapter of Object.values(createAdapters(resolvePolicy))) {
+ const outcome = await adapter({ method: 'workspace.readContext', params: {} }, true)
+ expect(outcome).toEqual({
+ ok: true,
+ value: {
+ branch: 'main',
+ displayName: 'Orca',
+ terminals: [{ id: TERMINAL_ID }]
+ }
+ })
+ expect(outcome).not.toHaveProperty('value.path')
+ expect(outcome).not.toHaveProperty('value.worktreeId')
+ }
+ })
+
+ const deniedCases: {
+ name: string
+ request: unknown
+ viaPanel: boolean
+ policy: () => PluginHostCallPolicy
+ code: string
+ }[] = [
+ {
+ name: 'missing or stale consent',
+ request: { method: 'workspace.readContext', params: {} },
+ viaPanel: true,
+ policy: () => createPolicy(null),
+ code: 'consent_required'
+ },
+ {
+ name: 'missing capability',
+ request: { method: 'workspace.readContext', params: {} },
+ viaPanel: true,
+ policy: () => createPolicy([]),
+ code: 'capability_denied'
+ },
+ {
+ name: 'unknown method',
+ request: { method: 'workspace.erase', params: {} },
+ viaPanel: false,
+ policy: () => createPolicy(['workspace:read']),
+ code: 'unknown_method'
+ },
+ {
+ name: 'malformed params',
+ request: {
+ method: 'terminal.sendText',
+ params: { terminalId: TERMINAL_ID, text: '' }
+ },
+ viaPanel: true,
+ policy: () => createPolicy(['terminal:send']),
+ code: 'invalid_params'
+ },
+ {
+ name: 'panel-forbidden method',
+ request: { method: 'storage.get', params: { key: 'alpha' } },
+ viaPanel: true,
+ policy: () => createPolicy(['storage']),
+ code: 'panel_forbidden'
+ },
+ {
+ name: 'malformed result',
+ request: {
+ method: 'notifications.show',
+ params: { title: 'Hello' }
+ },
+ viaPanel: true,
+ policy: () => {
+ const services = createServices()
+ services.dispatchPluginNotification = vi
+ .fn()
+ .mockResolvedValue({ delivered: 'yes' } as unknown as { delivered: boolean })
+ return createPolicy(['notifications:show'], services)
+ },
+ code: 'action_failed'
+ },
+ {
+ name: 'mutation audit failure',
+ request: {
+ method: 'storage.set',
+ params: { key: 'alpha', value: 1 }
+ },
+ viaPanel: false,
+ policy: () =>
+ createPolicy(['storage'], createServices(), {
+ record: vi.fn().mockRejectedValue(new Error('disk full'))
+ }),
+ code: 'action_failed'
+ }
+ ]
+
+ it.each(deniedCases)('returns identical $code codes for $name', async (testCase) => {
+ const outcomes: PluginPanelActionOutcome[] = []
+ for (const adapterName of ['desktop-main', 'relay']) {
+ const resolvePolicy = vi.fn().mockImplementation(() => testCase.policy())
+ const adapter = createAdapters(resolvePolicy)[adapterName]!
+ outcomes.push(await adapter(testCase.request, testCase.viaPanel))
+ }
+ expect(outcomes[0]).toMatchObject({ ok: false, code: testCase.code })
+ expect(outcomes[1]).toMatchObject({ ok: false, code: testCase.code })
+ expect(outcomes[0]).toEqual(outcomes[1])
+ })
+
+ it('enforces the same per-plugin panel budget on desktop main and relay', async () => {
+ for (const adapterName of ['desktop-main', 'relay']) {
+ const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['notifications:show']))
+ const adapter = createAdapters(resolvePolicy, {
+ maxMessages: 1,
+ perMs: 10_000
+ })[adapterName]!
+
+ await expect(
+ adapter({ method: 'notifications.show', params: { title: 'first' } }, true)
+ ).resolves.toMatchObject({ ok: true })
+ await expect(
+ adapter({ method: 'notifications.show', params: { title: 'second' } }, true)
+ ).resolves.toEqual({
+ ok: false,
+ code: 'rate_limited',
+ error: 'too many panel requests'
+ })
+ }
+ })
+
+ it('charges malformed and oversized panel traffic before schema parsing', async () => {
+ for (const adapterName of ['desktop-main', 'relay']) {
+ const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['notifications:show']))
+ const adapter = createAdapters(resolvePolicy, {
+ maxBytes: 128,
+ maxMessages: 2,
+ perMs: 10_000
+ })[adapterName]!
+
+ await expect(
+ adapter({ method: 'notifications.show', unexpected: true }, true)
+ ).resolves.toMatchObject({ ok: false, code: 'invalid_request' })
+ await expect(
+ adapter(
+ {
+ method: 'notifications.show',
+ params: { title: 'x'.repeat(256) }
+ },
+ true
+ )
+ ).resolves.toEqual({
+ ok: false,
+ code: 'invalid_request',
+ error: 'panel message exceeds the size limit'
+ })
+ await expect(
+ adapter({ method: 'notifications.show', params: { title: 'third' } }, true)
+ ).resolves.toEqual({
+ ok: false,
+ code: 'rate_limited',
+ error: 'too many panel requests'
+ })
+ expect(resolvePolicy).not.toHaveBeenCalled()
+ }
+ })
+
+ it('binds relay plugin identity to the requesting connection', async () => {
+ const relayHandlers = new Map()
+ const services = createServices()
+ const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['storage'], services))
+ const resolveIdentity = vi
+ .fn()
+ .mockImplementation(({ clientId }: { clientId: number }) =>
+ clientId === 7 ? PLUGIN_KEY : null
+ )
+ registerRelayPluginHostCallHandlers(
+ { onRequest: (method, handler) => relayHandlers.set(method, handler) },
+ resolveIdentity,
+ resolvePolicy
+ )
+ const handler = relayHandlers.get(RELAY_PLUGIN_WORKER_HOST_CALL_METHOD)!
+
+ await expect(
+ handler(
+ { method: 'storage.get', params: { key: 'alpha' } },
+ { clientId: 7, isStale: () => false }
+ )
+ ).resolves.toMatchObject({ ok: true })
+ expect(services.storage.get).toHaveBeenCalledWith(PLUGIN_KEY, 'alpha')
+
+ await expect(
+ handler(
+ { method: 'storage.get', params: { key: 'alpha' } },
+ { clientId: 8, isStale: () => false }
+ )
+ ).resolves.toMatchObject({ ok: false, code: 'unavailable' })
+ expect(resolvePolicy).toHaveBeenCalledTimes(1)
+ })
+
+ it('rejects malformed envelopes and client-supplied authority before policy resolution', async () => {
+ const requests = [
+ { pluginKey: '../evil', method: 'storage.get', params: { key: 'alpha' } },
+ {
+ pluginKey: PLUGIN_KEY,
+ method: 'storage.get',
+ params: { key: 'alpha' },
+ grantedCapabilities: ['storage']
+ },
+ {
+ pluginKey: PLUGIN_KEY,
+ method: 'storage.get',
+ params: { key: 'alpha' },
+ viaPanel: false
+ }
+ ]
+ for (const request of requests) {
+ for (const adapterName of ['desktop-main', 'relay']) {
+ const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['storage']))
+ const outcome = await createAdapters(resolvePolicy)[adapterName]!(request, false)
+ expect(outcome).toMatchObject({ ok: false, code: 'invalid_request' })
+ expect(resolvePolicy).not.toHaveBeenCalled()
+ }
+ }
+ })
+})
diff --git a/src/main/plugins/plugin-host-entry.ts b/src/main/plugins/plugin-host-entry.ts
new file mode 100644
index 000000000..cc6d40a55
--- /dev/null
+++ b/src/main/plugins/plugin-host-entry.ts
@@ -0,0 +1,41 @@
+/**
+ * Child-process entry for the out-of-process plugin worker. Forked with
+ * ELECTRON_RUN_AS_NODE, so this file must stay plain Node — no electron
+ * imports (directly or transitively). All logic lives in
+ * `plugin-host-runtime.ts`; this file only wires the fork IPC channel.
+ */
+import { createPluginWorkerRuntime } from './plugin-host-runtime'
+import type { PluginWorkerChildMessage } from '../../shared/plugins/plugin-host-protocol'
+
+function sendToParent(message: PluginWorkerChildMessage): void {
+ process.send?.(message)
+}
+
+const runtime = createPluginWorkerRuntime({ send: sendToParent })
+
+process.on('message', (raw: unknown) => {
+ void runtime.handleMessage(raw)
+})
+
+// Why: third-party plugin code runs here; an escaped rejection must not leave
+// a zombie worker. Report the crash so the parent can supervise/restart.
+function dieFatally(error: unknown): void {
+ try {
+ sendToParent({
+ type: 'fatal',
+ error: error instanceof Error ? (error.stack ?? error.message) : String(error)
+ })
+ } catch {
+ // Channel already gone; nothing left to report to.
+ }
+ process.exit(1)
+}
+
+process.on('uncaughtException', dieFatally)
+process.on('unhandledRejection', dieFatally)
+
+// Why: if the parent dies without sending shutdown, the IPC channel closes;
+// exit instead of lingering as an orphaned Node process.
+process.on('disconnect', () => {
+ process.exit(0)
+})
diff --git a/src/main/plugins/plugin-host-method-bindings.ts b/src/main/plugins/plugin-host-method-bindings.ts
new file mode 100644
index 000000000..b10730272
--- /dev/null
+++ b/src/main/plugins/plugin-host-method-bindings.ts
@@ -0,0 +1,181 @@
+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
+}
+
+/** Structural service surface the facade delegates to. Desktop main binds it
+ * over runtime services; relay policy and conformance tests bind fakes. */
+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)
+ // Why: Orca worktree ids embed provider paths, so the public projection
+ // must select safe fields instead of spreading the internal context.
+ 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')
+ }
+ // Why: terminal handles are provider-owned and can outlive focus changes;
+ // re-list the resolved worktree immediately before routing plugin 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) }
+ })
+])
+
+// Why: adding a facade schema without a binding must fail at module load,
+// before a plugin can observe transport-specific 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-methods.test.ts b/src/main/plugins/plugin-host-methods.test.ts
new file mode 100644
index 000000000..19b12f43d
--- /dev/null
+++ b/src/main/plugins/plugin-host-methods.test.ts
@@ -0,0 +1,232 @@
+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 { bindPluginHostServices, type PluginRuntimeDelegate } from './plugin-host-service-bindings'
+import { executePluginHostCall, type PluginHostServices } from './plugin-host-methods'
+
+function createServices(storageSet: PluginHostServices['storage']['set']): PluginHostServices {
+ return {
+ resolveActiveWorktreeContext: vi.fn().mockResolvedValue(null),
+ listWorktreeTerminals: vi.fn().mockResolvedValue([]),
+ sendTerminalText: vi.fn().mockResolvedValue({ accepted: true }),
+ dispatchPluginNotification: vi.fn().mockResolvedValue({ delivered: true }),
+ storage: {
+ get: vi.fn(),
+ set: storageSet,
+ delete: vi.fn(),
+ keys: vi.fn().mockReturnValue([])
+ },
+ secrets: {
+ get: vi.fn().mockReturnValue({ ok: true, value: null }),
+ set: vi.fn().mockReturnValue({ ok: true }),
+ delete: vi.fn()
+ },
+ settings: {
+ getAll: vi.fn().mockReturnValue({}),
+ set: vi.fn().mockReturnValue({ ok: true })
+ },
+ subscribeEvents: vi.fn().mockReturnValue([])
+ }
+}
+
+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',
+ method: 'storage.set',
+ params: { key: '__proto__', value: 42 },
+ viaPanel: false,
+ grantedCapabilities: ['storage'],
+ services: createServices(storageSet),
+ audit: { record: vi.fn().mockResolvedValue(undefined) }
+ })
+
+ expect(outcome).toMatchObject({ ok: false, code: 'invalid_params' })
+ expect(storageSet).not.toHaveBeenCalled()
+ })
+
+ 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',
+ method: 'storage.set',
+ params: { key: 'created', value: new Date() },
+ viaPanel: false,
+ grantedCapabilities: ['storage'],
+ services: createServices(storageSet),
+ audit: { record: vi.fn().mockResolvedValue(undefined) }
+ })
+
+ expect(outcome).toMatchObject({ ok: false, code: 'invalid_params' })
+ expect(storageSet).not.toHaveBeenCalled()
+ })
+
+ 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',
+ method: 'storage.set',
+ params: { key: 'answer', value: 42 },
+ viaPanel: false,
+ grantedCapabilities: ['storage'],
+ services: createServices(storageSet),
+ audit: { record: vi.fn().mockRejectedValue(new Error('disk full')) }
+ })
+
+ expect(outcome).toMatchObject({ ok: false, code: 'action_failed' })
+ expect(storageSet).not.toHaveBeenCalled()
+ })
+
+ it('records an intent before the mutation and its outcome afterward', async () => {
+ const order: string[] = []
+ const storageSet = vi.fn(() => {
+ order.push('mutation')
+ return { ok: true as const }
+ })
+ const record = vi.fn(async (entry: { outcome: string }) => {
+ order.push(`audit:${entry.outcome}`)
+ })
+
+ const outcome = await executePluginHostCall({
+ pluginId: 'orca-samples.demo',
+ method: 'storage.set',
+ params: { key: 'answer', value: 42 },
+ viaPanel: false,
+ grantedCapabilities: ['storage'],
+ services: createServices(storageSet),
+ audit: { record }
+ })
+
+ expect(outcome).toEqual({ ok: true, value: { ok: true } })
+ expect(order).toEqual(['audit:attempt', 'mutation', 'audit:ok'])
+ })
+
+ 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',
+ method: 'storage.set',
+ params: { key: 'answer', value: 42 },
+ viaPanel: false,
+ grantedCapabilities: ['storage'],
+ services: createServices(storageSet)
+ })
+
+ expect(outcome).toMatchObject({ ok: false, code: 'unavailable' })
+ expect(storageSet).not.toHaveBeenCalled()
+ })
+})
+
+function createTerminalHarness(terminalHandles: string[]): {
+ delegate: PluginRuntimeDelegate
+ services: PluginHostServices
+} {
+ const delegate: PluginRuntimeDelegate = {
+ resolveActiveWorktreeContext: vi.fn().mockResolvedValue({
+ worktreeId: 'worktree-1',
+ path: '/Users/private/repo',
+ branch: 'main',
+ displayName: 'Repo'
+ }),
+ listTerminals: vi.fn().mockResolvedValue({
+ terminals: terminalHandles.map((handle) => ({ handle, title: null }))
+ }),
+ sendTerminal: vi.fn().mockResolvedValue({ accepted: true }),
+ dispatchPluginNotification: vi.fn().mockResolvedValue({ delivered: true })
+ }
+ return {
+ delegate,
+ services: bindPluginHostServices({
+ delegate,
+ pluginsDataDir: join(tmpdir(), 'plugin-host-methods-test'),
+ subscribeEvents: vi.fn().mockReturnValue([])
+ })
+ }
+}
+
+async function sendTerminalText(
+ services: PluginHostServices,
+ terminalId: string
+): ReturnType {
+ return executePluginHostCall({
+ pluginId: 'orca-samples.demo',
+ method: 'terminal.sendText',
+ params: { terminalId, text: 'echo hi', enter: true },
+ viaPanel: true,
+ grantedCapabilities: ['terminal:send'],
+ services,
+ audit: { record: vi.fn().mockResolvedValue(undefined) }
+ })
+}
+
+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'])
+
+ const outcome = await sendTerminalText(services, 'terminal:ssh:requested')
+
+ expect(outcome).toMatchObject({ ok: false, code: 'action_failed' })
+ expect(delegate.resolveActiveWorktreeContext).toHaveBeenCalledTimes(1)
+ expect(delegate.listTerminals).toHaveBeenCalledTimes(1)
+ expect(delegate.listTerminals).toHaveBeenCalledWith(
+ 'id:worktree-1',
+ PLUGIN_WORKSPACE_TERMINAL_LIMIT
+ )
+ expect(delegate.sendTerminal).not.toHaveBeenCalled()
+ })
+
+ it.each(['terminal:local:one', 'terminal:ssh:opaque-provider-id'])(
+ 'performs one bounded list and one send for provider-agnostic id %s',
+ async (terminalId) => {
+ const { delegate, services } = createTerminalHarness([terminalId])
+
+ const outcome = await sendTerminalText(services, terminalId)
+
+ expect(outcome).toEqual({ ok: true, value: { accepted: true } })
+ expect(delegate.resolveActiveWorktreeContext).toHaveBeenCalledTimes(1)
+ expect(delegate.listTerminals).toHaveBeenCalledTimes(1)
+ expect(delegate.listTerminals).toHaveBeenCalledWith(
+ 'id:worktree-1',
+ PLUGIN_WORKSPACE_TERMINAL_LIMIT
+ )
+ expect(delegate.sendTerminal).toHaveBeenCalledTimes(1)
+ expect(delegate.sendTerminal).toHaveBeenCalledWith(terminalId, {
+ text: 'echo hi',
+ enter: true
+ })
+ expect(vi.mocked(delegate.listTerminals).mock.invocationCallOrder[0]!).toBeLessThan(
+ vi.mocked(delegate.sendTerminal).mock.invocationCallOrder[0]!
+ )
+ }
+ )
+
+ it('bounds workspace.readContext and omits the provider path', async () => {
+ const handles = Array.from(
+ { length: PLUGIN_WORKSPACE_TERMINAL_LIMIT + 10 },
+ (_, index) => `terminal:local:${index}`
+ )
+ const { delegate, services } = createTerminalHarness(handles)
+
+ const outcome = await executePluginHostCall({
+ pluginId: 'orca-samples.demo',
+ method: 'workspace.readContext',
+ params: {},
+ viaPanel: true,
+ grantedCapabilities: ['workspace:read'],
+ services
+ })
+
+ expect(outcome).toMatchObject({
+ ok: true,
+ value: { branch: 'main', displayName: 'Repo' }
+ })
+ expect(outcome).not.toHaveProperty('value.path')
+ expect(outcome).not.toHaveProperty('value.worktreeId')
+ expect(outcome.ok && (outcome.value as { terminals: unknown[] }).terminals).toHaveLength(
+ PLUGIN_WORKSPACE_TERMINAL_LIMIT
+ )
+ expect(delegate.listTerminals).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/src/main/plugins/plugin-host-methods.ts b/src/main/plugins/plugin-host-methods.ts
new file mode 100644
index 000000000..c4ffb0053
--- /dev/null
+++ b/src/main/plugins/plugin-host-methods.ts
@@ -0,0 +1,144 @@
+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'
+
+/**
+ * Host API v0 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'
+
+export type ExecutePluginHostCallInput = {
+ /** Qualified plugin key, bound host-side from authenticated identity. */
+ pluginId: string
+ method: string
+ params: unknown
+ /** True when the call arrives over the sandboxed panel bridge. */
+ viaPanel: boolean
+ /** Consented capability kinds; null = unknown/disabled/consent-stale. */
+ grantedCapabilities: readonly PluginCapabilityKind[] | null
+ services: PluginHostServices | null
+ audit?: Pick
+}
+
+export async function executePluginHostCall(
+ input: ExecutePluginHostCallInput
+): 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 },
+ input.method
+ )
+ if (!gate.granted) {
+ return { ok: false, code: gate.code, error: gate.error }
+ }
+ const bound = getBoundPluginHostMethod(input.method)
+ 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'}`
+ }
+ }
+ if (!input.services) {
+ return { ok: false, code: 'unavailable', error: 'runtime is not available' }
+ }
+ const auditMutation = async (outcome: 'attempt' | 'ok' | 'error'): Promise => {
+ if (bound.spec.mutation && input.audit) {
+ await input.audit.record({
+ ts: Date.now(),
+ actor: `plugin:${input.pluginId}`,
+ method: input.method,
+ summary: summarizeParams(input.method, parsedParams.data),
+ outcome
+ })
+ }
+ }
+ if (bound.spec.mutation) {
+ if (!input.audit) {
+ return {
+ ok: false,
+ code: 'unavailable',
+ error: 'mutation audit log is not available'
+ }
+ }
+ try {
+ // The intent is appended before the handler. If this write fails, the
+ // mutation is never attempted.
+ await auditMutation('attempt')
+ } catch {
+ return {
+ ok: false,
+ code: 'action_failed',
+ error: 'mutation audit log could not be written'
+ }
+ }
+ }
+ try {
+ const value = await bound.handler(parsedParams.data, {
+ pluginId: input.pluginId,
+ services: input.services
+ })
+ const validated = bound.spec.result.safeParse(value)
+ if (!validated.success) {
+ await auditMutation('error').catch(() => undefined)
+ // A result-schema mismatch is a host bug; fail the call rather than
+ // leaking an unvalidated shape into plugin-facing transports.
+ return {
+ ok: false,
+ code: 'action_failed',
+ error: `internal: malformed ${input.method} result`
+ }
+ }
+ await auditMutation('ok').catch(() => undefined)
+ return { ok: true, value: validated.data }
+ } catch (error) {
+ await auditMutation('error').catch(() => undefined)
+ return {
+ ok: false,
+ code: 'action_failed',
+ error: error instanceof Error ? error.message : String(error)
+ }
+ }
+}
+
+/** Bounded, content-free summaries for the audit log. */
+function summarizeParams(method: string, params: unknown): string {
+ const record = (typeof params === 'object' && params !== null ? params : {}) as Record<
+ string,
+ unknown
+ >
+ switch (method) {
+ case 'terminal.sendText': {
+ const text = typeof record.text === 'string' ? record.text : ''
+ return `terminal=${String(record.terminalId)} bytes=${Buffer.byteLength(text, 'utf8')} enter=${record.enter === true}`
+ }
+ case 'notifications.show': {
+ const title = typeof record.title === 'string' ? record.title : ''
+ return `titleChars=${title.length}`
+ }
+ case 'storage.set':
+ case 'storage.delete':
+ case 'secrets.set':
+ case 'secrets.delete':
+ case 'settings.set':
+ return `key=${String(record.key)}`
+ default:
+ return ''
+ }
+}
diff --git a/src/main/plugins/plugin-host-process.test.ts b/src/main/plugins/plugin-host-process.test.ts
new file mode 100644
index 000000000..2c4716f8e
--- /dev/null
+++ b/src/main/plugins/plugin-host-process.test.ts
@@ -0,0 +1,135 @@
+import { EventEmitter } from 'node:events'
+import { PassThrough } from 'node:stream'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const processMocks = vi.hoisted(() => ({ fork: vi.fn() }))
+vi.mock('node:child_process', () => ({ fork: processMocks.fork }))
+
+import { startPluginWorker } from './plugin-host-process'
+
+class FakeChild extends EventEmitter {
+ connected = true
+ stdout = new PassThrough()
+ stderr = new PassThrough()
+ send = vi.fn()
+ kill = vi.fn()
+}
+
+function start(child: FakeChild, options: { eventTimeoutMs?: number } = {}) {
+ processMocks.fork.mockReturnValue(child)
+ return startPluginWorker({
+ pluginId: 'orca-samples.demo',
+ rootDir: '/plugin',
+ mainEntry: 'worker.js',
+ entryPath: '/host.js',
+ grantedCapabilities: [],
+ executeHostCall: async () => ({ ok: true, value: null }),
+ log: vi.fn(),
+ ...options
+ })
+}
+
+beforeEach(() => {
+ processMocks.fork.mockReset()
+})
+
+afterEach(() => {
+ vi.useRealTimers()
+})
+
+describe('startPluginWorker', () => {
+ it('does not inherit Orca execArgv', async () => {
+ const child = new FakeChild()
+ const pending = start(child)
+ child.emit('message', { type: 'ready', commands: [] })
+ await pending
+
+ expect(processMocks.fork).toHaveBeenCalledWith(
+ '/host.js',
+ [],
+ expect.objectContaining({ execArgv: [] })
+ )
+ })
+
+ 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'] })
+ const handle = await pending
+ child.emit('exit', 23)
+ const onExit = vi.fn()
+
+ handle.onExit(onExit)
+
+ expect(onExit).toHaveBeenCalledOnce()
+ expect(onExit).toHaveBeenCalledWith(23)
+ })
+
+ 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'] })
+ const handle = await pending
+ const command = handle.invokeCommand('run')
+ child.connected = false
+
+ child.emit('disconnect')
+
+ await expect(command).rejects.toThrow('disconnected')
+ expect(child.kill).toHaveBeenCalledWith('SIGKILL')
+ })
+
+ 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: [] })
+ const handle = await pending
+
+ handle.deliverEvent('worktree.created', {
+ worktreeId: 'worktree-1',
+ path: '/repo',
+ branch: 'feature'
+ })
+
+ expect(handle.inFlightCount()).toBe(1)
+ child.emit('message', { type: 'eventAck', eventId: 0 })
+ expect(handle.inFlightCount()).toBe(0)
+ })
+
+ it('kills a worker whose event handler never acknowledges completion', async () => {
+ vi.useFakeTimers()
+ const child = new FakeChild()
+ const pending = start(child, { eventTimeoutMs: 25 })
+ child.emit('message', { type: 'ready', commands: [] })
+ const handle = await pending
+
+ handle.deliverEvent('worktree.created', {
+ worktreeId: 'worktree-1',
+ path: '/repo',
+ branch: 'feature'
+ })
+ await vi.advanceTimersByTimeAsync(25)
+
+ expect(handle.inFlightCount()).toBe(0)
+ expect(child.kill).toHaveBeenCalledWith('SIGKILL')
+ })
+
+ 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: [] })
+ const handle = await pending
+
+ for (let index = 0; index < 65; index += 1) {
+ handle.deliverEvent('agent.status.changed', {
+ worktreeId: null,
+ paneKey: `pane-${index}`,
+ state: 'working',
+ receivedAt: Date.now()
+ })
+ }
+
+ expect(handle.inFlightCount()).toBe(64)
+ expect(child.kill).toHaveBeenCalledWith('SIGKILL')
+ })
+})
diff --git a/src/main/plugins/plugin-host-process.ts b/src/main/plugins/plugin-host-process.ts
new file mode 100644
index 000000000..0c289d502
--- /dev/null
+++ b/src/main/plugins/plugin-host-process.ts
@@ -0,0 +1,332 @@
+import { fork, type ChildProcess } from 'node:child_process'
+import { existsSync } from 'node:fs'
+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'
+import { buildPluginWorkerEnv } from './plugin-worker-env'
+import { pipePluginWorkerOutput } from './plugin-worker-output-buffer'
+
+// Grace between the shutdown message and SIGKILL: long enough for plugin
+// cleanup, short enough that disable/quit never feels stuck.
+const PLUGIN_WORKER_SHUTDOWN_GRACE_MS = 2_000
+const PLUGIN_WORKER_EVENT_TIMEOUT_MS = 5 * 60_000
+const PLUGIN_WORKER_MAX_PENDING_EVENTS = 64
+
+export type PluginWorkerLogSink = (level: 'info' | 'warn' | 'error', line: string) => void
+
+/** Executes a worker-originated host API call; the outcome is relayed back
+ * over the fork channel as a hostResult message. */
+export type PluginWorkerHostCallExecutor = (
+ method: string,
+ params: unknown
+) => Promise
+
+export type PluginWorkerHandle = {
+ /** Command ids the worker registered on activate (⊆ manifest commands). */
+ commands: readonly string[]
+ invokeCommand(commandId: string, args?: unknown): Promise
+ deliverEvent(event: PluginEventName, payload: unknown): void
+ /** Milliseconds timestamp of the last completed work (for idle reap). */
+ lastActivityAt(): number
+ inFlightCount(): number
+ dispose(): Promise
+ kill(): void
+ onExit(callback: (code: number | null) => void): void
+}
+
+export type StartPluginWorkerOptions = {
+ pluginId: string
+ rootDir: string
+ mainEntry: string
+ /** Absolute path to the compiled plugin-host-entry.js, resolved by caller. */
+ entryPath: string
+ grantedCapabilities: readonly PluginCapabilityKind[]
+ executeHostCall: PluginWorkerHostCallExecutor
+ log: PluginWorkerLogSink
+ readyTimeoutMs?: number
+ invokeTimeoutMs?: number
+ eventTimeoutMs?: number
+ signal?: AbortSignal
+}
+
+/**
+ * Resolves the compiled child entry from the app path. Mirrors
+ * getDaemonEntryPath(): packaged apps must fork the asar-unpacked copy
+ * because fork() cannot execute scripts from inside app.asar.
+ */
+export function resolvePluginHostEntryPath(appPath: string, isPackaged: boolean): string {
+ const basePath = isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath
+ const directEntryPath = join(basePath, 'plugin-host-entry.js')
+ if (existsSync(directEntryPath)) {
+ return directEntryPath
+ }
+ return join(basePath, 'out', 'main', 'plugin-host-entry.js')
+}
+
+type PendingCall = {
+ resolve: (value: unknown) => void
+ reject: (error: Error) => void
+ timer: ReturnType
+}
+
+export async function startPluginWorker(
+ options: StartPluginWorkerOptions
+): Promise {
+ const { pluginId, rootDir, mainEntry, entryPath, log } = options
+ const readyTimeoutMs = options.readyTimeoutMs ?? PLUGIN_WORKER_READY_TIMEOUT_MS
+ const invokeTimeoutMs = options.invokeTimeoutMs ?? PLUGIN_WORKER_INVOKE_TIMEOUT_MS
+ const eventTimeoutMs = options.eventTimeoutMs ?? PLUGIN_WORKER_EVENT_TIMEOUT_MS
+ const tag = `[plugin:${pluginId}]`
+
+ const child: ChildProcess = fork(entryPath, [], {
+ // Why: ELECTRON_RUN_AS_NODE makes the forked Electron binary behave as
+ // plain Node. The env is a scrubbed allowlist — never ...process.env,
+ // which can carry shell-exported secrets into third-party code.
+ env: buildPluginWorkerEnv(),
+ // Why: inspector/loader flags from Orca's own launch must never execute
+ // inside third-party plugin workers.
+ execArgv: [],
+ // Why: the protocol permits structured-clone values. Node's default JSON
+ // fork serialization rejects BigInt, cycles, maps, and typed arrays.
+ serialization: 'advanced',
+ stdio: ['ignore', 'pipe', 'pipe', 'ipc']
+ })
+ pipePluginWorkerOutput(child.stdout, 'info', log)
+ pipePluginWorkerOutput(child.stderr, 'error', log)
+
+ const pendingCommands = new Map()
+ const pendingEvents = new Map>()
+ const exitCallbacks: ((code: number | null) => void)[] = []
+ let nextCallId = 0
+ let nextEventId = 0
+ let exited = false
+ let exitCode: number | null = null
+ let disposed = false
+ let lastActivityAt = Date.now()
+
+ function sendToChild(message: PluginWorkerParentMessage): void {
+ if (child.connected) {
+ child.send(message)
+ }
+ }
+
+ function rejectAllPending(reason: string): void {
+ for (const [callId, entry] of pendingCommands) {
+ clearTimeout(entry.timer)
+ pendingCommands.delete(callId)
+ entry.reject(new Error(reason))
+ }
+ for (const timer of pendingEvents.values()) {
+ clearTimeout(timer)
+ }
+ pendingEvents.clear()
+ }
+
+ child.on('exit', (code) => {
+ exited = true
+ exitCode = code
+ rejectAllPending(`${tag} worker exited before responding`)
+ for (const callback of exitCallbacks) {
+ callback(code)
+ }
+ })
+ child.on('disconnect', () => {
+ // Why: a worker can drop fork IPC while its event loop stays alive. Kill
+ // it so the ensuing exit enters the normal supervision/backoff path.
+ rejectAllPending(`${tag} worker disconnected before responding`)
+ if (!exited) {
+ child.kill('SIGKILL')
+ }
+ })
+
+ const commands = await new Promise((resolve, reject) => {
+ let settled = false
+ const timer = setTimeout(() => {
+ fail(new Error(`${tag} worker did not become ready within ${readyTimeoutMs}ms`))
+ child.kill('SIGKILL')
+ }, readyTimeoutMs)
+ function fail(error: Error): void {
+ if (!settled) {
+ settled = true
+ clearTimeout(timer)
+ options.signal?.removeEventListener('abort', onAbort)
+ reject(error)
+ }
+ }
+ const onAbort = (): void => {
+ fail(new Error(`${tag} worker startup was cancelled`))
+ child.kill('SIGKILL')
+ }
+ options.signal?.addEventListener('abort', onAbort, { once: true })
+ child.on('error', (error) => {
+ const failure = new Error(`${tag} worker process error: ${error.message}`)
+ fail(failure)
+ child.kill('SIGKILL')
+ // Why: fail() no-ops once ready; a post-ready channel fault must still
+ // reject in-flight calls instead of letting each hit its own timeout.
+ rejectAllPending(failure.message)
+ })
+ child.on('exit', (code) => fail(new Error(`${tag} worker exited before ready (code ${code})`)))
+ child.on('message', (raw) => {
+ const parsed = pluginWorkerChildMessageSchema.safeParse(raw)
+ if (!parsed.success) {
+ 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) {
+ return
+ }
+ clearTimeout(entry.timer)
+ pendingCommands.delete(message.callId)
+ lastActivityAt = Date.now()
+ if (message.ok) {
+ entry.resolve(message.value)
+ } else {
+ entry.reject(new Error(message.error ?? 'plugin command failed'))
+ }
+ return
+ }
+ case 'eventAck': {
+ const timer = pendingEvents.get(message.eventId)
+ if (timer) {
+ clearTimeout(timer)
+ pendingEvents.delete(message.eventId)
+ }
+ lastActivityAt = Date.now()
+ return
+ }
+ case 'hostCall': {
+ lastActivityAt = Date.now()
+ // Host API calls from the worker: gate + execute in main, then
+ // relay the outcome. Never throws — errors become outcomes.
+ 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
+ }
+ )
+ })
+ return
+ }
+ case 'log': {
+ log(message.level, message.message)
+ return
+ }
+ case 'fatal': {
+ fail(new Error(`${tag} worker crashed: ${message.error}`))
+ rejectAllPending(`${tag} worker crashed: ${message.error}`)
+ child.kill('SIGKILL')
+ }
+ }
+ })
+ sendToChild({
+ type: 'init',
+ pluginId,
+ pluginRoot: rootDir,
+ mainEntry,
+ grantedCapabilities: [...options.grantedCapabilities]
+ })
+ if (options.signal?.aborted) {
+ onAbort()
+ }
+ })
+
+ return {
+ commands,
+ invokeCommand(commandId, args) {
+ if (exited || disposed) {
+ return Promise.reject(new Error(`${tag} worker is not running`))
+ }
+ const callId = nextCallId++
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ pendingCommands.delete(callId)
+ reject(new Error(`${tag} ${commandId} timed out after ${invokeTimeoutMs}ms`))
+ }, invokeTimeoutMs)
+ pendingCommands.set(callId, { resolve, reject, timer })
+ sendToChild({ type: 'invokeCommand', callId, commandId, args })
+ })
+ },
+ deliverEvent(event, payload) {
+ if (exited || disposed) {
+ return
+ }
+ if (pendingEvents.size >= PLUGIN_WORKER_MAX_PENDING_EVENTS) {
+ log('error', `${tag} exceeded the pending event limit`)
+ child.kill('SIGKILL')
+ return
+ }
+ lastActivityAt = Date.now()
+ const eventId = nextEventId++
+ 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 })
+ },
+ lastActivityAt: () => lastActivityAt,
+ inFlightCount: () => pendingCommands.size + pendingEvents.size,
+ async dispose() {
+ if (disposed) {
+ return
+ }
+ disposed = true
+ if (exited) {
+ return
+ }
+ sendToChild({ type: 'shutdown' })
+ await new Promise((resolve) => {
+ const killTimer = setTimeout(() => {
+ child.kill('SIGKILL')
+ }, PLUGIN_WORKER_SHUTDOWN_GRACE_MS)
+ child.once('exit', () => {
+ clearTimeout(killTimer)
+ resolve()
+ })
+ if (exited) {
+ clearTimeout(killTimer)
+ resolve()
+ }
+ })
+ },
+ kill() {
+ child.kill('SIGKILL')
+ },
+ onExit(callback) {
+ if (exited) {
+ callback(exitCode)
+ } else {
+ exitCallbacks.push(callback)
+ }
+ }
+ }
+}
diff --git a/src/main/plugins/plugin-host-runtime.test.ts b/src/main/plugins/plugin-host-runtime.test.ts
new file mode 100644
index 000000000..48d680a03
--- /dev/null
+++ b/src/main/plugins/plugin-host-runtime.test.ts
@@ -0,0 +1,76 @@
+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
new file mode 100644
index 000000000..2a2f5f9fc
--- /dev/null
+++ b/src/main/plugins/plugin-host-runtime.ts
@@ -0,0 +1,210 @@
+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'
+
+/**
+ * Message-loop core of the out-of-process plugin worker. Electron-free and
+ * side-effect-free (send/import/exit are injected) so it unit-tests without
+ * forking a real child process; `plugin-host-entry.ts` wires it to the fork
+ * IPC channel.
+ */
+
+export type PluginHostCallError = Error & { code?: string }
+
+/** API surface handed to a plugin's `activate(orca)` export. Everything is
+ * EXPERIMENTAL until pluginApi v1 freezes. */
+export type PluginWorkerOrcaApi = {
+ /** Register the handler for a command declared in the manifest. */
+ commands: {
+ register(commandId: string, handler: (args: unknown) => unknown | Promise): void
+ }
+ /** Handle an event the manifest subscribed to (`contributes.events`). */
+ events: {
+ on(event: PluginEventName, handler: (payload: unknown) => void | Promise): void
+ }
+ /** Call a host API method (capability-gated host-side). */
+ 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 | Promise>()
+ 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 | Promise) | 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
+ // Why: file URL import keeps ESM plugin entries working on Windows paths.
+ // Why: manifest paths accept either portable separator; split explicitly
+ // so a Windows-authored plugin also imports on macOS/Linux and vice versa.
+ 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 | Promise) | 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) {
+ // Why: an init/activation failure leaves the worker useless; report
+ // and die so the parent surfaces the error instead of hanging on
+ // the ready timeout.
+ send({ type: 'fatal', error: toErrorMessage(error) })
+ exit(1)
+ }
+ }
+ }
+}
diff --git a/src/main/plugins/plugin-host-service-bindings.ts b/src/main/plugins/plugin-host-service-bindings.ts
new file mode 100644
index 000000000..a9aad46d3
--- /dev/null
+++ b/src/main/plugins/plugin-host-service-bindings.ts
@@ -0,0 +1,84 @@
+import type { PluginEventName } from '../../shared/plugins/plugin-manifest'
+import { PLUGIN_WORKSPACE_TERMINAL_LIMIT } from '../../shared/plugins/plugin-host-api'
+import type { PluginHostServices } from './plugin-host-methods'
+import { PluginSecretsStore } from './plugin-secrets-store'
+import { PluginKvStore } from './plugin-storage-store'
+
+/** Structural subset of OrcaRuntimeService exposed to plugin facade bindings. */
+export type PluginRuntimeDelegate = {
+ resolveActiveWorktreeContext(): Promise<{
+ worktreeId: string
+ path: string
+ branch: string
+ displayName: string
+ } | null>
+ listTerminals(
+ worktreeSelector?: string,
+ limit?: number
+ ): Promise<{ terminals: { handle: string; title: string | null }[] }>
+ sendTerminal(
+ handle: string,
+ action: { text?: string; enter?: boolean }
+ ): Promise<{ accepted: boolean }>
+ dispatchPluginNotification(input: {
+ pluginId: string
+ title: string
+ body?: string
+ }): Promise<{ delivered: boolean }>
+}
+
+export function bindPluginHostServices(input: {
+ delegate: PluginRuntimeDelegate
+ pluginsDataDir: string
+ subscribeEvents: (pluginKey: string, events: PluginEventName[]) => PluginEventName[]
+}): PluginHostServices {
+ const { delegate, pluginsDataDir, subscribeEvents } = input
+ return {
+ resolveActiveWorktreeContext: async () => {
+ const context = await delegate.resolveActiveWorktreeContext()
+ if (!context) {
+ return null
+ }
+ // Why: retain the internal id only for host-side terminal membership;
+ // the public handler projects it out because it embeds provider paths.
+ return {
+ worktreeId: context.worktreeId,
+ branch: context.branch,
+ displayName: context.displayName
+ }
+ },
+ listWorktreeTerminals: async (worktreeId) => {
+ const result = await delegate.listTerminals(
+ `id:${worktreeId}`,
+ PLUGIN_WORKSPACE_TERMINAL_LIMIT
+ )
+ return result.terminals
+ .slice(0, PLUGIN_WORKSPACE_TERMINAL_LIMIT)
+ .map((terminal) => ({ id: terminal.handle }))
+ },
+ sendTerminalText: async (terminalId, action) => {
+ const result = await delegate.sendTerminal(terminalId, action)
+ return { accepted: result.accepted }
+ },
+ dispatchPluginNotification: (notification) => delegate.dispatchPluginNotification(notification),
+ storage: {
+ get: (key, itemKey) => new PluginKvStore(pluginsDataDir, key, 'storage.json').get(itemKey),
+ set: (key, itemKey, value) =>
+ new PluginKvStore(pluginsDataDir, key, 'storage.json').set(itemKey, value),
+ delete: (key, itemKey) =>
+ new PluginKvStore(pluginsDataDir, key, 'storage.json').delete(itemKey),
+ keys: (key) => new PluginKvStore(pluginsDataDir, key, 'storage.json').keys()
+ },
+ secrets: {
+ get: (key, itemKey) => new PluginSecretsStore(pluginsDataDir, key).get(itemKey),
+ set: (key, itemKey, value) => new PluginSecretsStore(pluginsDataDir, key).set(itemKey, value),
+ delete: (key, itemKey) => new PluginSecretsStore(pluginsDataDir, key).delete(itemKey)
+ },
+ settings: {
+ 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-install-lockfile-store.ts b/src/main/plugins/plugin-install-lockfile-store.ts
new file mode 100644
index 000000000..b83079c1f
--- /dev/null
+++ b/src/main/plugins/plugin-install-lockfile-store.ts
@@ -0,0 +1,83 @@
+import { createReadStream } from 'node:fs'
+import { mkdir } from 'node:fs/promises'
+import { join } from 'node:path'
+import {
+ emptyPluginLockfile,
+ parsePluginLockfile,
+ serializePluginLockfile,
+ type PluginLockfile
+} from '../../shared/plugins/plugin-install-lockfile'
+import { writePluginFileAtomically } from './plugin-atomic-file-write'
+import { recoverPluginLockfile } from './plugin-install-provenance'
+
+export const PLUGIN_LOCKFILE_MAX_BYTES = 5 * 1024 * 1024
+const lockfileAccessChains = new Map>()
+
+export function pluginLockfilePath(pluginsDir: string): string {
+ return join(pluginsDir, 'plugins.lock.json')
+}
+
+async function serializeLockfileAccess(
+ pluginsDir: string,
+ operation: () => Promise
+): Promise {
+ const previous = lockfileAccessChains.get(pluginsDir) ?? Promise.resolve()
+ const run = previous.catch(() => undefined).then(operation)
+ const settled = run.then(
+ () => undefined,
+ () => undefined
+ )
+ lockfileAccessChains.set(pluginsDir, settled)
+ try {
+ return await run
+ } finally {
+ if (lockfileAccessChains.get(pluginsDir) === settled) {
+ lockfileAccessChains.delete(pluginsDir)
+ }
+ }
+}
+
+/** Reads the install index through a byte cap so a corrupt local file cannot
+ * turn every plugin-list refresh into an unbounded main-process allocation. */
+export async function readPluginLockfile(pluginsDir: string): Promise {
+ return serializeLockfileAccess(pluginsDir, () => readPluginLockfileUnserialized(pluginsDir))
+}
+
+async function readPluginLockfileUnserialized(pluginsDir: string): Promise {
+ let lock = emptyPluginLockfile()
+ try {
+ const chunks: Buffer[] = []
+ let totalBytes = 0
+ for await (const chunk of createReadStream(pluginLockfilePath(pluginsDir))) {
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
+ totalBytes += bytes.byteLength
+ if (totalBytes > PLUGIN_LOCKFILE_MAX_BYTES) {
+ throw new Error('plugin lockfile exceeds its size limit')
+ }
+ chunks.push(bytes)
+ }
+ lock = parsePluginLockfile(JSON.parse(Buffer.concat(chunks, totalBytes).toString('utf8')))
+ } catch {
+ // Missing/corrupt global indexes can be reconstructed from current-version provenance.
+ }
+ const recovered = await recoverPluginLockfile(pluginsDir, lock)
+ if (recovered.changed) {
+ await writePluginLockfileUnserialized(pluginsDir, recovered.lock).catch(() => undefined)
+ }
+ return recovered.lock
+}
+
+export async function writePluginLockfile(pluginsDir: string, lock: PluginLockfile): Promise {
+ await serializeLockfileAccess(pluginsDir, () => writePluginLockfileUnserialized(pluginsDir, lock))
+}
+
+async function writePluginLockfileUnserialized(
+ pluginsDir: string,
+ lock: PluginLockfile
+): Promise {
+ await mkdir(pluginsDir, { recursive: true })
+ await writePluginFileAtomically(
+ pluginLockfilePath(pluginsDir),
+ JSON.stringify(serializePluginLockfile(lock), null, 2)
+ )
+}
diff --git a/src/main/plugins/plugin-install-provenance.ts b/src/main/plugins/plugin-install-provenance.ts
new file mode 100644
index 000000000..76f06c318
--- /dev/null
+++ b/src/main/plugins/plugin-install-provenance.ts
@@ -0,0 +1,118 @@
+import { createReadStream } from 'node:fs'
+import { mkdir, readdir, rm } from 'node:fs/promises'
+import { join } from 'node:path'
+import {
+ PLUGIN_CONTENT_HASH_PATTERN,
+ pluginLockEntrySchema,
+ type PluginLockEntry,
+ type PluginLockfile
+} from '../../shared/plugins/plugin-install-lockfile'
+import { isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest'
+import { writePluginFileAtomically } from './plugin-atomic-file-write'
+import { readPluginCurrentPointer } from './plugin-current-pointer'
+
+const PROVENANCE_DIRECTORY = '.install-provenance'
+const PROVENANCE_MAX_BYTES = 64 * 1024
+
+function provenancePath(pluginDir: string, contentHash: string): string {
+ if (!PLUGIN_CONTENT_HASH_PATTERN.test(contentHash)) {
+ throw new Error('invalid plugin content hash')
+ }
+ return join(pluginDir, PROVENANCE_DIRECTORY, `${contentHash}.json`)
+}
+
+/** Prewrites immutable provenance before the executable current pointer moves. */
+export async function writePluginInstallProvenance(
+ pluginDir: string,
+ entry: PluginLockEntry
+): Promise {
+ const parsedEntry = pluginLockEntrySchema.parse(entry)
+ const directory = join(pluginDir, PROVENANCE_DIRECTORY)
+ await mkdir(directory, { recursive: true, mode: 0o700 })
+ await writePluginFileAtomically(
+ provenancePath(pluginDir, parsedEntry.contentHash),
+ JSON.stringify({ version: 1, entry: parsedEntry }, null, 2),
+ { mode: 0o600 }
+ )
+}
+
+export async function readPluginInstallProvenance(
+ pluginDir: string,
+ contentHash: string
+): Promise {
+ try {
+ const chunks: Buffer[] = []
+ let totalBytes = 0
+ for await (const chunk of createReadStream(provenancePath(pluginDir, contentHash))) {
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
+ totalBytes += bytes.byteLength
+ if (totalBytes > PROVENANCE_MAX_BYTES) {
+ return null
+ }
+ chunks.push(bytes)
+ }
+ const raw = JSON.parse(Buffer.concat(chunks, totalBytes).toString('utf8')) as {
+ version?: unknown
+ entry?: unknown
+ }
+ if (raw.version !== 1) {
+ return null
+ }
+ const parsed = pluginLockEntrySchema.safeParse(raw.entry)
+ return parsed.success ? parsed.data : null
+ } catch {
+ return null
+ }
+}
+
+/** Repairs a pointer-new/lock-old interrupted publication from immutable provenance. */
+export async function recoverPluginLockfile(
+ pluginsDir: string,
+ lock: PluginLockfile
+): Promise<{ lock: PluginLockfile; changed: boolean }> {
+ const plugins = { ...lock.plugins }
+ let changed = false
+ const directories = await readdir(pluginsDir, { withFileTypes: true }).catch(() => [])
+ for (const directory of directories) {
+ if (!directory.isDirectory() || !isQualifiedPluginKey(directory.name)) {
+ continue
+ }
+ const pluginDir = join(pluginsDir, directory.name)
+ const contentHash = await readPluginCurrentPointer(pluginDir).catch(() => null)
+ if (!contentHash || !PLUGIN_CONTENT_HASH_PATTERN.test(contentHash)) {
+ continue
+ }
+ const provenance = await readPluginInstallProvenance(pluginDir, contentHash)
+ if (
+ !provenance ||
+ provenance.pluginKey !== directory.name ||
+ provenance.contentHash !== contentHash
+ ) {
+ continue
+ }
+ if (JSON.stringify(plugins[directory.name]) !== JSON.stringify(provenance)) {
+ plugins[directory.name] = provenance
+ changed = true
+ }
+ }
+ return { lock: { version: 1, plugins }, changed }
+}
+
+export async function prunePluginInstallProvenance(
+ pluginDir: string,
+ retained: ReadonlySet
+): Promise {
+ const directory = join(pluginDir, PROVENANCE_DIRECTORY)
+ const entries = await readdir(directory, { withFileTypes: true }).catch(() => [])
+ await Promise.all(
+ entries
+ .filter(
+ (entry) =>
+ entry.isFile() &&
+ entry.name.endsWith('.json') &&
+ PLUGIN_CONTENT_HASH_PATTERN.test(entry.name.slice(0, -'.json'.length)) &&
+ !retained.has(entry.name.slice(0, -'.json'.length))
+ )
+ .map((entry) => rm(join(directory, entry.name), { force: true }))
+ )
+}
diff --git a/src/main/plugins/plugin-install-publication.ts b/src/main/plugins/plugin-install-publication.ts
new file mode 100644
index 000000000..291afa76f
--- /dev/null
+++ b/src/main/plugins/plugin-install-publication.ts
@@ -0,0 +1,94 @@
+import { readdir, rm } from 'node:fs/promises'
+import { join } from 'node:path'
+import {
+ PLUGIN_CONTENT_HASH_PATTERN,
+ upsertPluginLock,
+ type PluginLockEntry
+} from '../../shared/plugins/plugin-install-lockfile'
+import {
+ readPluginCurrentPointer,
+ restorePluginCurrentPointer,
+ writePluginCurrentPointer
+} from './plugin-current-pointer'
+import { readPluginLockfile, writePluginLockfile } from './plugin-install-lockfile-store'
+import {
+ prunePluginInstallProvenance,
+ readPluginInstallProvenance,
+ writePluginInstallProvenance
+} from './plugin-install-provenance'
+
+/** Publishes executable identity and provenance as one recoverable mutation,
+ * then retains only current plus one rollback version. */
+export async function publishPluginInstall(input: {
+ pluginsDir: string
+ pluginDir: string
+ entry: PluginLockEntry
+}): Promise {
+ const previousContentHash = await readPluginCurrentPointer(input.pluginDir)
+ const currentLock = await readPluginLockfile(input.pluginsDir)
+ const provenanceCandidate =
+ previousContentHash === input.entry.contentHash
+ ? await readPluginInstallProvenance(input.pluginDir, input.entry.contentHash)
+ : null
+ const matchesCurrentIdentity = (entry: PluginLockEntry | undefined | null): boolean =>
+ entry?.pluginKey === input.entry.pluginKey && entry.contentHash === input.entry.contentHash
+ const existingProvenance = matchesCurrentIdentity(provenanceCandidate)
+ ? provenanceCandidate
+ : null
+ const legacyLockEntry = currentLock.plugins[input.entry.pluginKey]
+ const legacyCurrentEntry =
+ previousContentHash === input.entry.contentHash && matchesCurrentIdentity(legacyLockEntry)
+ ? legacyLockEntry
+ : null
+ // Provenance is immutable per executable identity. A same-byte reinstall
+ // is a no-op so a failed or interrupted source change cannot be recovered
+ // later as though it had successfully published.
+ const publishedEntry = existingProvenance ?? legacyCurrentEntry ?? input.entry
+ const nextLock = upsertPluginLock(currentLock, publishedEntry)
+ // Why: after a crash between pointer and global-index publication, startup
+ // can reconstruct exact source/commit identity from this immutable record.
+ if (!existingProvenance) {
+ await writePluginInstallProvenance(input.pluginDir, publishedEntry)
+ }
+ await writePluginCurrentPointer(input.pluginDir, input.entry.contentHash)
+ try {
+ await writePluginLockfile(input.pluginsDir, nextLock)
+ } catch (publicationError) {
+ try {
+ await restorePluginCurrentPointer(input.pluginDir, previousContentHash)
+ } catch (rollbackError) {
+ throw new AggregateError(
+ [publicationError, rollbackError],
+ 'plugin install publication and pointer rollback both failed'
+ )
+ }
+ throw publicationError
+ }
+ // Reinstalling B must not collapse an existing A rollback into {B}.
+ if (previousContentHash !== input.entry.contentHash) {
+ await pruneHistoricalVersions(
+ input.pluginDir,
+ new Set(
+ [input.entry.contentHash, previousContentHash].filter(
+ (hash): hash is string =>
+ typeof hash === 'string' && PLUGIN_CONTENT_HASH_PATTERN.test(hash)
+ )
+ )
+ ).catch(() => undefined)
+ }
+}
+
+async function pruneHistoricalVersions(pluginDir: string, retained: ReadonlySet) {
+ const entries = await readdir(pluginDir, { withFileTypes: true })
+ await Promise.all(
+ entries
+ .filter(
+ (entry) =>
+ entry.isDirectory() &&
+ PLUGIN_CONTENT_HASH_PATTERN.test(entry.name) &&
+ !retained.has(entry.name)
+ )
+ .map((entry) => rm(join(pluginDir, entry.name), { recursive: true, force: true }))
+ )
+ await prunePluginInstallProvenance(pluginDir, retained)
+}
diff --git a/src/main/plugins/plugin-install-staging.ts b/src/main/plugins/plugin-install-staging.ts
new file mode 100644
index 000000000..2f0a71fe7
--- /dev/null
+++ b/src/main/plugins/plugin-install-staging.ts
@@ -0,0 +1,253 @@
+import { existsSync } from 'node:fs'
+import { cp, mkdir, rm } from 'node:fs/promises'
+import { join, relative, resolve, sep } from 'node:path'
+import {
+ PLUGIN_MANIFEST_FILENAME,
+ parsePluginManifest,
+ qualifiedPluginKey,
+ satisfiesOrcaEngineRange,
+ type PluginManifest
+} from '../../shared/plugins/plugin-manifest'
+import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint'
+import type {
+ PluginInstallSource,
+ PluginLockEntry
+} from '../../shared/plugins/plugin-install-lockfile'
+import {
+ validateDeclaredPluginArtifacts,
+ validatePluginInstallContent,
+ type PluginArtifactValidationResult
+} from './plugin-artifact-validation'
+import { renamePluginFileWithWindowsRetry } from './plugin-atomic-file-write'
+import { hashPluginTree } from './plugin-content-hash'
+import { publishPluginInstall } from './plugin-install-publication'
+import { readPluginManifestText } from './plugin-manifest-file'
+import { pluginInstallTrustError } from './plugin-install-trust'
+
+export type PluginInstallResult =
+ | {
+ ok: true
+ pluginKey: string
+ version: string
+ contentHash: string
+ consentFingerprint: string
+ resolvedCommit: string | null
+ }
+ | { ok: false; error: string }
+
+export type PluginInstallInspection =
+ | {
+ ok: true
+ manifest: PluginManifest
+ pluginKey: string
+ contentHash: string
+ consentFingerprint: string
+ }
+ | { ok: false; error: string }
+
+async function validatePluginInstallTree(
+ rootDir: string,
+ manifest: PluginManifest
+): Promise {
+ const declared = await validateDeclaredPluginArtifacts(rootDir, manifest)
+ return declared.ok ? validatePluginInstallContent(rootDir, manifest) : declared
+}
+
+async function readInstallManifest(
+ rootDir: string,
+ hostVersion: string
+): Promise<{ ok: true; manifest: PluginManifest } | { ok: false; error: string }> {
+ let raw: unknown
+ try {
+ raw = JSON.parse(await readPluginManifestText(rootDir))
+ } catch (error) {
+ return {
+ ok: false,
+ error: `unreadable ${PLUGIN_MANIFEST_FILENAME}: ${error instanceof Error ? error.message : String(error)}`
+ }
+ }
+ const parsed = parsePluginManifest(raw)
+ if (!parsed.ok) {
+ return { ok: false, error: `invalid manifest: ${parsed.error}` }
+ }
+ if (!satisfiesOrcaEngineRange(hostVersion, parsed.manifest.engines.orca)) {
+ return {
+ ok: false,
+ error: `plugin requires Orca ${parsed.manifest.engines.orca} (this is ${hostVersion})`
+ }
+ }
+ return { ok: true, manifest: parsed.manifest }
+}
+
+/** Validates and hashes a source tree without publishing it. Marketplace
+ * previews use this exact path so the reviewed bytes match install policy. */
+export async function inspectPluginInstallTree(input: {
+ rootDir: string
+ hostVersion: string
+ expectedPluginKey?: string
+}): Promise {
+ const sourceManifest = await readInstallManifest(input.rootDir, input.hostVersion)
+ if (!sourceManifest.ok) {
+ return sourceManifest
+ }
+ const pluginKey = qualifiedPluginKey(sourceManifest.manifest)
+ if (input.expectedPluginKey && pluginKey !== input.expectedPluginKey) {
+ return {
+ ok: false,
+ error: `plugin manifest identity ${pluginKey} does not match marketplace listing ${input.expectedPluginKey}`
+ }
+ }
+ const declaredArtifacts = await validatePluginInstallTree(input.rootDir, sourceManifest.manifest)
+ if (!declaredArtifacts.ok) {
+ return { ok: false, error: `invalid declared artifact: ${declaredArtifacts.error}` }
+ }
+ const treeHash = await hashPluginTree(input.rootDir)
+ if (!treeHash.ok) {
+ return { ok: false, error: treeHash.error }
+ }
+ return {
+ ok: true,
+ manifest: sourceManifest.manifest,
+ pluginKey,
+ contentHash: treeHash.hash,
+ consentFingerprint: fingerprintPluginConsent(sourceManifest.manifest, treeHash.hash)
+ }
+}
+
+/** Installs a validated staging tree into the hash-addressed layout. */
+export async function installStagedPluginTree(input: {
+ pluginsDir: string
+ stagingDir: string
+ hostVersion: string
+ source: PluginInstallSource
+ resolvedCommit: string | null
+ expectedPluginKey?: string
+ /** Trusted bundled bytes may restore an immutable directory damaged on disk. */
+ repairCorruptedVersion?: boolean
+ blockedPluginReason?: (pluginKey: string) => string | null
+}): Promise {
+ const sourceInspection = await inspectPluginInstallTree({
+ rootDir: input.stagingDir,
+ hostVersion: input.hostVersion,
+ ...(input.expectedPluginKey ? { expectedPluginKey: input.expectedPluginKey } : {})
+ })
+ if (!sourceInspection.ok) {
+ return sourceInspection
+ }
+ const trustError = pluginInstallTrustError(sourceInspection.pluginKey, input.source)
+ if (trustError) {
+ return { ok: false, error: trustError }
+ }
+ const blockedReason = input.blockedPluginReason?.(sourceInspection.pluginKey)
+ if (blockedReason) {
+ return { ok: false, error: `plugin is blocked by Orca's safety list: ${blockedReason}` }
+ }
+ let manifest = sourceInspection.manifest
+ const pluginKey = sourceInspection.pluginKey
+ const pluginDir = join(input.pluginsDir, pluginKey)
+ const versionDir = join(pluginDir, sourceInspection.contentHash)
+ if (existsSync(versionDir) && input.repairCorruptedVersion) {
+ const existingHash = await hashPluginTree(versionDir)
+ if (!existingHash.ok || existingHash.hash !== sourceInspection.contentHash) {
+ // Why: bundled resources are release-index verified above, so they can
+ // safely restore a damaged immutable install instead of staying broken.
+ await rm(versionDir, { recursive: true, force: true })
+ }
+ }
+ if (!existsSync(versionDir)) {
+ const stagedVersionDir = `${versionDir}.staging`
+ try {
+ await rm(stagedVersionDir, { recursive: true, force: true })
+ await mkdir(pluginDir, { recursive: true })
+ // Source trees can change while copying. Hashing the destination closes
+ // that race before the immutable directory becomes current.
+ const stagingRoot = resolve(input.stagingDir)
+ await cp(stagingRoot, stagedVersionDir, {
+ recursive: true,
+ verbatimSymlinks: true,
+ // Source-control metadata is not plugin content. Skip it at the copy
+ // boundary so a large local repository cannot bypass install limits.
+ filter: (source) => {
+ const fromRoot = relative(stagingRoot, resolve(source))
+ return fromRoot !== '.git' && !fromRoot.startsWith(`.git${sep}`)
+ }
+ })
+ const copiedHash = await hashPluginTree(stagedVersionDir)
+ if (!copiedHash.ok || copiedHash.hash !== sourceInspection.contentHash) {
+ return {
+ ok: false,
+ error: copiedHash.ok
+ ? 'plugin content changed while it was being copied'
+ : copiedHash.error
+ }
+ }
+ const copiedManifest = await readInstallManifest(stagedVersionDir, input.hostVersion)
+ if (!copiedManifest.ok) {
+ return { ok: false, error: `copied ${copiedManifest.error}` }
+ }
+ if (qualifiedPluginKey(copiedManifest.manifest) !== pluginKey) {
+ return { ok: false, error: 'plugin manifest identity changed while it was being staged' }
+ }
+ manifest = copiedManifest.manifest
+ const copiedArtifacts = await validatePluginInstallTree(stagedVersionDir, manifest)
+ if (!copiedArtifacts.ok) {
+ return { ok: false, error: `copied artifact validation failed: ${copiedArtifacts.error}` }
+ }
+ await renamePluginFileWithWindowsRetry(stagedVersionDir, versionDir)
+ } catch (error) {
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
+ } finally {
+ await rm(stagedVersionDir, { recursive: true, force: true })
+ }
+ } else {
+ // Never repoint at an existing hash directory without proving its bytes;
+ // a previous partial/tampered install must not be revived by reinstall.
+ const existingHash = await hashPluginTree(versionDir)
+ if (!existingHash.ok || existingHash.hash !== sourceInspection.contentHash) {
+ return {
+ ok: false,
+ error: existingHash.ok
+ ? 'existing plugin content failed integrity verification'
+ : existingHash.error
+ }
+ }
+ const existingManifest = await readInstallManifest(versionDir, input.hostVersion)
+ if (!existingManifest.ok) {
+ return { ok: false, error: `installed ${existingManifest.error}` }
+ }
+ if (qualifiedPluginKey(existingManifest.manifest) !== pluginKey) {
+ return { ok: false, error: 'installed plugin manifest identity does not match its directory' }
+ }
+ manifest = existingManifest.manifest
+ const existingArtifacts = await validatePluginInstallTree(versionDir, manifest)
+ if (!existingArtifacts.ok) {
+ return {
+ ok: false,
+ error: `installed artifact validation failed: ${existingArtifacts.error}`
+ }
+ }
+ }
+ const consentFingerprint = fingerprintPluginConsent(manifest, sourceInspection.contentHash)
+ const entry: PluginLockEntry = {
+ pluginKey,
+ version: manifest.version,
+ source: input.source,
+ resolvedCommit: input.resolvedCommit,
+ contentHash: sourceInspection.contentHash,
+ consentFingerprint,
+ installedAt: Date.now()
+ }
+ try {
+ await publishPluginInstall({ pluginsDir: input.pluginsDir, pluginDir, entry })
+ } catch (error) {
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
+ }
+ return {
+ ok: true,
+ pluginKey,
+ version: manifest.version,
+ contentHash: sourceInspection.contentHash,
+ consentFingerprint,
+ resolvedCommit: input.resolvedCommit
+ }
+}
diff --git a/src/main/plugins/plugin-install-trust.test.ts b/src/main/plugins/plugin-install-trust.test.ts
new file mode 100644
index 000000000..8f1c5e302
--- /dev/null
+++ b/src/main/plugins/plugin-install-trust.test.ts
@@ -0,0 +1,115 @@
+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 {
+ installBundledPlugin,
+ installPluginFromLocalPath,
+ readPluginLockfile
+} from './plugin-install'
+import { pluginInstallTrustError } from './plugin-install-trust'
+
+const roots: string[] = []
+
+async function tempRoot(prefix: string): Promise {
+ const root = await mkdtemp(join(tmpdir(), prefix))
+ roots.push(root)
+ return root
+}
+
+async function writePlugin(root: string, publisher: string, id: string): Promise {
+ await writeFile(
+ join(root, 'orca-plugin.json'),
+ JSON.stringify({
+ manifestVersion: 1,
+ id,
+ publisher,
+ name: 'Plugin',
+ version: '1.0.0',
+ engines: { orca: '>=1.0.0' },
+ pluginApi: 1,
+ capabilities: []
+ })
+ )
+}
+
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+describe('plugin install trust', () => {
+ it.each<[PluginInstallSource, string | null]>([
+ [
+ {
+ kind: 'git',
+ url: 'https://github.com/attacker/orca-secrets.git',
+ ref: 'main'
+ },
+ 'reserved plugin identity community.orca-secrets must resolve to the stablyai organization'
+ ],
+ [
+ {
+ 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)
+ })
+
+ it('rejects locally installed reserved identities before publication', async () => {
+ const sourcePath = await tempRoot('orca-reserved-plugin-')
+ const pluginsDir = await tempRoot('orca-plugin-installs-')
+ await writePlugin(sourcePath, 'stablyai', 'orca-skills')
+
+ await expect(
+ 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'
+ })
+ await expect(readPluginLockfile(pluginsDir)).resolves.toEqual({ version: 1, plugins: {} })
+ })
+
+ it('allows the app-bundled path only for the complete official identity', async () => {
+ const sourcePath = await tempRoot('orca-bundled-plugin-')
+ const pluginsDir = await tempRoot('orca-plugin-installs-')
+ await writePlugin(sourcePath, 'stablyai', 'orca-skills')
+
+ const result = await installBundledPlugin({
+ pluginsDir,
+ sourcePath,
+ hostVersion: '1.4.0',
+ expectedPluginKey: 'stablyai.orca-skills'
+ })
+
+ expect(result).toMatchObject({ ok: true, pluginKey: 'stablyai.orca-skills' })
+ const lock = await readPluginLockfile(pluginsDir)
+ expect(lock.plugins['stablyai.orca-skills']?.source).toEqual({
+ kind: 'bundled',
+ bundleId: 'stablyai.orca-skills'
+ })
+ })
+
+ it('blocks a killed plugin even when the caller bypasses marketplace UI', async () => {
+ const sourcePath = await tempRoot('orca-killed-plugin-')
+ const pluginsDir = await tempRoot('orca-plugin-installs-')
+ await writePlugin(sourcePath, 'community', 'unsafe')
+
+ await expect(
+ installPluginFromLocalPath({
+ pluginsDir,
+ sourcePath,
+ hostVersion: '1.4.0',
+ blockedPluginReason: (pluginKey) =>
+ pluginKey === 'community.unsafe' ? 'Security incident' : null
+ })
+ ).resolves.toEqual({
+ ok: false,
+ error: "plugin is blocked by Orca's safety list: Security incident"
+ })
+ })
+})
diff --git a/src/main/plugins/plugin-install-trust.ts b/src/main/plugins/plugin-install-trust.ts
new file mode 100644
index 000000000..33b46614a
--- /dev/null
+++ b/src/main/plugins/plugin-install-trust.ts
@@ -0,0 +1,27 @@
+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-install.test.ts b/src/main/plugins/plugin-install.test.ts
new file mode 100644
index 000000000..ab9591eba
--- /dev/null
+++ b/src/main/plugins/plugin-install.test.ts
@@ -0,0 +1,531 @@
+import { execFile } from 'node:child_process'
+import {
+ mkdtemp,
+ mkdir,
+ readFile,
+ readdir,
+ rm,
+ symlink,
+ truncate,
+ writeFile
+} from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { promisify } from 'node:util'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import {
+ installPluginFromLocalPath,
+ installPluginFromGit,
+ PLUGIN_LOCKFILE_MAX_BYTES,
+ readPluginLockfile,
+ removeInstalledPlugin
+} from './plugin-install'
+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'
+
+const roots: string[] = []
+const execFileAsync = promisify(execFile)
+
+async function tempRoot(prefix: string): Promise {
+ const root = await mkdtemp(join(tmpdir(), prefix))
+ roots.push(root)
+ return root
+}
+
+async function writePluginSource(
+ root: string,
+ options: { id?: string; panelEntry?: string; includePanel?: boolean } = {}
+): Promise {
+ const panelEntry = options.panelEntry ?? 'panel.html'
+ await writeFile(
+ join(root, 'orca-plugin.json'),
+ JSON.stringify({
+ manifestVersion: 1,
+ id: options.id ?? 'demo',
+ publisher: 'orca-samples',
+ name: 'Demo',
+ version: '1.0.0',
+ engines: { orca: '>=1.0.0' },
+ pluginApi: 1,
+ contributes: {
+ panels: [{ id: 'panel', title: 'Panel', entry: panelEntry }],
+ commands: [],
+ events: []
+ },
+ capabilities: []
+ })
+ )
+ if (options.includePanel !== false) {
+ await writeFile(join(root, panelEntry), 'Panel
')
+ }
+}
+
+afterEach(async () => {
+ vi.restoreAllMocks()
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+describe('installPluginFromLocalPath', () => {
+ it('refuses to allocate an oversized install lockfile', async () => {
+ const pluginsDir = await tempRoot('orca-plugin-installs-')
+ const lockPath = join(pluginsDir, 'plugins.lock.json')
+ await writeFile(lockPath, '')
+ await truncate(lockPath, PLUGIN_LOCKFILE_MAX_BYTES + 1)
+
+ await expect(readPluginLockfile(pluginsDir)).resolves.toEqual({ version: 1, plugins: {} })
+ })
+
+ it('verifies copied content and writes a rollback-compatible consent field', async () => {
+ const sourcePath = await tempRoot('orca-plugin-source-')
+ const pluginsDir = await tempRoot('orca-plugin-installs-')
+ await writePluginSource(sourcePath)
+
+ const result = await installPluginFromLocalPath({
+ pluginsDir,
+ sourcePath,
+ hostVersion: '1.4.0'
+ })
+
+ expect(result.ok).toBe(true)
+ if (!result.ok) {
+ return
+ }
+ await expect(
+ readFile(join(pluginsDir, result.pluginKey, result.contentHash, 'panel.html'), 'utf8')
+ ).resolves.toBe('Panel
')
+ const lock = JSON.parse(await readFile(join(pluginsDir, 'plugins.lock.json'), 'utf8')) as {
+ plugins: Record>
+ }
+ expect(lock.plugins[result.pluginKey]).toMatchObject({
+ capabilityHash: result.consentFingerprint
+ })
+ expect(lock.plugins[result.pluginKey]).not.toHaveProperty('consentFingerprint')
+ })
+
+ it('publishes metadata from the copied immutable manifest', async () => {
+ const sourcePath = await tempRoot('orca-plugin-source-')
+ const pluginsDir = await tempRoot('orca-plugin-installs-')
+ await writePluginSource(sourcePath)
+ const firstManifest = await readFile(join(sourcePath, 'orca-plugin.json'), 'utf8')
+ const changedManifest = {
+ ...(JSON.parse(firstManifest) as Record),
+ name: 'Changed During Staging',
+ version: '2.0.0'
+ }
+ await writeFile(join(sourcePath, 'orca-plugin.json'), JSON.stringify(changedManifest))
+ const manifestRead = vi
+ .spyOn(manifestFile, 'readPluginManifestText')
+ .mockResolvedValueOnce(firstManifest)
+
+ const result = await installStagedPluginTree({
+ pluginsDir,
+ stagingDir: sourcePath,
+ hostVersion: '1.4.0',
+ source: { kind: 'local-path', path: sourcePath },
+ resolvedCommit: null
+ })
+
+ expect(manifestRead).toHaveBeenCalledTimes(2)
+ expect(result).toMatchObject({ ok: true, version: '2.0.0' })
+ if (result.ok) {
+ const lock = await readPluginLockfile(pluginsDir)
+ expect(lock.plugins[result.pluginKey]?.version).toBe('2.0.0')
+ }
+ })
+
+ it('skips root Git metadata before copying while still enforcing plugin limits', async () => {
+ const sourcePath = await tempRoot('orca-plugin-source-')
+ const pluginsDir = await tempRoot('orca-plugin-installs-')
+ await writePluginSource(sourcePath)
+ const gitDir = join(sourcePath, '.git')
+ await mkdir(gitDir)
+ await writeFile(join(gitDir, 'large.pack'), '')
+ await truncate(join(gitDir, 'large.pack'), 50 * 1024 * 1024 + 1)
+
+ const result = await installPluginFromLocalPath({
+ pluginsDir,
+ sourcePath,
+ hostVersion: '1.4.0'
+ })
+
+ expect(result).toMatchObject({ ok: true })
+ if (result.ok) {
+ await expect(
+ readFile(join(pluginsDir, result.pluginKey, result.contentHash, '.git', 'large.pack'))
+ ).rejects.toMatchObject({ code: 'ENOENT' })
+ }
+ })
+
+ it('restores the previous current pointer when lockfile publication fails', async () => {
+ const sourcePath = await tempRoot('orca-plugin-source-')
+ const pluginsDir = await tempRoot('orca-plugin-installs-')
+ await writePluginSource(sourcePath)
+ const first = await installPluginFromLocalPath({ pluginsDir, sourcePath, hostVersion: '1.4.0' })
+ expect(first.ok).toBe(true)
+ if (!first.ok) {
+ return
+ }
+ await writeFile(join(sourcePath, 'panel.html'), 'Updated
')
+ await rm(join(pluginsDir, 'plugins.lock.json'))
+ await mkdir(join(pluginsDir, 'plugins.lock.json'))
+
+ const failed = await installPluginFromLocalPath({
+ pluginsDir,
+ sourcePath,
+ hostVersion: '1.4.0'
+ })
+
+ expect(failed).toMatchObject({ ok: false })
+ await expect(readPluginCurrentPointer(join(pluginsDir, first.pluginKey))).resolves.toBe(
+ first.contentHash
+ )
+ })
+
+ it('does not replace provenance when a same-content reinstall fails to publish', async () => {
+ const firstSource = await tempRoot('orca-plugin-source-')
+ const secondSource = await tempRoot('orca-plugin-source-')
+ const pluginsDir = await tempRoot('orca-plugin-installs-')
+ await writePluginSource(firstSource)
+ await writePluginSource(secondSource)
+ const first = await installPluginFromLocalPath({
+ pluginsDir,
+ sourcePath: firstSource,
+ hostVersion: '1.4.0'
+ })
+ expect(first.ok).toBe(true)
+ if (!first.ok) {
+ return
+ }
+ const acceptedLock = await readFile(join(pluginsDir, 'plugins.lock.json'), 'utf8')
+ await rm(join(pluginsDir, 'plugins.lock.json'))
+ await mkdir(join(pluginsDir, 'plugins.lock.json'))
+
+ const failed = await installPluginFromLocalPath({
+ pluginsDir,
+ sourcePath: secondSource,
+ hostVersion: '1.4.0'
+ })
+ expect(failed).toMatchObject({ ok: false })
+
+ await rm(join(pluginsDir, 'plugins.lock.json'), { recursive: true })
+ await writeFile(join(pluginsDir, 'plugins.lock.json'), acceptedLock)
+ const recovered = await readPluginLockfile(pluginsDir)
+ expect(recovered.plugins[first.pluginKey]?.source).toEqual({
+ kind: 'local-path',
+ path: firstSource
+ })
+ })
+
+ it('preserves legacy lock provenance during a same-content reinstall', async () => {
+ const firstSource = await tempRoot('orca-plugin-source-')
+ const secondSource = await tempRoot('orca-plugin-source-')
+ const pluginsDir = await tempRoot('orca-plugin-installs-')
+ await writePluginSource(firstSource)
+ await writePluginSource(secondSource)
+ const first = await installPluginFromLocalPath({
+ pluginsDir,
+ sourcePath: firstSource,
+ hostVersion: '1.4.0'
+ })
+ expect(first.ok).toBe(true)
+ if (!first.ok) {
+ return
+ }
+ await rm(join(pluginsDir, first.pluginKey, '.install-provenance', `${first.contentHash}.json`))
+ const reinstalled = await installPluginFromLocalPath({
+ pluginsDir,
+ sourcePath: secondSource,
+ hostVersion: '1.4.0'
+ })
+ expect(reinstalled).toMatchObject({ ok: true })
+
+ // Recovery from the newly backfilled provenance must retain the accepted
+ // legacy source rather than the same-byte reinstall's alternate source.
+ await rm(join(pluginsDir, 'plugins.lock.json'))
+ const recovered = await readPluginLockfile(pluginsDir)
+ expect(recovered.plugins[first.pluginKey]?.source).toEqual({
+ kind: 'local-path',
+ path: firstSource
+ })
+ })
+
+ it('retains only the current and immediately previous content versions', async () => {
+ const sourcePath = await tempRoot('orca-plugin-source-')
+ const pluginsDir = await tempRoot('orca-plugin-installs-')
+ await writePluginSource(sourcePath)
+ const hashes: string[] = []
+ for (const content of ['one', 'two', 'three']) {
+ await writeFile(join(sourcePath, 'panel.html'), `${content}
`)
+ const result = await installPluginFromLocalPath({
+ pluginsDir,
+ sourcePath,
+ hostVersion: '1.4.0'
+ })
+ expect(result.ok).toBe(true)
+ if (result.ok) {
+ hashes.push(result.contentHash)
+ }
+ }
+
+ const versionDirs = (
+ await readdir(join(pluginsDir, 'orca-samples.demo'), {
+ withFileTypes: true
+ })
+ )
+ .filter((entry) => entry.isDirectory() && /^[0-9a-f]{64}$/.test(entry.name))
+ .map((entry) => entry.name)
+ .sort()
+ expect(versionDirs).toEqual(hashes.slice(-2).sort())
+ })
+
+ it('keeps the rollback version when current content is reinstalled', async () => {
+ const sourcePath = await tempRoot('orca-plugin-source-')
+ const pluginsDir = await tempRoot('orca-plugin-installs-')
+ await writePluginSource(sourcePath)
+ const hashes: string[] = []
+ for (const content of ['one', 'two', 'two']) {
+ await writeFile(join(sourcePath, 'panel.html'), `${content}
`)
+ const result = await installPluginFromLocalPath({
+ pluginsDir,
+ sourcePath,
+ hostVersion: '1.4.0'
+ })
+ expect(result.ok).toBe(true)
+ if (result.ok) {
+ hashes.push(result.contentHash)
+ }
+ }
+
+ const versionDirs = (
+ await readdir(join(pluginsDir, 'orca-samples.demo'), { withFileTypes: true })
+ )
+ .filter((entry) => entry.isDirectory() && /^[0-9a-f]{64}$/.test(entry.name))
+ .map((entry) => entry.name)
+ .sort()
+ expect(versionDirs).toEqual([...new Set(hashes)].sort())
+ })
+
+ it('repairs a pointer-new lock-old interrupted publication from provenance', async () => {
+ const sourcePath = await tempRoot('orca-plugin-source-')
+ const pluginsDir = await tempRoot('orca-plugin-installs-')
+ await writePluginSource(sourcePath)
+ const first = await installPluginFromLocalPath({ pluginsDir, sourcePath, hostVersion: '1.4.0' })
+ expect(first.ok).toBe(true)
+ const oldLock = await readFile(join(pluginsDir, 'plugins.lock.json'), 'utf8')
+ await writeFile(join(sourcePath, 'panel.html'), 'new current
')
+ const second = await installPluginFromLocalPath({
+ pluginsDir,
+ sourcePath,
+ hostVersion: '1.4.0'
+ })
+ expect(second.ok).toBe(true)
+ if (!first.ok || !second.ok) {
+ return
+ }
+
+ await writeFile(join(pluginsDir, 'plugins.lock.json'), oldLock)
+ const repaired = await readPluginLockfile(pluginsDir)
+
+ expect(repaired.plugins[second.pluginKey]?.contentHash).toBe(second.contentHash)
+ const persisted = JSON.parse(await readFile(join(pluginsDir, 'plugins.lock.json'), 'utf8')) as {
+ plugins: Record
+ }
+ expect(persisted.plugins[second.pluginKey]?.contentHash).toBe(second.contentHash)
+ })
+
+ it('rejects a manifest whose declared panel artifact is missing', async () => {
+ const sourcePath = await tempRoot('orca-plugin-source-')
+ const pluginsDir = await tempRoot('orca-plugin-installs-')
+ await writePluginSource(sourcePath, { includePanel: false })
+
+ const result = await installPluginFromLocalPath({
+ pluginsDir,
+ sourcePath,
+ hostVersion: '1.4.0'
+ })
+
+ expect(result).toMatchObject({ ok: false })
+ })
+
+ it('rejects an oversized manifest without reading an unbounded JSON payload', async () => {
+ const sourcePath = await tempRoot('orca-plugin-source-')
+ const pluginsDir = await tempRoot('orca-plugin-installs-')
+ const manifestPath = join(sourcePath, 'orca-plugin.json')
+ await writeFile(manifestPath, '')
+ await truncate(manifestPath, PLUGIN_MANIFEST_MAX_BYTES + 1)
+
+ const result = await installPluginFromLocalPath({
+ pluginsDir,
+ sourcePath,
+ hostVersion: '1.4.0'
+ })
+
+ expect(result).toMatchObject({ ok: false, error: expect.stringContaining('exceeds') })
+ })
+
+ it('serializes concurrent installs so lockfile entries are not lost', async () => {
+ const firstSource = await tempRoot('orca-plugin-source-')
+ const secondSource = await tempRoot('orca-plugin-source-')
+ const pluginsDir = await tempRoot('orca-plugin-installs-')
+ await writePluginSource(firstSource, { id: 'first' })
+ await writePluginSource(secondSource, { id: 'second' })
+
+ const results = await Promise.all([
+ installPluginFromLocalPath({ pluginsDir, sourcePath: firstSource, hostVersion: '1.4.0' }),
+ installPluginFromLocalPath({ pluginsDir, sourcePath: secondSource, hostVersion: '1.4.0' })
+ ])
+ expect(results.every((result) => result.ok)).toBe(true)
+ 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'])
+ })
+
+ it('serializes concurrent lockfile publications without temporary-file collisions', async () => {
+ const pluginsDir = await tempRoot('orca-plugin-installs-')
+ const lock = { version: 1 as const, plugins: {} }
+
+ await expect(
+ Promise.all([
+ writePluginLockfile(pluginsDir, lock),
+ writePluginLockfile(pluginsDir, lock),
+ writePluginLockfile(pluginsDir, lock)
+ ])
+ ).resolves.toHaveLength(3)
+ await expect(readPluginLockfile(pluginsDir)).resolves.toEqual(lock)
+ })
+
+ it('refuses to repoint at a tampered existing content directory', async () => {
+ const sourcePath = await tempRoot('orca-plugin-source-')
+ const pluginsDir = await tempRoot('orca-plugin-installs-')
+ await writePluginSource(sourcePath)
+ const first = await installPluginFromLocalPath({
+ pluginsDir,
+ sourcePath,
+ hostVersion: '1.4.0'
+ })
+ expect(first.ok).toBe(true)
+ if (!first.ok) {
+ return
+ }
+ await writeFile(
+ join(pluginsDir, first.pluginKey, first.contentHash, 'panel.html'),
+ 'Tampered
'
+ )
+
+ const second = await installPluginFromLocalPath({
+ pluginsDir,
+ sourcePath,
+ hostVersion: '1.4.0'
+ })
+
+ expect(second).toMatchObject({
+ ok: false,
+ error: expect.stringContaining('integrity verification')
+ })
+ })
+})
+
+describe('installPluginFromGit', () => {
+ it('uses system Git, resolves the requested ref, and installs its exact bytes', async () => {
+ const sourcePath = await tempRoot('orca-plugin-git-source-')
+ const pluginsDir = await tempRoot('orca-plugin-installs-')
+ await writePluginSource(sourcePath)
+ await execFileAsync('git', ['init', '--quiet'], { cwd: sourcePath })
+ await execFileAsync('git', ['config', 'user.email', 'plugins@example.invalid'], {
+ cwd: sourcePath
+ })
+ await execFileAsync('git', ['config', 'user.name', 'Plugin Test'], { cwd: sourcePath })
+ await execFileAsync('git', ['add', '.'], { cwd: sourcePath })
+ await execFileAsync('git', ['commit', '--quiet', '-m', 'fixture'], { cwd: sourcePath })
+ await execFileAsync('git', ['tag', 'v1.0.0'], { cwd: sourcePath })
+ const { stdout: commitStdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], {
+ cwd: sourcePath
+ })
+
+ const configKeys = ['GIT_CONFIG_COUNT', 'GIT_CONFIG_KEY_0', 'GIT_CONFIG_VALUE_0'] as const
+ const previous = Object.fromEntries(configKeys.map((key) => [key, process.env[key]]))
+ process.env.GIT_CONFIG_COUNT = '1'
+ process.env.GIT_CONFIG_KEY_0 = `url.${pathToFileURL(sourcePath).href}.insteadOf`
+ process.env.GIT_CONFIG_VALUE_0 = 'https://plugin.test/demo.git'
+ try {
+ const result = await installPluginFromGit({
+ pluginsDir,
+ url: 'https://plugin.test/demo.git',
+ ref: 'v1.0.0',
+ hostVersion: '1.4.0'
+ })
+
+ expect(result).toMatchObject({ ok: true, resolvedCommit: commitStdout.trim() })
+ if (result.ok) {
+ await expect(
+ readFile(join(pluginsDir, result.pluginKey, result.contentHash, 'panel.html'), 'utf8')
+ ).resolves.toBe('Panel
')
+ }
+ } finally {
+ for (const key of configKeys) {
+ const value = previous[key]
+ if (value === undefined) {
+ delete process.env[key]
+ } else {
+ process.env[key] = value
+ }
+ }
+ }
+ })
+})
+
+describe('removeInstalledPlugin', () => {
+ it('rejects an unqualified or traversing key before removing anything', async () => {
+ const pluginsDir = await tempRoot('orca-plugin-installs-')
+ const pluginsDataDir = await tempRoot('orca-plugin-data-')
+ const outside = join(await tempRoot('orca-plugin-outside-'), 'keep.txt')
+ await writeFile(outside, 'keep')
+
+ await expect(
+ removeInstalledPlugin({ pluginsDir, pluginsDataDir, pluginKey: '../outside' })
+ ).rejects.toThrow('invalid qualified plugin key')
+ await expect(readFile(outside, 'utf8')).resolves.toBe('keep')
+ })
+
+ it('rejects a resolved uninstall target outside its root', async () => {
+ const pluginsDir = await tempRoot('orca-plugin-installs-')
+ const pluginsDataDir = await tempRoot('orca-plugin-data-')
+ const outside = await tempRoot('orca-plugin-outside-')
+ const marker = join(outside, 'keep.txt')
+ await writeFile(marker, 'keep')
+ await symlink(
+ outside,
+ join(pluginsDir, 'orca-samples.demo'),
+ process.platform === 'win32' ? 'junction' : 'dir'
+ )
+
+ await expect(
+ removeInstalledPlugin({
+ pluginsDir,
+ pluginsDataDir,
+ pluginKey: 'orca-samples.demo'
+ })
+ ).rejects.toThrow('outside')
+ await expect(readFile(marker, 'utf8')).resolves.toBe('keep')
+ })
+
+ 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'
+ await mkdir(join(pluginsDir, key))
+ await mkdir(join(pluginsDataDir, key))
+ await writeFile(join(pluginsDir, key, 'content'), 'installed')
+ await writeFile(join(pluginsDataDir, key, 'storage.json'), '{}')
+
+ await removeInstalledPlugin({ pluginsDir, pluginsDataDir, pluginKey: key })
+
+ await expect(readFile(join(pluginsDir, key, 'content'))).rejects.toThrow()
+ await expect(readFile(join(pluginsDataDir, key, 'storage.json'))).rejects.toThrow()
+ })
+})
diff --git a/src/main/plugins/plugin-install.ts b/src/main/plugins/plugin-install.ts
new file mode 100644
index 000000000..df3b6b4ed
--- /dev/null
+++ b/src/main/plugins/plugin-install.ts
@@ -0,0 +1,319 @@
+import { mkdtemp, readdir, realpath, 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 {
+ PLUGIN_MANIFEST_FILENAME,
+ isQualifiedPluginKey
+} from '../../shared/plugins/plugin-manifest'
+import {
+ isAllowedPluginGitUrl,
+ PLUGIN_COMMIT_PATTERN,
+ PLUGIN_CONTENT_HASH_PATTERN,
+ pluginInstallSourceSchema,
+ removePluginLock
+} from '../../shared/plugins/plugin-install-lockfile'
+import { readPluginLockfile, writePluginLockfile } from './plugin-install-lockfile-store'
+import {
+ inspectPluginInstallTree,
+ installStagedPluginTree,
+ type PluginInstallResult
+} from './plugin-install-staging'
+import { checkoutPluginGitSource } from './plugin-git-repository'
+import { readPluginCurrentPointer } from './plugin-current-pointer'
+import { readPluginInstallProvenance } from './plugin-install-provenance'
+import { publishPluginInstall } from './plugin-install-publication'
+
+export type { PluginInstallResult } from './plugin-install-staging'
+
+export {
+ PLUGIN_LOCKFILE_MAX_BYTES,
+ pluginLockfilePath,
+ readPluginLockfile
+} from './plugin-install-lockfile-store'
+
+/**
+ * Plugin installer, v0 sources: local path + git URL `#ref`. Git operations
+ * shell out to SYSTEM git (execFile, argv arrays — never a shell string, and
+ * never a vendored checkout: private repos must work with the user's
+ * existing credential helpers and SSH remotes). No script execution during
+ * install, ever — the installer copies files, nothing more.
+ *
+ * Installs land in immutable hash-addressed dirs behind an atomic pointer
+ * swap; the previous version dir is kept for one-step 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
+ hostVersion: string
+ blockedPluginReason?: (pluginKey: string) => string | null
+}): Promise {
+ return serializePluginMutation(input.pluginsDir, async () => {
+ if (!existsSync(join(input.sourcePath, PLUGIN_MANIFEST_FILENAME))) {
+ return { ok: false, error: `no ${PLUGIN_MANIFEST_FILENAME} found in ${input.sourcePath}` }
+ }
+ return installStagedPluginTree({
+ pluginsDir: input.pluginsDir,
+ stagingDir: input.sourcePath,
+ hostVersion: input.hostVersion,
+ source: { kind: 'local-path', path: input.sourcePath },
+ resolvedCommit: null,
+ blockedPluginReason: input.blockedPluginReason
+ })
+ })
+}
+
+export async function installBundledPlugin(input: {
+ pluginsDir: string
+ sourcePath: string
+ hostVersion: string
+ expectedPluginKey: string
+ blockedPluginReason?: (pluginKey: string) => string | null
+}): Promise {
+ return serializePluginMutation(input.pluginsDir, () =>
+ installStagedPluginTree({
+ pluginsDir: input.pluginsDir,
+ stagingDir: input.sourcePath,
+ hostVersion: input.hostVersion,
+ source: { kind: 'bundled', bundleId: input.expectedPluginKey },
+ resolvedCommit: null,
+ expectedPluginKey: input.expectedPluginKey,
+ repairCorruptedVersion: true,
+ blockedPluginReason: input.blockedPluginReason
+ })
+ )
+}
+
+export async function installPluginFromGit(input: {
+ pluginsDir: string
+ url: string
+ /** `#ref` suffix: branch, tag, or full commit SHA. Empty = default branch. */
+ ref: string
+ hostVersion: string
+ blockedPluginReason?: (pluginKey: string) => string | null
+}): Promise {
+ if (!isAllowedPluginGitUrl(input.url)) {
+ return { ok: false, error: 'plugin Git URL must use HTTPS or SSH' }
+ }
+ return serializePluginMutation(input.pluginsDir, async () => {
+ const stagingDir = await mkdtemp(join(tmpdir(), 'orca-plugin-install-'))
+ try {
+ const ref = input.ref.trim()
+ const resolvedCommit = await checkoutPluginGitSource({
+ url: input.url,
+ ref,
+ destination: stagingDir,
+ workingDirectory: tmpdir()
+ })
+ return await installStagedPluginTree({
+ pluginsDir: input.pluginsDir,
+ stagingDir,
+ hostVersion: input.hostVersion,
+ source: { kind: 'git', url: input.url, ref },
+ resolvedCommit,
+ blockedPluginReason: input.blockedPluginReason
+ })
+ } catch (error) {
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
+ } finally {
+ await rm(stagingDir, { recursive: true, force: true })
+ }
+ })
+}
+
+export async function installPluginFromMarketplace(input: {
+ pluginsDir: string
+ hostVersion: string
+ expectedPluginKey: string
+ expectedResolvedCommit: string
+ marketplace: { url: string; ref: string; resolvedCommit: string }
+ plugin: { url: string; ref: string }
+ blockedPluginReason?: (pluginKey: string) => string | null
+}): Promise {
+ const source = pluginInstallSourceSchema.parse({
+ kind: 'marketplace',
+ marketplace: input.marketplace,
+ plugin: input.plugin
+ })
+ if (!isQualifiedPluginKey(input.expectedPluginKey)) {
+ return { ok: false, error: 'invalid marketplace plugin identity' }
+ }
+ if (!PLUGIN_COMMIT_PATTERN.test(input.expectedResolvedCommit)) {
+ return { ok: false, error: 'invalid previewed plugin commit' }
+ }
+ return serializePluginMutation(input.pluginsDir, async () => {
+ const stagingDir = await mkdtemp(join(tmpdir(), 'orca-plugin-marketplace-install-'))
+ try {
+ const resolvedCommit = await checkoutPluginGitSource({
+ url: input.plugin.url,
+ ref: input.plugin.ref,
+ destination: stagingDir,
+ workingDirectory: tmpdir()
+ })
+ if (resolvedCommit !== input.expectedResolvedCommit) {
+ return { ok: false, error: 'plugin source changed after preview; review the update again' }
+ }
+ return await installStagedPluginTree({
+ pluginsDir: input.pluginsDir,
+ stagingDir,
+ hostVersion: input.hostVersion,
+ source,
+ resolvedCommit,
+ expectedPluginKey: input.expectedPluginKey,
+ blockedPluginReason: input.blockedPluginReason
+ })
+ } catch (error) {
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
+ } finally {
+ await rm(stagingDir, { recursive: true, force: true })
+ }
+ })
+}
+
+/** Restores the single retained immutable predecessor. The old consent
+ * fingerprint becomes current again, so enablement still fails closed until
+ * the user has approved those exact bytes. */
+export async function rollbackInstalledPlugin(input: {
+ pluginsDir: string
+ pluginKey: string
+ hostVersion: string
+ blockedPluginReason?: (pluginKey: string) => string | null
+}): Promise {
+ if (!isQualifiedPluginKey(input.pluginKey)) {
+ return { ok: false, error: 'invalid qualified plugin key' }
+ }
+ const blockedReason = input.blockedPluginReason?.(input.pluginKey)
+ if (blockedReason) {
+ return { ok: false, error: `plugin is blocked by Orca's safety list: ${blockedReason}` }
+ }
+ 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 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 provenance = await readPluginInstallProvenance(pluginDir, contentHash)
+ if (
+ !provenance ||
+ provenance.pluginKey !== input.pluginKey ||
+ provenance.contentHash !== contentHash
+ ) {
+ return { ok: false, error: 'rollback version has no valid install provenance' }
+ }
+ const inspection = await inspectPluginInstallTree({
+ rootDir: join(pluginDir, contentHash),
+ hostVersion: input.hostVersion,
+ expectedPluginKey: input.pluginKey
+ })
+ if (!inspection.ok || inspection.contentHash !== contentHash) {
+ return {
+ ok: false,
+ error: inspection.ok ? 'rollback version failed integrity verification' : inspection.error
+ }
+ }
+ try {
+ await publishPluginInstall({ pluginsDir: input.pluginsDir, pluginDir, entry: provenance })
+ } catch (error) {
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
+ }
+ return {
+ ok: true,
+ pluginKey: input.pluginKey,
+ version: inspection.manifest.version,
+ contentHash,
+ consentFingerprint: provenance.consentFingerprint,
+ resolvedCommit: provenance.resolvedCommit
+ }
+ })
+}
+
+/** Removes the install dir, the plugin's data dir, and the lock entry. */
+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}`)
+ }
+ 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-instructional-content-integrity.ts b/src/main/plugins/plugin-instructional-content-integrity.ts
new file mode 100644
index 000000000..fe2c9a3dc
--- /dev/null
+++ b/src/main/plugins/plugin-instructional-content-integrity.ts
@@ -0,0 +1,30 @@
+import { hasInstructionalPluginContributions } from '../../shared/plugins/plugin-consent-fingerprint'
+import { hashPluginTree } from './plugin-content-hash'
+import type { ValidDiscoveredPlugin } from './plugin-discovery'
+
+/** Instructional 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 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}`
+ )
+ }
+ const matches =
+ actual.hash === plugin.consentContentHash ||
+ (plugin.consentContentHash.length === 32 && actual.hash.startsWith(plugin.consentContentHash))
+ if (!matches) {
+ throw new Error(
+ `plugin ${plugin.pluginKey} instructional content changed since it was reviewed`
+ )
+ }
+}
diff --git a/src/main/plugins/plugin-kill-list-service.test.ts b/src/main/plugins/plugin-kill-list-service.test.ts
new file mode 100644
index 000000000..3da97e085
--- /dev/null
+++ b/src/main/plugins/plugin-kill-list-service.test.ts
@@ -0,0 +1,111 @@
+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 { PluginKillListStore } from './plugin-kill-list-store'
+
+const roots: string[] = []
+
+async function tempRoot(): Promise {
+ const root = await mkdtemp(join(tmpdir(), 'orca-plugin-kill-list-'))
+ roots.push(root)
+ return root
+}
+
+function killList(date = '2026-07-12T20:00:00Z'): PluginKillList {
+ return {
+ version: 1,
+ generatedAt: date,
+ plugins: [{ pluginKey: 'community.unsafe', reason: 'Malware advisory' }]
+ }
+}
+
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+describe('PluginKillListService', () => {
+ it('loads cached revocations before any network refresh', async () => {
+ const root = await tempRoot()
+ const first = new PluginKillListService({
+ pluginsDataDir: root,
+ fetcher: async () => killList()
+ })
+ await first.refresh()
+ const fetcher = vi.fn(async () => killList())
+ const restarted = new PluginKillListService({ pluginsDataDir: root, fetcher })
+
+ await restarted.initialize()
+
+ expect(restarted.reason('community.unsafe')).toBe('Malware advisory')
+ expect(fetcher).not.toHaveBeenCalled()
+ })
+
+ it('publishes valid refreshes and notifies runtime reconciliation', async () => {
+ const service = new PluginKillListService({
+ pluginsDataDir: await tempRoot(),
+ fetcher: async () => killList()
+ })
+ const changed = vi.fn()
+ service.onChanged(changed)
+
+ await service.refresh()
+
+ expect(service.find('community.unsafe')).toMatchObject({ reason: 'Malware advisory' })
+ expect(changed).toHaveBeenCalledTimes(1)
+ })
+
+ it('starts with no revocations after a corrupt cache and accepts a valid refresh', async () => {
+ const store = {
+ read: vi.fn().mockRejectedValue(new Error('invalid JSON')),
+ write: vi.fn().mockResolvedValue(undefined)
+ } as unknown as PluginKillListStore
+ const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ const service = new PluginKillListService({
+ pluginsDataDir: await tempRoot(),
+ store,
+ fetcher: async () => killList()
+ })
+
+ await expect(service.initialize()).resolves.toBeUndefined()
+ expect(service.snapshot()).toBeNull()
+ await expect(service.refresh()).resolves.toEqual(killList())
+ expect(service.reason('community.unsafe')).toBe('Malware advisory')
+ expect(warning).toHaveBeenCalledWith(
+ '[plugins] ignoring invalid cached plugin safety list:',
+ expect.any(Error)
+ )
+ })
+
+ it('rejects a replayed older snapshot without replacing cached revocations', async () => {
+ const fetcher = vi
+ .fn<() => Promise>()
+ .mockResolvedValueOnce(killList('2026-07-12T20:00:00Z'))
+ .mockResolvedValueOnce(killList('2026-07-11T20:00:00Z'))
+ const service = new PluginKillListService({ pluginsDataDir: await tempRoot(), fetcher })
+ await service.refresh()
+
+ await expect(service.refresh()).rejects.toThrow('older snapshot')
+ expect(service.snapshot()?.generatedAt).toBe('2026-07-12T20:00:00Z')
+ })
+})
+
+describe('fetchPluginKillList', () => {
+ it('validates a bounded HTTPS response body', async () => {
+ const fetcher = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify(killList()), {
+ status: 200,
+ headers: { 'content-type': 'application/json' }
+ })
+ )
+
+ await expect(fetchPluginKillList(fetcher)).resolves.toEqual(killList())
+ })
+
+ it('rejects non-success responses', async () => {
+ const fetcher = vi.fn().mockResolvedValue(new Response('no', { status: 503 }))
+ await expect(fetchPluginKillList(fetcher)).rejects.toThrow('HTTP 503')
+ })
+})
diff --git a/src/main/plugins/plugin-kill-list-service.ts b/src/main/plugins/plugin-kill-list-service.ts
new file mode 100644
index 000000000..572e7074b
--- /dev/null
+++ b/src/main/plugins/plugin-kill-list-service.ts
@@ -0,0 +1,140 @@
+import {
+ findKilledPlugin,
+ pluginKillListSchema,
+ type PluginKillList,
+ type PluginKillListEntry
+} from '../../shared/plugins/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
+
+type PluginKillListFetcher = () => Promise
+
+export class PluginKillListService {
+ private readonly store: PluginKillListStore
+ private readonly fetcher: PluginKillListFetcher
+ 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: []
+ })
+
+ constructor(options: {
+ pluginsDataDir: string
+ store?: PluginKillListStore
+ fetcher?: PluginKillListFetcher
+ }) {
+ this.store = options.store ?? new PluginKillListStore(options.pluginsDataDir)
+ this.fetcher = options.fetcher ?? (() => fetchPluginKillList())
+ }
+
+ async initialize(): Promise {
+ this.loadPromise ??= this.store
+ .read()
+ .then((killList) => {
+ this.currentList = killList
+ })
+ .catch((error) => {
+ // Why: an unusable cache must not prevent Orca from starting; a valid
+ // network refresh can still restore runtime revocations this session.
+ console.warn('[plugins] ignoring invalid cached plugin safety list:', error)
+ this.currentList = null
+ })
+ await this.loadPromise
+ }
+
+ onChanged(listener: () => void): () => void {
+ this.listeners.add(listener)
+ return () => this.listeners.delete(listener)
+ }
+
+ find(pluginKey: string): PluginKillListEntry | null {
+ return this.currentList ? findKilledPlugin(this.currentList, pluginKey) : null
+ }
+
+ reason(pluginKey: string): string | null {
+ return this.find(pluginKey)?.reason ?? null
+ }
+
+ snapshot(): PluginKillList | null {
+ return this.currentList
+ }
+
+ refresh(): Promise {
+ const refresh = this.refreshChain
+ .catch(() => this.currentList ?? emptyKillList())
+ .then(() => this.performRefresh())
+ this.refreshChain = refresh
+ return refresh
+ }
+
+ private async performRefresh(): Promise {
+ await this.initialize()
+ const fetched = pluginKillListSchema.parse(await this.fetcher())
+ if (
+ this.currentList &&
+ Date.parse(fetched.generatedAt) < Date.parse(this.currentList.generatedAt)
+ ) {
+ throw new Error('refusing to replace the plugin kill list with an older snapshot')
+ }
+ await this.store.write(fetched)
+ this.currentList = fetched
+ for (const listener of this.listeners) {
+ listener()
+ }
+ return fetched
+ }
+}
+
+export async function fetchPluginKillList(
+ fetcher: typeof fetch = fetch,
+ url = PLUGIN_KILL_LIST_URL
+): Promise {
+ const response = await fetcher(url, { cache: 'no-store' })
+ if (!response.ok) {
+ throw new Error(`plugin kill-list request failed with HTTP ${response.status}`)
+ }
+ const declaredBytes = Number(response.headers.get('content-length') ?? '0')
+ if (Number.isFinite(declaredBytes) && declaredBytes > PLUGIN_KILL_LIST_DOWNLOAD_LIMIT) {
+ throw new Error('plugin kill-list response exceeds its size limit')
+ }
+ if (!response.body) {
+ throw new Error('plugin kill-list response has no body')
+ }
+ const reader = response.body.getReader()
+ const chunks: Uint8Array[] = []
+ let totalBytes = 0
+ while (true) {
+ const chunk = await reader.read()
+ if (chunk.done) {
+ break
+ }
+ totalBytes += chunk.value.byteLength
+ if (totalBytes > PLUGIN_KILL_LIST_DOWNLOAD_LIMIT) {
+ await reader.cancel()
+ throw new Error('plugin kill-list response exceeds its size limit')
+ }
+ chunks.push(chunk.value)
+ }
+ const bytes = new Uint8Array(totalBytes)
+ let offset = 0
+ for (const chunk of chunks) {
+ bytes.set(chunk, offset)
+ offset += chunk.byteLength
+ }
+ try {
+ return pluginKillListSchema.parse(JSON.parse(new TextDecoder().decode(bytes)))
+ } catch (error) {
+ throw new Error(
+ `invalid plugin kill-list response: ${error instanceof Error ? error.message : String(error)}`
+ )
+ }
+}
+
+function emptyKillList(): PluginKillList {
+ return { version: 1, generatedAt: '1970-01-01T00:00:00Z', plugins: [] }
+}
diff --git a/src/main/plugins/plugin-kill-list-store.ts b/src/main/plugins/plugin-kill-list-store.ts
new file mode 100644
index 000000000..5dca14190
--- /dev/null
+++ b/src/main/plugins/plugin-kill-list-store.ts
@@ -0,0 +1,46 @@
+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'
+
+const PLUGIN_KILL_LIST_MAX_BYTES = 4 * 1024 * 1024
+
+export class PluginKillListStore {
+ private readonly filePath: string
+
+ constructor(pluginsDataDir: string) {
+ this.filePath = join(pluginsDataDir, 'plugin-kill-list.json')
+ }
+
+ async read(): Promise {
+ try {
+ const chunks: Buffer[] = []
+ let totalBytes = 0
+ for await (const chunk of createReadStream(this.filePath)) {
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
+ totalBytes += bytes.byteLength
+ if (totalBytes > PLUGIN_KILL_LIST_MAX_BYTES) {
+ throw new Error('plugin kill list exceeds its size limit')
+ }
+ chunks.push(bytes)
+ }
+ return pluginKillListSchema.parse(
+ JSON.parse(Buffer.concat(chunks, totalBytes).toString('utf8'))
+ )
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
+ return null
+ }
+ throw new Error(
+ `cached plugin kill list is invalid: ${error instanceof Error ? error.message : String(error)}`
+ )
+ }
+ }
+
+ async write(killList: PluginKillList): Promise {
+ const parsed = pluginKillListSchema.parse(killList)
+ await mkdir(dirname(this.filePath), { recursive: true })
+ await writePluginFileAtomically(this.filePath, `${JSON.stringify(parsed, null, 2)}\n`)
+ }
+}
diff --git a/src/main/plugins/plugin-language-pack-registry.test.ts b/src/main/plugins/plugin-language-pack-registry.test.ts
new file mode 100644
index 000000000..94fd93e45
--- /dev/null
+++ b/src/main/plugins/plugin-language-pack-registry.test.ts
@@ -0,0 +1,78 @@
+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 { PluginLanguagePackRegistry } from './plugin-language-pack-registry'
+
+const roots: string[] = []
+
+async function pluginWithCatalog(catalog: unknown): Promise {
+ const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-language-registry-'))
+ roots.push(rootDir)
+ await mkdir(join(rootDir, 'locales'))
+ await writeFile(join(rootDir, 'locales', 'pt-BR.json'), JSON.stringify(catalog))
+ const manifest = pluginManifestSchema.parse({
+ manifestVersion: 1,
+ id: 'portuguese',
+ publisher: 'orca-samples',
+ name: 'Portuguese',
+ version: '1.0.0',
+ engines: { orca: '>=1.0.0' },
+ pluginApi: 1,
+ contributes: {
+ languagePacks: [{ locale: 'pt-BR', path: 'locales/pt-BR.json' }]
+ },
+ capabilities: []
+ })
+ return {
+ pluginKey: 'orca-samples.portuguese',
+ rootDir,
+ manifest,
+ consentFingerprint: fingerprintPluginConsent(manifest),
+ contentHash: null,
+ isDev: true
+ }
+}
+
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+describe('PluginLanguagePackRegistry', () => {
+ it('loads approved catalogs under an isolated plugin language id', async () => {
+ const plugin = await pluginWithCatalog({ common: { save: 'Salvar' } })
+ const registry = new PluginLanguagePackRegistry(new PluginContentVerifier())
+
+ await registry.reconcile([plugin], () => true)
+
+ expect(registry.list()).toEqual([
+ {
+ id: 'plugin:orca-samples.portuguese/pt-BR',
+ resourceLanguage:
+ 'plugin0070006c007500670069006e003a006f007200630061002d00730061006d0070006c00650073002e0070006f00720074007500670075006500730065002f00700074002d00420052',
+ pluginKey: 'orca-samples.portuguese',
+ locale: 'pt-BR',
+ catalog: { common: { save: 'Salvar' } }
+ }
+ ])
+ expect(registry.error(plugin.pluginKey)).toBeNull()
+ })
+
+ it('fails closed for protected security copy and clears state when disabled', async () => {
+ const plugin = await pluginWithCatalog({
+ 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()
+ })
+})
diff --git a/src/main/plugins/plugin-language-pack-registry.ts b/src/main/plugins/plugin-language-pack-registry.ts
new file mode 100644
index 000000000..df4092647
--- /dev/null
+++ b/src/main/plugins/plugin-language-pack-registry.ts
@@ -0,0 +1,96 @@
+import {
+ parsePluginLanguagePackArtifact,
+ pluginLanguageResourceId,
+ type PluginLanguagePackRegistration
+} from '../../shared/plugins/plugin-language-pack-artifact'
+import {
+ PLUGIN_LANGUAGE_PACK_MAX_BYTES,
+ readContainedPluginArtifactText
+} from './plugin-artifact-validation'
+import type { PluginContentVerifier } from './plugin-content-integrity'
+import { mapWithConcurrency } from '../../shared/map-with-concurrency'
+import {
+ isInvalidDiscoveredPlugin,
+ type DiscoveredPlugin,
+ type ValidDiscoveredPlugin
+} from './plugin-discovery'
+
+const LANGUAGE_PACK_LOAD_CONCURRENCY = 4
+
+type LanguageLoadResult =
+ | { pluginKey: string; packs: PluginLanguagePackRegistration[] }
+ | { pluginKey: string; error: string }
+
+export class PluginLanguagePackRegistry {
+ private packs: PluginLanguagePackRegistration[] = []
+ private readonly errors = new Map()
+
+ constructor(private readonly contentVerifier: PluginContentVerifier) {}
+
+ list(): readonly PluginLanguagePackRegistration[] {
+ return this.packs
+ }
+
+ error(pluginKey: string): string | null {
+ return this.errors.get(pluginKey) ?? null
+ }
+
+ async reconcile(
+ discovered: readonly DiscoveredPlugin[],
+ isApproved: (plugin: ValidDiscoveredPlugin) => boolean
+ ): Promise {
+ const candidates: ValidDiscoveredPlugin[] = []
+ for (const plugin of discovered) {
+ if (
+ !isInvalidDiscoveredPlugin(plugin) &&
+ isApproved(plugin) &&
+ plugin.manifest.contributes.languagePacks.length > 0
+ ) {
+ candidates.push(plugin)
+ }
+ }
+ const results = await mapWithConcurrency(
+ candidates,
+ LANGUAGE_PACK_LOAD_CONCURRENCY,
+ async (plugin): Promise => {
+ try {
+ await this.contentVerifier.verify(plugin)
+ const packs = await Promise.all(
+ plugin.manifest.contributes.languagePacks.map(async (contribution) => {
+ const text = await readContainedPluginArtifactText(
+ plugin.rootDir,
+ contribution.path,
+ PLUGIN_LANGUAGE_PACK_MAX_BYTES
+ )
+ const parsed = parsePluginLanguagePackArtifact(text)
+ if (!parsed.ok) {
+ throw new Error(`language pack "${contribution.locale}" ${parsed.error}`)
+ }
+ const id = `plugin:${plugin.pluginKey}/${contribution.locale}` as const
+ return {
+ id,
+ resourceLanguage: pluginLanguageResourceId(id),
+ pluginKey: plugin.pluginKey,
+ locale: contribution.locale,
+ catalog: parsed.catalog
+ }
+ })
+ )
+ return { pluginKey: plugin.pluginKey, packs }
+ } catch (error) {
+ return {
+ pluginKey: plugin.pluginKey,
+ error: error instanceof Error ? error.message : String(error)
+ }
+ }
+ }
+ )
+ this.packs = results.flatMap((result) => ('packs' in result ? result.packs : []))
+ this.errors.clear()
+ for (const result of results) {
+ if ('error' in result) {
+ this.errors.set(result.pluginKey, result.error)
+ }
+ }
+ }
+}
diff --git a/src/main/plugins/plugin-launch-content.test.ts b/src/main/plugins/plugin-launch-content.test.ts
new file mode 100644
index 000000000..692a98755
--- /dev/null
+++ b/src/main/plugins/plugin-launch-content.test.ts
@@ -0,0 +1,113 @@
+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 {
+ isOfficialOrganizationGitSource,
+ isOfficialPluginIdentity,
+ pluginMarketplaceSchema
+} from '../../shared/plugins/plugin-marketplace'
+import { bootstrapBundledPlugins, resolveBundledPluginRoot } from './plugin-bundled-bootstrap'
+import { inspectPluginInstallTree } from './plugin-install-staging'
+
+const launchRoot = join(process.cwd(), 'resources', 'plugins', 'launch')
+const temporaryRoots: string[] = []
+
+async function readJson(path: string): Promise {
+ return JSON.parse(await readFile(path, 'utf8'))
+}
+
+afterEach(async () => {
+ await Promise.all(
+ temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))
+ )
+})
+
+describe('Phase 1 launch plugin content', () => {
+ it('lists and validates the launch plugin packs', async () => {
+ const marketplace = pluginMarketplaceSchema.parse(
+ 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'
+ ])
+ expect(
+ marketplace.plugins.filter(
+ (plugin) =>
+ isOfficialPluginIdentity(plugin.id) && isOfficialOrganizationGitSource(plugin.source.url)
+ ).length
+ ).toBeGreaterThanOrEqual(2)
+
+ const localPluginDirectories = (await readdir(launchRoot, { withFileTypes: true }))
+ .filter((entry) => entry.isDirectory())
+ .map((entry) => entry.name)
+ .sort()
+ expect(marketplace.plugins.map((plugin) => plugin.id).sort()).toEqual(localPluginDirectories)
+
+ const contributionKinds = new Set()
+ for (const listing of marketplace.plugins) {
+ const inspection = await inspectPluginInstallTree({
+ rootDir: join(launchRoot, listing.id),
+ hostVersion: '1.4.0',
+ expectedPluginKey: listing.id
+ })
+ expect(inspection, `${listing.id} must pass the production install inspection`).toMatchObject(
+ {
+ ok: true
+ }
+ )
+ if (!inspection.ok) {
+ 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']))
+ })
+
+ it('publishes every bundled pack only when its release hash matches exact bytes', async () => {
+ const userDataPath = await mkdtemp(join(tmpdir(), 'orca-launch-content-'))
+ temporaryRoots.push(userDataPath)
+
+ const result = await bootstrapBundledPlugins({
+ root: launchRoot,
+ userDataPath,
+ hostVersion: '1.4.0'
+ })
+
+ expect(result.errors).toEqual([])
+ expect(result.installed.length).toBeGreaterThanOrEqual(1)
+ expect(result.installed.every(isOfficialPluginIdentity)).toBe(true)
+ })
+
+ it('boots release-indexed content from the packaged resources layout', async () => {
+ const resourcesPath = await mkdtemp(join(tmpdir(), 'orca-packaged-resources-'))
+ const userDataPath = await mkdtemp(join(tmpdir(), 'orca-packaged-user-data-'))
+ temporaryRoots.push(resourcesPath, userDataPath)
+ const packagedRoot = join(resourcesPath, 'plugins', 'launch')
+ await cp(launchRoot, packagedRoot, { recursive: true })
+
+ const result = await bootstrapBundledPlugins({
+ root: resolveBundledPluginRoot({
+ isPackaged: true,
+ resourcesPath,
+ appPath: join(resourcesPath, 'app.asar')
+ }),
+ userDataPath,
+ hostVersion: '1.4.0'
+ })
+
+ expect(result.errors).toEqual([])
+ expect(result.installed).toEqual(['stablyai.orca-navigation-shortcuts'])
+ })
+})
diff --git a/src/main/plugins/plugin-list-projection.test.ts b/src/main/plugins/plugin-list-projection.test.ts
new file mode 100644
index 000000000..36904cfa2
--- /dev/null
+++ b/src/main/plugins/plugin-list-projection.test.ts
@@ -0,0 +1,224 @@
+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 type { InvalidDiscoveredPlugin, ValidDiscoveredPlugin } from './plugin-discovery'
+import { buildPluginList } from './plugin-list-projection'
+import type { PluginService } from './plugin-service'
+
+const manifest = pluginManifestSchema.parse({
+ manifestVersion: 1,
+ id: 'demo',
+ publisher: 'orca-samples',
+ name: 'Demo',
+ version: '1.0.0',
+ engines: { orca: '>=1.0.0' },
+ pluginApi: 1,
+ contributes: { panels: [], commands: [], events: [] },
+ capabilities: [{ kind: 'workspace:read' }]
+})
+
+function serviceWith(
+ discovered: ValidDiscoveredPlugin,
+ options: {
+ activation?: ReturnType
+ worker?: ReturnType
+ vmRecipes?: ReturnType
+ commands?: ReturnType
+ } = {}
+): PluginService {
+ return {
+ options: {
+ getPluginConsents: () => ({}),
+ getDisabledPlugins: () => []
+ },
+ getDiscovered: () => [discovered],
+ activationState: () => options.activation ?? 'pending',
+ workerState: () => options.worker ?? { state: 'inactive', restarts: 0 },
+ activationError: () => null,
+ contentPacks: {
+ vmRecipes: { preview: () => options.vmRecipes ?? [] },
+ commands: { preview: () => options.commands ?? [] }
+ }
+ } as unknown as PluginService
+}
+
+describe('buildPluginList consent identity', () => {
+ it('projects the exact current fingerprint for an optimistic consent write', async () => {
+ const plugin: ValidDiscoveredPlugin = {
+ pluginKey: 'orca-samples.demo',
+ rootDir: join(tmpdir(), 'plugins', 'demo'),
+ manifest,
+ consentFingerprint: 'sha256-current',
+ contentHash: null,
+ isDev: true
+ }
+
+ expect((await buildPluginList(serviceWith(plugin), emptyPluginLockfile()))[0]).toMatchObject({
+ pluginKey: plugin.pluginKey,
+ consentFingerprint: 'sha256-current',
+ status: 'pending'
+ })
+ })
+
+ it('projects supervised backoff as restarting instead of running', async () => {
+ const plugin: ValidDiscoveredPlugin = {
+ pluginKey: 'orca-samples.demo',
+ rootDir: join(tmpdir(), 'plugins', 'demo'),
+ manifest,
+ consentFingerprint: 'sha256-current',
+ contentHash: null,
+ isDev: true
+ }
+
+ expect(
+ (
+ await buildPluginList(
+ serviceWith(plugin, {
+ activation: 'approved',
+ worker: { state: 'restarting', restarts: 2 }
+ }),
+ emptyPluginLockfile()
+ )
+ )[0]
+ ).toMatchObject({ status: 'restarting', restarts: 2 })
+ })
+
+ it('does not attribute a shadowing dev plugin to the installed source', async () => {
+ const plugin: ValidDiscoveredPlugin = {
+ pluginKey: 'orca-samples.demo',
+ rootDir: join(tmpdir(), 'development', 'demo'),
+ manifest,
+ consentFingerprint: 'sha256-current',
+ contentHash: null,
+ isDev: true
+ }
+ const lock = {
+ version: 1 as const,
+ plugins: {
+ [plugin.pluginKey]: {
+ pluginKey: plugin.pluginKey,
+ version: '1.0.0',
+ source: { kind: 'git' as const, url: 'https://example.com/demo.git', ref: 'v1' },
+ resolvedCommit: 'a'.repeat(40),
+ contentHash: 'b'.repeat(64),
+ consentFingerprint: 'sha256-installed',
+ installedAt: 1
+ }
+ }
+ }
+
+ expect((await buildPluginList(serviceWith(plugin), lock))[0]).not.toHaveProperty('source')
+ })
+
+ it('does not expose an invalid development plugin absolute path as identity', async () => {
+ const invalid: InvalidDiscoveredPlugin = {
+ rootDir: join(tmpdir(), 'private', 'secret-plugin-path'),
+ error: 'missing orca-plugin.json',
+ isDev: true
+ }
+ const service = {
+ options: { getPluginConsents: () => ({}), getDisabledPlugins: () => [] },
+ getDiscovered: () => [invalid]
+ } as unknown as PluginService
+
+ const projected = (await buildPluginList(service, emptyPluginLockfile()))[0]!
+ expect(projected.pluginKey).toBe('invalid-development-plugin-1')
+ expect(projected.name).toBe('invalid-development-plugin-1')
+ expect(JSON.stringify(projected)).not.toContain(invalid.rootDir)
+ })
+
+ it('projects exact VM lifecycle commands for instructional consent', async () => {
+ const recipeManifest = pluginManifestSchema.parse({
+ ...manifest,
+ contributes: { vmRecipes: [{ path: 'recipes/cloud.json' }] }
+ })
+ const plugin: ValidDiscoveredPlugin = {
+ pluginKey: 'orca-samples.demo',
+ rootDir: join(tmpdir(), 'plugins', 'demo'),
+ manifest: recipeManifest,
+ consentFingerprint: 'sha256-current',
+ consentContentHash: 'a'.repeat(64),
+ contentHash: null,
+ isDev: true
+ }
+
+ expect(
+ (
+ await buildPluginList(
+ serviceWith(plugin, {
+ vmRecipes: [
+ {
+ pluginKey: plugin.pluginKey,
+ recipe: {
+ id: 'cloud',
+ name: 'Cloud',
+ create: './create.sh',
+ destroyDisabled: true
+ }
+ }
+ ]
+ }),
+ emptyPluginLockfile()
+ )
+ )[0]?.vmRecipes
+ ).toEqual([
+ {
+ id: 'cloud',
+ name: 'Cloud',
+ commands: [
+ { phase: 'create', command: './create.sh' },
+ { phase: 'destroy', command: 'none' }
+ ]
+ }
+ ])
+ })
+
+ it('projects command handlers and normalized keybindings for consent and dispatch', async () => {
+ const commandManifest = pluginManifestSchema.parse({
+ ...manifest,
+ contributes: {
+ commands: [{ id: 'tasks', title: 'Open Tasks', context: 'worktree', action: 'view.tasks' }],
+ keybindings: [{ command: 'tasks', key: 'mod+alt+t' }]
+ }
+ })
+ const plugin: ValidDiscoveredPlugin = {
+ pluginKey: 'orca-samples.demo',
+ rootDir: join(tmpdir(), 'plugins', 'demo'),
+ manifest: commandManifest,
+ consentFingerprint: 'sha256-current',
+ consentContentHash: 'a'.repeat(64),
+ contentHash: null,
+ isDev: true
+ }
+
+ expect(
+ (
+ await buildPluginList(
+ serviceWith(plugin, {
+ commands: [
+ {
+ pluginKey: plugin.pluginKey,
+ id: 'tasks',
+ title: 'Open Tasks',
+ context: 'worktree',
+ handler: { type: 'built-in', action: 'view.tasks' },
+ keybindings: [{ key: 'Mod+Alt+T', when: 'worktree' }]
+ }
+ ]
+ }),
+ emptyPluginLockfile()
+ )
+ )[0]?.commands
+ ).toEqual([
+ {
+ id: 'tasks',
+ title: 'Open Tasks',
+ context: 'worktree',
+ handler: { type: 'built-in', action: 'view.tasks' },
+ keybindings: [{ key: 'Mod+Alt+T', when: 'worktree' }]
+ }
+ ])
+ })
+})
diff --git a/src/main/plugins/plugin-list-projection.ts b/src/main/plugins/plugin-list-projection.ts
new file mode 100644
index 000000000..16d44935b
--- /dev/null
+++ b/src/main/plugins/plugin-list-projection.ts
@@ -0,0 +1,231 @@
+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'
+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 {
+ isOfficialMarketplaceGitSource,
+ isOfficialOrganizationGitSource,
+ isOfficialPluginIdentity
+} from '../../shared/plugins/plugin-marketplace'
+import { mapWithConcurrency } from '../../shared/map-with-concurrency'
+
+const PLUGIN_LIST_PROJECTION_CONCURRENCY = 4
+
+/**
+ * Wire projection of installed plugins for the renderer and serve RPC.
+ * `invalid` = unreadable/failed manifest; `pending` = awaiting (re-)consent;
+ * `idle` = enabled with no worker running (lazy); `restarting` = waiting for
+ * supervised backoff; `errored` = crashed past the budget or failed to activate.
+ */
+
+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 }
+ }
+}
+
+export async function buildPluginList(
+ service: PluginService,
+ lock: PluginLockfile
+): Promise {
+ const consents = {
+ pluginConsents: service.options.getPluginConsents(),
+ disabledPlugins: service.options.getDisabledPlugins()
+ }
+ return mapWithConcurrency(
+ service.getDiscovered(),
+ PLUGIN_LIST_PROJECTION_CONCURRENCY,
+ async (plugin, index): Promise => {
+ if (isInvalidDiscoveredPlugin(plugin)) {
+ // Why: invalid dev paths can contain private absolute desktop paths;
+ // never project those as identity over desktop/serve transports.
+ const fallbackKey = plugin.pluginKey ?? `invalid-development-plugin-${index + 1}`
+ return {
+ pluginKey: fallbackKey,
+ consentFingerprint: null,
+ name: fallbackKey,
+ version: '0.0.0',
+ publisher: '',
+ status: 'invalid' as const,
+ needsReconsent: false,
+ error: plugin.error,
+ isDev: plugin.isDev,
+ official: false,
+ bundled: false,
+ capabilities: [],
+ panels: [],
+ commands: [],
+ hasWorker: false,
+ vmRecipes: [],
+ restarts: 0
+ }
+ }
+ const activation = service.activationState(plugin)
+ const worker = service.workerState(plugin.pluginKey)
+ const activationError = service.activationError(plugin.pluginKey)
+ const killListEntry = service.options.getPluginKillListEntry?.(plugin.pluginKey) ?? null
+ let status: PluginListStatus
+ if (activation === 'disabled') {
+ status = 'disabled'
+ } else if (activation === 'pending') {
+ status = 'pending'
+ } else if (worker.state === 'errored' || activationError) {
+ status = 'errored'
+ } else if (worker.state === 'restarting') {
+ status = 'restarting'
+ } else {
+ status = worker.state === 'running' ? 'running' : 'idle'
+ }
+ const candidateLockEntry = lock.plugins[plugin.pluginKey]
+ // Why: never show provenance for bytes other than the current executable
+ // identity. Dev overrides execute outside the immutable installed tree and
+ // must never inherit the shadowed install's pinned-source attribution.
+ const lockEntry =
+ candidateLockEntry &&
+ !plugin.isDev &&
+ plugin.contentHash !== null &&
+ candidateLockEntry.contentHash === plugin.contentHash
+ ? candidateLockEntry
+ : undefined
+ 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 {
+ pluginKey: plugin.pluginKey,
+ consentFingerprint: plugin.consentFingerprint,
+ name: plugin.manifest.name,
+ version: plugin.manifest.version,
+ publisher: plugin.manifest.publisher,
+ ...(plugin.manifest.description ? { description: plugin.manifest.description } : {}),
+ status,
+ needsReconsent: needsReconsent(plugin.pluginKey, plugin.consentFingerprint, consents),
+ ...(status === 'errored'
+ ? { error: activationError ?? 'plugin worker crashed repeatedly' }
+ : {}),
+ isDev: plugin.isDev,
+ official,
+ bundled,
+ capabilities: plugin.manifest.capabilities.map((capability) => ({
+ kind: capability.kind,
+ description: PLUGIN_CAPABILITY_DESCRIPTIONS[capability.kind]
+ })),
+ panels: plugin.manifest.contributes.panels.map((panel) => ({
+ id: panel.id,
+ title: panel.title,
+ ...(panel.icon ? { icon: panel.icon } : {}),
+ tabKey: pluginPanelTabKey(plugin.pluginKey, panel.id)
+ })),
+ commands: service.contentPacks.commands.preview(plugin.pluginKey).map((command) => ({
+ id: command.id,
+ title: command.title,
+ context: command.context,
+ handler: command.handler,
+ keybindings: command.keybindings
+ })),
+ hasWorker: Boolean(plugin.manifest.main),
+ vmRecipes: service.contentPacks.vmRecipes.preview(plugin.pluginKey).map(({ recipe }) => ({
+ id: recipe.id,
+ name: recipe.name,
+ ...(recipe.description ? { description: recipe.description } : {}),
+ commands: listPluginVmRecipeCommands(recipe)
+ })),
+ restarts: worker.restarts,
+ ...(killListEntry
+ ? {
+ blockedByKillList: {
+ reason: killListEntry.reason,
+ ...(killListEntry.advisoryUrl ? { advisoryUrl: killListEntry.advisoryUrl } : {})
+ }
+ }
+ : {}),
+ ...(lockEntry
+ ? {
+ source: {
+ kind: lockEntry.source.kind,
+ reference:
+ lockEntry.source.kind === 'local-path'
+ ? lockEntry.source.path
+ : lockEntry.source.kind === 'git'
+ ? lockEntry.source.url
+ : lockEntry.source.kind === 'marketplace'
+ ? lockEntry.source.plugin.url
+ : `bundled:${lockEntry.source.bundleId}`,
+ resolvedCommit: lockEntry.resolvedCommit,
+ contentHash: lockEntry.contentHash,
+ ...(lockEntry.source.kind === 'marketplace'
+ ? {
+ marketplace: {
+ reference: lockEntry.source.marketplace.url,
+ resolvedCommit: lockEntry.source.marketplace.resolvedCommit
+ }
+ }
+ : {})
+ }
+ }
+ : {})
+ }
+ }
+ )
+}
diff --git a/src/main/plugins/plugin-log-buffer.ts b/src/main/plugins/plugin-log-buffer.ts
new file mode 100644
index 000000000..54fbdcd83
--- /dev/null
+++ b/src/main/plugins/plugin-log-buffer.ts
@@ -0,0 +1,20 @@
+export type PluginLogLine = { ts: number; level: 'info' | 'warn' | 'error'; line: string }
+
+const LOG_RING_LIMIT = 200
+
+export class PluginLogBuffer {
+ private readonly logs = new Map()
+
+ get(pluginKey: string): PluginLogLine[] {
+ return this.logs.get(pluginKey) ?? []
+ }
+
+ append(pluginKey: string, level: PluginLogLine['level'], line: string): void {
+ const ring = this.logs.get(pluginKey) ?? []
+ ring.push({ ts: Date.now(), level, line })
+ if (ring.length > LOG_RING_LIMIT) {
+ ring.splice(0, ring.length - LOG_RING_LIMIT)
+ }
+ this.logs.set(pluginKey, ring)
+ }
+}
diff --git a/src/main/plugins/plugin-manifest-file.ts b/src/main/plugins/plugin-manifest-file.ts
new file mode 100644
index 000000000..68b01dfe1
--- /dev/null
+++ b/src/main/plugins/plugin-manifest-file.ts
@@ -0,0 +1,22 @@
+import { createReadStream } from 'node:fs'
+import { join } from 'node:path'
+import { PLUGIN_MANIFEST_FILENAME } from '../../shared/plugins/plugin-manifest'
+
+/** A manifest is startup metadata, not an artifact payload. Bounding it keeps
+ * discovery and install preview from allocating an attacker-sized JSON file. */
+export const PLUGIN_MANIFEST_MAX_BYTES = 1024 * 1024
+
+export async function readPluginManifestText(rootDir: string): Promise {
+ const chunks: Buffer[] = []
+ let totalBytes = 0
+ const stream = createReadStream(join(rootDir, PLUGIN_MANIFEST_FILENAME))
+ for await (const chunk of stream) {
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
+ totalBytes += bytes.byteLength
+ if (totalBytes > PLUGIN_MANIFEST_MAX_BYTES) {
+ throw new Error(`${PLUGIN_MANIFEST_FILENAME} exceeds ${PLUGIN_MANIFEST_MAX_BYTES} bytes`)
+ }
+ chunks.push(bytes)
+ }
+ return Buffer.concat(chunks, totalBytes).toString('utf8')
+}
diff --git a/src/main/plugins/plugin-marketplace-error-message.ts b/src/main/plugins/plugin-marketplace-error-message.ts
new file mode 100644
index 000000000..d82903692
--- /dev/null
+++ b/src/main/plugins/plugin-marketplace-error-message.ts
@@ -0,0 +1,3 @@
+export function pluginMarketplaceErrorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error)
+}
diff --git a/src/main/plugins/plugin-marketplace-fetch.ts b/src/main/plugins/plugin-marketplace-fetch.ts
new file mode 100644
index 000000000..d035841eb
--- /dev/null
+++ b/src/main/plugins/plugin-marketplace-fetch.ts
@@ -0,0 +1,63 @@
+import { createReadStream } from 'node:fs'
+import { mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import {
+ PLUGIN_MARKETPLACE_FILENAME,
+ pluginMarketplaceSchema,
+ type PluginMarketplace
+} from '../../shared/plugins/plugin-marketplace'
+import { checkoutPluginGitSource } from './plugin-git-repository'
+import type { PluginMarketplaceRegisteredSource } from './plugin-marketplace-store'
+
+const MARKETPLACE_INDEX_MAX_BYTES = 16 * 1024 * 1024
+
+export type PluginMarketplaceFetchResult = {
+ marketplaceCommit: string
+ marketplace: PluginMarketplace
+}
+
+/** Fetches a marketplace through system Git so private repositories use the
+ * same SSH agent and credential helpers as every other Orca Git operation. */
+export async function fetchPluginMarketplace(
+ source: PluginMarketplaceRegisteredSource
+): Promise {
+ const stagingDirectory = await mkdtemp(join(tmpdir(), 'orca-plugin-marketplace-'))
+ try {
+ const marketplaceCommit = await checkoutPluginGitSource({
+ url: source.source.url,
+ ref: source.source.ref,
+ destination: stagingDirectory,
+ workingDirectory: tmpdir()
+ })
+ const marketplace = await readPluginMarketplaceIndex(stagingDirectory)
+ return { marketplaceCommit, marketplace }
+ } finally {
+ await rm(stagingDirectory, { recursive: true, force: true })
+ }
+}
+
+export async function readPluginMarketplaceIndex(
+ rootDirectory: string
+): Promise {
+ const path = join(rootDirectory, PLUGIN_MARKETPLACE_FILENAME)
+ const chunks: Buffer[] = []
+ let totalBytes = 0
+ for await (const chunk of createReadStream(path)) {
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
+ totalBytes += bytes.byteLength
+ if (totalBytes > MARKETPLACE_INDEX_MAX_BYTES) {
+ throw new Error(`${PLUGIN_MARKETPLACE_FILENAME} exceeds its size limit`)
+ }
+ chunks.push(bytes)
+ }
+ try {
+ return pluginMarketplaceSchema.parse(
+ JSON.parse(Buffer.concat(chunks, totalBytes).toString('utf8'))
+ )
+ } catch (error) {
+ throw new Error(
+ `invalid ${PLUGIN_MARKETPLACE_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
new file mode 100644
index 000000000..589a90699
--- /dev/null
+++ b/src/main/plugins/plugin-marketplace-installer.test.ts
@@ -0,0 +1,198 @@
+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 { 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: vi.fn(),
+ version: '1.0.0',
+ publisher: 'community',
+ id: 'notes',
+ commit: 'a'.repeat(40),
+ payload: 'first'
+}))
+
+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']
+ }
+ ]
+ }
+}
+
+async function writeCurrentPlugin(destination: string): Promise {
+ await mkdir(destination, { recursive: true })
+ await writeFile(
+ join(destination, 'orca-plugin.json'),
+ JSON.stringify({
+ 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.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('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
new file mode 100644
index 000000000..179e68768
--- /dev/null
+++ b/src/main/plugins/plugin-marketplace-installer.ts
@@ -0,0 +1,164 @@
+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,
+ ref: listing.source.ref,
+ destination: stagingDirectory,
+ workingDirectory: tmpdir()
+ })
+ const inspection = await inspectPluginInstallTree({
+ rootDir: stagingDirectory,
+ hostVersion: this.hostVersion,
+ expectedPluginKey: pluginKey
+ })
+ 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 },
+ 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
new file mode 100644
index 000000000..c411801d8
--- /dev/null
+++ b/src/main/plugins/plugin-marketplace-projection.ts
@@ -0,0 +1,33 @@
+import type {
+ PluginMarketplaceEntry,
+ PluginMarketplaceGitSource
+} from '../../shared/plugins/plugin-marketplace'
+
+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 }
+}
diff --git a/src/main/plugins/plugin-marketplace-provenance.ts b/src/main/plugins/plugin-marketplace-provenance.ts
new file mode 100644
index 000000000..fbef2ce69
--- /dev/null
+++ b/src/main/plugins/plugin-marketplace-provenance.ts
@@ -0,0 +1,27 @@
+import {
+ OFFICIAL_MARKETPLACE_OWNER,
+ isOfficialMarketplaceGitSource,
+ isOfficialOrganizationGitSource,
+ isReservedPluginIdentity
+} from '../../shared/plugins/plugin-marketplace'
+import type { PluginMarketplaceFetchResult } from './plugin-marketplace-fetch'
+import type { PluginMarketplaceRegisteredSource } from './plugin-marketplace-store'
+
+export function validateMarketplaceProvenance(
+ source: PluginMarketplaceRegisteredSource,
+ fetched: PluginMarketplaceFetchResult
+): void {
+ if (
+ isOfficialMarketplaceGitSource(source.source.url) &&
+ 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)) {
+ 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/plugin-marketplace-service.test.ts
new file mode 100644
index 000000000..38c39a79c
--- /dev/null
+++ b/src/main/plugins/plugin-marketplace-service.test.ts
@@ -0,0 +1,336 @@
+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 {
+ PluginMarketplace,
+ PluginMarketplaceGitSource
+} from '../../shared/plugins/plugin-marketplace'
+import { OFFICIAL_MARKETPLACE_GIT_SOURCE } from '../../shared/plugins/plugin-marketplace'
+import type { PluginMarketplaceFetchResult } from './plugin-marketplace-fetch'
+import { PluginMarketplaceService } from './plugin-marketplace-service'
+import {
+ marketplaceSourceId,
+ PLUGIN_MARKETPLACE_SOURCE_LIMIT,
+ PluginMarketplaceStore,
+ type PluginMarketplaceRegisteredSource
+} from './plugin-marketplace-store'
+
+const roots: string[] = []
+
+async function tempRoot(): Promise {
+ const root = await mkdtemp(join(tmpdir(), 'orca-marketplace-service-'))
+ roots.push(root)
+ return root
+}
+
+function source(url = 'https://github.com/community/plugins.git'): PluginMarketplaceGitSource {
+ return { kind: 'git', url, ref: 'main' }
+}
+
+function marketplace(
+ name = 'Community',
+ pluginKey = 'community.notes',
+ pluginUrl = 'https://github.com/community/notes.git'
+): PluginMarketplace {
+ return {
+ name,
+ owner: name.toLowerCase(),
+ plugins: [
+ {
+ id: pluginKey,
+ source: { kind: 'git', url: pluginUrl, ref: 'v1' },
+ description: 'Notes for active worktrees.',
+ categories: ['productivity']
+ }
+ ]
+ }
+}
+
+function fetched(value = marketplace(), commit = 'a'.repeat(40)): PluginMarketplaceFetchResult {
+ return { marketplaceCommit: commit, marketplace: value }
+}
+
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+describe('PluginMarketplaceService', () => {
+ it('fetches before registration, then serves browse data from the local snapshot', async () => {
+ const fetcher = vi
+ .fn<
+ (registration: PluginMarketplaceRegisteredSource) => Promise
+ >()
+ .mockResolvedValue(fetched())
+ const service = new PluginMarketplaceService({ pluginsDataDir: await tempRoot(), fetcher })
+
+ const added = await service.addSource(source())
+
+ expect(added).toMatchObject({ marketplace: { name: 'Community' }, stale: false })
+ expect(fetcher).toHaveBeenCalledTimes(1)
+ await expect(service.listPlugins()).resolves.toEqual([
+ expect.objectContaining({
+ marketplaceSourceId: added.id,
+ pluginKey: 'community.notes',
+ official: false,
+ bundled: false
+ })
+ ])
+ await service.listSources()
+ expect(fetcher).toHaveBeenCalledTimes(1)
+ })
+
+ it('keeps and labels the last valid snapshot when a refresh is offline', async () => {
+ const fetcher = vi
+ .fn<
+ (registration: PluginMarketplaceRegisteredSource) => Promise
+ >()
+ .mockResolvedValueOnce(fetched())
+ .mockRejectedValueOnce(new Error('offline'))
+ const service = new PluginMarketplaceService({ pluginsDataDir: await tempRoot(), fetcher })
+ const added = await service.addSource(source())
+
+ await expect(service.refreshSource(added.id)).resolves.toMatchObject({
+ marketplace: { name: 'Community', resolvedCommit: 'a'.repeat(40) },
+ stale: true,
+ error: 'offline'
+ })
+ await expect(service.listPlugins()).resolves.toEqual([
+ expect.objectContaining({ pluginKey: 'community.notes' })
+ ])
+ })
+
+ it('atomically replaces the cache only after a valid refreshed index', async () => {
+ const fetcher = vi
+ .fn<
+ (registration: PluginMarketplaceRegisteredSource) => Promise
+ >()
+ .mockResolvedValueOnce(fetched())
+ .mockResolvedValueOnce(fetched(marketplace('Updated'), 'b'.repeat(40)))
+ const root = await tempRoot()
+ const service = new PluginMarketplaceService({ pluginsDataDir: root, fetcher })
+ const added = await service.addSource(source())
+
+ await expect(service.refreshSource(added.id)).resolves.toMatchObject({
+ marketplace: { name: 'Updated', resolvedCommit: 'b'.repeat(40) },
+ stale: false
+ })
+ await expect(
+ new PluginMarketplaceService({ pluginsDataDir: root, fetcher }).listPlugins()
+ ).resolves.toEqual([expect.objectContaining({ marketplaceName: 'Updated' })])
+ })
+
+ it('does not persist a source whose first fetch fails', async () => {
+ const service = new PluginMarketplaceService({
+ pluginsDataDir: await tempRoot(),
+ fetcher: async () => {
+ throw new Error('authentication failed')
+ }
+ })
+
+ await expect(service.addSource(source())).rejects.toThrow('authentication failed')
+ await expect(service.listSources()).resolves.toEqual([])
+ })
+
+ it('rejects reserved identities outside the official organization', async () => {
+ const service = new PluginMarketplaceService({
+ pluginsDataDir: await tempRoot(),
+ fetcher: async () =>
+ fetched(
+ marketplace('Attack', 'community.orca-secrets', 'https://github.com/attacker/x.git')
+ )
+ })
+
+ await expect(service.addSource(source())).rejects.toThrow(
+ 'reserved plugin identity community.orca-secrets'
+ )
+ await expect(service.listSources()).resolves.toEqual([])
+ })
+
+ it('derives the Official badge only from the canonical marketplace and source organization', async () => {
+ const officialMarketplace: PluginMarketplace = {
+ name: 'Orca Plugins',
+ owner: 'stablyai',
+ plugins: [
+ {
+ id: 'stablyai.orca-shortcuts',
+ source: {
+ kind: 'git',
+ url: 'git@github.com:stablyai/orca-shortcuts.git',
+ ref: 'main'
+ },
+ categories: ['keybindings']
+ }
+ ]
+ }
+ const service = new PluginMarketplaceService({
+ pluginsDataDir: await tempRoot(),
+ fetcher: async () => fetched(officialMarketplace)
+ })
+
+ await service.addSource(source('https://github.com/stablyai/orca-plugins.git'))
+
+ await expect(service.listPlugins()).resolves.toEqual([
+ expect.objectContaining({ pluginKey: 'stablyai.orca-shortcuts', official: true })
+ ])
+ })
+
+ it('hides listings whose contribution kind this build no longer supports', async () => {
+ const mixed: PluginMarketplace = {
+ name: 'Community',
+ owner: 'community',
+ plugins: [
+ {
+ id: 'community.midnight',
+ source: { kind: 'git', url: 'https://github.com/community/midnight.git', ref: 'v1' },
+ categories: ['themes', 'official']
+ },
+ {
+ id: 'community.recipes',
+ source: { kind: 'git', url: 'https://github.com/community/recipes.git', ref: 'v1' },
+ categories: ['vm-recipes']
+ }
+ ]
+ }
+ const service = new PluginMarketplaceService({
+ pluginsDataDir: await tempRoot(),
+ fetcher: async () => fetched(mixed)
+ })
+
+ const added = await service.addSource(source())
+
+ // The theme pack is filtered out; only the supported recipe listing remains.
+ await expect(service.listPlugins()).resolves.toEqual([
+ expect.objectContaining({ pluginKey: 'community.recipes' })
+ ])
+
+ // Preview and install resolve by key, so an unsupported pack must be
+ // unreachable that way too — otherwise the dead install just moves later.
+ await expect(service.findPlugin(added.id, 'community.midnight')).resolves.toBeNull()
+ await expect(service.findPlugin(added.id, 'community.recipes')).resolves.toEqual(
+ expect.objectContaining({ pluginKey: 'community.recipes' })
+ )
+ })
+
+ it('seeds the official marketplace once and keeps it configured across restarts', async () => {
+ const root = await tempRoot()
+ const officialMarketplace = marketplace(
+ 'Orca Plugins',
+ 'stablyai.orca-notes',
+ 'https://github.com/stablyai/orca-notes.git'
+ )
+ officialMarketplace.owner = 'stablyai'
+ const fetcher = vi.fn(async () => fetched(officialMarketplace))
+ const first = new PluginMarketplaceService({ pluginsDataDir: root, fetcher })
+
+ await expect(first.seedOfficialSource()).resolves.toMatchObject({
+ official: true,
+ marketplace: { name: 'Orca Plugins' }
+ })
+ await expect(first.seedOfficialSource()).resolves.toMatchObject({ official: true })
+ expect(fetcher).toHaveBeenCalledTimes(1)
+
+ const restarted = new PluginMarketplaceService({ pluginsDataDir: root, fetcher })
+ await expect(restarted.seedOfficialSource()).resolves.toMatchObject({ official: true })
+ expect(fetcher).toHaveBeenCalledTimes(1)
+ await expect(restarted.listSources()).resolves.toHaveLength(1)
+ })
+
+ it('persists an offline official source for a later refresh and does not remove it', async () => {
+ const service = new PluginMarketplaceService({
+ pluginsDataDir: await tempRoot(),
+ fetcher: async () => {
+ throw new Error('offline')
+ }
+ })
+
+ const seeded = await service.seedOfficialSource()
+
+ expect(seeded).toMatchObject({ official: true, stale: true, marketplace: null })
+ await expect(service.removeSource(seeded.id)).rejects.toThrow('cannot be removed')
+ await expect(service.listSources()).resolves.toEqual([seeded])
+ })
+
+ it('keeps reads usable and allows retry after official seeding rejects', async () => {
+ const registered: PluginMarketplaceRegisteredSource = {
+ id: marketplaceSourceId(OFFICIAL_MARKETPLACE_GIT_SOURCE),
+ source: OFFICIAL_MARKETPLACE_GIT_SOURCE,
+ addedAt: 1
+ }
+ const officialMarketplace = marketplace(
+ 'Orca Plugins',
+ 'stablyai.orca-notes',
+ 'https://github.com/stablyai/orca-notes.git'
+ )
+ officialMarketplace.owner = 'stablyai'
+ const listSources = vi
+ .fn<() => Promise>()
+ .mockRejectedValueOnce(new Error('source store temporarily unavailable'))
+ .mockResolvedValue([registered])
+ const store = {
+ listSources,
+ readSnapshot: vi.fn().mockResolvedValue(null),
+ writeSnapshot: vi.fn(async ({ source: snapshotSource, ...snapshot }) => ({
+ schemaVersion: 1 as const,
+ sourceId: snapshotSource.id,
+ source: snapshotSource.source,
+ fetchedAt: 2,
+ ...snapshot
+ }))
+ } as unknown as PluginMarketplaceStore
+ const service = new PluginMarketplaceService({
+ pluginsDataDir: await tempRoot(),
+ store,
+ fetcher: async () => fetched(officialMarketplace)
+ })
+
+ await expect(service.seedOfficialSource()).rejects.toThrow('temporarily unavailable')
+ await expect(service.listSources()).resolves.toEqual([
+ expect.objectContaining({ id: registered.id, official: true })
+ ])
+ await expect(service.seedOfficialSource()).resolves.toMatchObject({
+ marketplace: { name: 'Orca Plugins' },
+ official: true
+ })
+ })
+
+ it('recovers the managed source after a full existing store frees a slot', async () => {
+ const root = await tempRoot()
+ const store = new PluginMarketplaceStore(root)
+ const registrations = await Promise.all(
+ Array.from({ length: PLUGIN_MARKETPLACE_SOURCE_LIMIT }, (_, index) =>
+ store.addSource(source(`https://example.com/community-${index}.git`), index + 1)
+ )
+ )
+ const officialMarketplace = marketplace(
+ 'Orca Plugins',
+ 'stablyai.orca-notes',
+ 'https://github.com/stablyai/orca-notes.git'
+ )
+ officialMarketplace.owner = 'stablyai'
+ const service = new PluginMarketplaceService({
+ pluginsDataDir: root,
+ store,
+ fetcher: async () => fetched(officialMarketplace)
+ })
+
+ await expect(service.seedOfficialSource()).rejects.toThrow('source limit')
+ await expect(service.removeSource(registrations[0]!.id)).resolves.toBe(true)
+
+ const sources = await service.listSources()
+ expect(sources).toHaveLength(PLUGIN_MARKETPLACE_SOURCE_LIMIT)
+ expect(sources).toContainEqual(expect.objectContaining({ official: true }))
+ })
+
+ it('removes source metadata and browse listings together', async () => {
+ const service = new PluginMarketplaceService({
+ pluginsDataDir: await tempRoot(),
+ fetcher: async () => fetched()
+ })
+ const added = await service.addSource(source())
+
+ await expect(service.removeSource(added.id)).resolves.toBe(true)
+ await expect(service.listSources()).resolves.toEqual([])
+ await expect(service.listPlugins()).resolves.toEqual([])
+ })
+})
diff --git a/src/main/plugins/plugin-marketplace-service.ts b/src/main/plugins/plugin-marketplace-service.ts
new file mode 100644
index 000000000..cdee5bf97
--- /dev/null
+++ b/src/main/plugins/plugin-marketplace-service.ts
@@ -0,0 +1,320 @@
+import {
+ OFFICIAL_MARKETPLACE_OWNER,
+ OFFICIAL_MARKETPLACE_GIT_SOURCE,
+ isOfficialMarketplaceGitSource,
+ isOfficialOrganizationGitSource,
+ isOfficialPluginIdentity,
+ isMarketplaceListingSupported,
+ pluginMarketplaceGitSourceSchema,
+ type PluginMarketplaceEntry,
+ type PluginMarketplaceGitSource
+} from '../../shared/plugins/plugin-marketplace'
+import {
+ fetchPluginMarketplace,
+ type PluginMarketplaceFetchResult
+} from './plugin-marketplace-fetch'
+import {
+ marketplaceSourceId,
+ PluginMarketplaceStore,
+ type PluginMarketplaceCachedSnapshot,
+ type PluginMarketplaceRegisteredSource
+} from './plugin-marketplace-store'
+import type { PluginKillListEntry } from '../../shared/plugins/plugin-kill-list'
+import { validateMarketplaceProvenance } from './plugin-marketplace-provenance'
+import { pluginMarketplaceErrorMessage } from './plugin-marketplace-error-message'
+import type {
+ PluginMarketplaceListing,
+ PluginMarketplaceSourceState
+} from './plugin-marketplace-projection'
+export type {
+ PluginMarketplaceListing,
+ PluginMarketplaceSourceState
+} from './plugin-marketplace-projection'
+
+type MarketplaceFetcher = (
+ source: PluginMarketplaceRegisteredSource
+) => Promise
+
+export class PluginMarketplaceService {
+ private readonly store: PluginMarketplaceStore
+ private readonly fetcher: MarketplaceFetcher
+ private readonly getKillListEntry: (pluginKey: string) => PluginKillListEntry | null
+ private readonly refreshChains = new Map>()
+ private readonly sourceErrors = new Map()
+ private officialSeedPromise: Promise | null = null
+ private officialSeedRequested = false
+
+ constructor(options: {
+ pluginsDataDir: string
+ fetcher?: MarketplaceFetcher
+ store?: PluginMarketplaceStore
+ getKillListEntry?: (pluginKey: string) => PluginKillListEntry | null
+ }) {
+ this.store = options.store ?? new PluginMarketplaceStore(options.pluginsDataDir)
+ this.fetcher = options.fetcher ?? fetchPluginMarketplace
+ this.getKillListEntry = options.getKillListEntry ?? (() => null)
+ }
+
+ async listSources(): Promise {
+ await this.waitForOfficialSeed()
+ const sources = await this.store.listSources()
+ return Promise.all(
+ sources.map(async (source) => {
+ try {
+ const error = this.sourceErrors.get(source.id)
+ return this.stateFromSnapshot(
+ source,
+ await this.store.readSnapshot(source.id),
+ Boolean(error),
+ error
+ )
+ } catch (error) {
+ return this.stateFromSnapshot(source, null, true, pluginMarketplaceErrorMessage(error))
+ }
+ })
+ )
+ }
+
+ async addSource(source: PluginMarketplaceGitSource): Promise {
+ const parsedSource = pluginMarketplaceGitSourceSchema.parse(source)
+ const sourceId = marketplaceSourceId(parsedSource)
+ const existing = (await this.store.listSources()).find((candidate) => candidate.id === sourceId)
+ const candidate: PluginMarketplaceRegisteredSource = existing ?? {
+ id: sourceId,
+ source: parsedSource,
+ addedAt: Date.now()
+ }
+ const fetched = await this.fetchAndValidate(candidate)
+ const registered = existing ?? (await this.store.addSource(parsedSource, candidate.addedAt))
+ try {
+ const snapshot = await this.store.writeSnapshot({ source: registered, ...fetched })
+ this.sourceErrors.delete(registered.id)
+ return this.stateFromSnapshot(registered, snapshot, false)
+ } catch (error) {
+ if (!existing) {
+ await this.store.removeSource(registered.id).catch(() => undefined)
+ }
+ throw error
+ }
+ }
+
+ async removeSource(sourceId: string): Promise {
+ const source = (await this.store.listSources()).find((candidate) => candidate.id === sourceId)
+ if (source && isOfficialMarketplaceGitSource(source.source.url)) {
+ throw new Error('the official marketplace is managed by Orca and cannot be removed')
+ }
+ const removed = await this.store.removeSource(sourceId)
+ if (removed) {
+ this.sourceErrors.delete(sourceId)
+ if (this.officialSeedRequested) {
+ // Why: an existing profile may already occupy every source slot. Once
+ // the user frees one, recover the managed source without a restart.
+ await this.seedOfficialSource().catch(() => undefined)
+ }
+ }
+ return removed
+ }
+
+ seedOfficialSource(): Promise {
+ this.officialSeedRequested = true
+ if (!this.officialSeedPromise) {
+ const seed = this.performOfficialSeed()
+ this.officialSeedPromise = seed
+ void seed.catch(() => {
+ if (this.officialSeedPromise === seed) {
+ // Why: a transient store failure or full source list must not poison
+ // every marketplace read or prevent a later recovery attempt.
+ this.officialSeedPromise = null
+ }
+ })
+ }
+ return this.officialSeedPromise
+ }
+
+ async refreshSource(sourceId: string): Promise {
+ const previous = this.refreshChains.get(sourceId) ?? Promise.resolve(null)
+ const refresh = previous.catch(() => null).then(() => this.performRefresh(sourceId))
+ this.refreshChains.set(sourceId, refresh)
+ try {
+ return await refresh
+ } finally {
+ if (this.refreshChains.get(sourceId) === refresh) {
+ this.refreshChains.delete(sourceId)
+ }
+ }
+ }
+
+ async refreshAll(): Promise {
+ await this.waitForOfficialSeed()
+ const sources = await this.store.listSources()
+ return Promise.all(sources.map((source) => this.refreshSource(source.id)))
+ }
+
+ async listPlugins(): Promise {
+ await this.waitForOfficialSeed()
+ const states = await this.listSnapshots()
+ 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))
+ )
+ .sort((left, right) =>
+ `${left.pluginKey}\0${left.marketplaceSourceId}`.localeCompare(
+ `${right.pluginKey}\0${right.marketplaceSourceId}`
+ )
+ )
+ }
+
+ async findPlugin(
+ marketplaceSourceId: string,
+ pluginKey: string
+ ): Promise {
+ const source = (await this.store.listSources()).find(
+ (candidate) => candidate.id === marketplaceSourceId
+ )
+ if (!source) {
+ return null
+ }
+ const snapshot = await this.store.readSnapshot(source.id)
+ // Why: preview and install resolve listings through here, so an unsupported
+ // pack must be unreachable by key too — hiding only the catalog card would
+ // move the dead install one click later instead of removing it.
+ const entry = snapshot?.marketplace.plugins.find(
+ (plugin) => plugin.id === pluginKey && isMarketplaceListingSupported(plugin.categories)
+ )
+ return snapshot && entry ? this.listingFromEntry(source, snapshot, entry) : null
+ }
+
+ private async performRefresh(sourceId: string): Promise {
+ const source = (await this.store.listSources()).find((candidate) => candidate.id === sourceId)
+ if (!source) {
+ throw new Error(`unknown marketplace source: ${sourceId}`)
+ }
+ try {
+ const fetched = await this.fetchAndValidate(source)
+ const snapshot = await this.store.writeSnapshot({ source, ...fetched })
+ this.sourceErrors.delete(source.id)
+ return this.stateFromSnapshot(source, snapshot, false)
+ } catch (error) {
+ const cached = await this.store.readSnapshot(source.id).catch(() => null)
+ if (!cached) {
+ throw error
+ }
+ const message = pluginMarketplaceErrorMessage(error)
+ this.sourceErrors.set(source.id, message)
+ return this.stateFromSnapshot(source, cached, true, message)
+ }
+ }
+
+ private async performOfficialSeed(): Promise {
+ const sources = await this.store.listSources()
+ const existing = sources.find((source) => isOfficialMarketplaceGitSource(source.source.url))
+ const source =
+ existing ?? (await this.store.addSource(OFFICIAL_MARKETPLACE_GIT_SOURCE, Date.now()))
+ const snapshot = await this.store.readSnapshot(source.id).catch(() => null)
+ if (snapshot) {
+ return this.stateFromSnapshot(source, snapshot, false)
+ }
+ try {
+ return await this.performRefresh(source.id)
+ } catch (error) {
+ // Why: the official source remains configured offline so a later manual
+ // or startup refresh can recover without asking the user for its URL.
+ const message = pluginMarketplaceErrorMessage(error)
+ this.sourceErrors.set(source.id, message)
+ return this.stateFromSnapshot(source, null, true, message)
+ }
+ }
+
+ private async waitForOfficialSeed(): Promise {
+ await this.officialSeedPromise?.catch(() => undefined)
+ }
+
+ private async fetchAndValidate(
+ source: PluginMarketplaceRegisteredSource
+ ): Promise {
+ const fetched = await this.fetcher(source)
+ validateMarketplaceProvenance(source, fetched)
+ return fetched
+ }
+
+ private async listSnapshots(): Promise<
+ { source: PluginMarketplaceRegisteredSource; snapshot: PluginMarketplaceCachedSnapshot }[]
+ > {
+ const sources = await this.store.listSources()
+ const snapshots = await Promise.all(
+ sources.map(async (source) => ({
+ source,
+ snapshot: await this.store.readSnapshot(source.id)
+ }))
+ )
+ return snapshots.filter(
+ (
+ candidate
+ ): candidate is {
+ source: PluginMarketplaceRegisteredSource
+ snapshot: PluginMarketplaceCachedSnapshot
+ } => candidate.snapshot !== null
+ )
+ }
+
+ private listingFromEntry(
+ source: PluginMarketplaceRegisteredSource,
+ 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,
+ ...(blocked
+ ? {
+ blockedByKillList: {
+ reason: blocked.reason,
+ ...(blocked.advisoryUrl ? { advisoryUrl: blocked.advisoryUrl } : {})
+ }
+ }
+ : {})
+ }
+ }
+
+ private stateFromSnapshot(
+ source: PluginMarketplaceRegisteredSource,
+ snapshot: PluginMarketplaceCachedSnapshot | null,
+ stale: boolean,
+ error?: string
+ ): PluginMarketplaceSourceState {
+ return {
+ id: source.id,
+ source: source.source,
+ addedAt: source.addedAt,
+ marketplace: snapshot
+ ? {
+ name: snapshot.marketplace.name,
+ owner: snapshot.marketplace.owner,
+ resolvedCommit: snapshot.marketplaceCommit,
+ fetchedAt: snapshot.fetchedAt
+ }
+ : null,
+ stale,
+ official: isOfficialMarketplaceGitSource(source.source.url),
+ ...(error ? { error } : {})
+ }
+ }
+}
diff --git a/src/main/plugins/plugin-marketplace-store.test.ts b/src/main/plugins/plugin-marketplace-store.test.ts
new file mode 100644
index 000000000..460703733
--- /dev/null
+++ b/src/main/plugins/plugin-marketplace-store.test.ts
@@ -0,0 +1,85 @@
+import { mkdtemp, readdir, rm } from 'node:fs/promises'
+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 { marketplaceSourceId, PluginMarketplaceStore } from './plugin-marketplace-store'
+
+const roots: string[] = []
+
+async function tempRoot(): Promise {
+ const root = await mkdtemp(join(tmpdir(), 'orca-marketplace-store-'))
+ roots.push(root)
+ return root
+}
+
+function source(ref = 'main'): PluginMarketplaceGitSource {
+ return { kind: 'git', url: 'https://github.com/community/plugins.git', ref }
+}
+
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+describe('PluginMarketplaceStore', () => {
+ it('persists bounded source registrations under a deterministic opaque id', async () => {
+ const root = await tempRoot()
+ const first = new PluginMarketplaceStore(root)
+ const registered = await first.addSource(source(), 123)
+
+ expect(registered).toEqual({
+ id: marketplaceSourceId(source()),
+ source: source(),
+ addedAt: 123
+ })
+ await expect(new PluginMarketplaceStore(root).listSources()).resolves.toEqual([registered])
+ await expect(first.addSource(source(), 999)).resolves.toEqual(registered)
+ })
+
+ it('atomically publishes a strict cached snapshot and removes it with its source', async () => {
+ const root = await tempRoot()
+ const store = new PluginMarketplaceStore(root)
+ const registered = await store.addSource(source(), 123)
+ const snapshot = await store.writeSnapshot({
+ source: registered,
+ marketplaceCommit: 'a'.repeat(40),
+ fetchedAt: 456,
+ marketplace: {
+ name: 'Community',
+ owner: 'community',
+ plugins: [
+ {
+ id: 'community.theme',
+ source: {
+ kind: 'git',
+ url: 'https://github.com/community/theme.git',
+ ref: 'v1'
+ },
+ categories: ['themes']
+ }
+ ]
+ }
+ })
+
+ await expect(new PluginMarketplaceStore(root).readSnapshot(registered.id)).resolves.toEqual(
+ snapshot
+ )
+ expect(
+ (await readdir(join(root, 'marketplaces', 'snapshots'))).filter((entry) =>
+ entry.endsWith('.tmp')
+ )
+ ).toEqual([])
+ await expect(store.removeSource(registered.id)).resolves.toBe(true)
+ await expect(store.readSnapshot(registered.id)).resolves.toBeNull()
+ })
+
+ it('keeps distinct refs as distinct marketplace sources', () => {
+ expect(marketplaceSourceId(source('main'))).not.toBe(marketplaceSourceId(source('stable')))
+ })
+
+ it('rejects path-like source ids before reading or deleting cache files', async () => {
+ const store = new PluginMarketplaceStore(await tempRoot())
+ await expect(store.readSnapshot('../outside')).rejects.toThrow()
+ await expect(store.removeSource('../outside')).rejects.toThrow()
+ })
+})
diff --git a/src/main/plugins/plugin-marketplace-store.ts b/src/main/plugins/plugin-marketplace-store.ts
new file mode 100644
index 000000000..437f6148f
--- /dev/null
+++ b/src/main/plugins/plugin-marketplace-store.ts
@@ -0,0 +1,212 @@
+import { createHash } from 'node:crypto'
+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 {
+ pluginMarketplaceGitSourceSchema,
+ pluginMarketplaceSchema,
+ type PluginMarketplace,
+ type PluginMarketplaceGitSource
+} from '../../shared/plugins/plugin-marketplace'
+import { writePluginFileAtomically } from './plugin-atomic-file-write'
+
+export const PLUGIN_MARKETPLACE_SOURCE_LIMIT = 64
+export const PLUGIN_MARKETPLACE_SOURCE_ID_PATTERN = /^[0-9a-f]{32}$/
+
+const sourceIdSchema = z.string().regex(PLUGIN_MARKETPLACE_SOURCE_ID_PATTERN)
+const registeredSourceSchema = z.strictObject({
+ id: sourceIdSchema,
+ source: pluginMarketplaceGitSourceSchema,
+ addedAt: z.number().finite().nonnegative()
+})
+const sourceFileSchema = z.strictObject({
+ schemaVersion: z.literal(1),
+ sources: z.array(registeredSourceSchema).max(PLUGIN_MARKETPLACE_SOURCE_LIMIT)
+})
+const cachedSnapshotSchema = z.strictObject({
+ schemaVersion: z.literal(1),
+ sourceId: sourceIdSchema,
+ source: pluginMarketplaceGitSourceSchema,
+ marketplaceCommit: z.string().regex(PLUGIN_COMMIT_PATTERN),
+ fetchedAt: z.number().finite().nonnegative(),
+ marketplace: pluginMarketplaceSchema
+})
+
+export type PluginMarketplaceRegisteredSource = z.infer
+export type PluginMarketplaceCachedSnapshot = z.infer
+
+const SOURCE_FILE_MAX_BYTES = 2 * 1024 * 1024
+const SNAPSHOT_FILE_MAX_BYTES = 16 * 1024 * 1024
+
+export function marketplaceSourceId(source: PluginMarketplaceGitSource): string {
+ const parsed = pluginMarketplaceGitSourceSchema.parse(source)
+ return createHash('sha256')
+ .update(`orca-plugin-marketplace-source-v1\0${parsed.url}\0${parsed.ref}`)
+ .digest('hex')
+ .slice(0, 32)
+}
+
+export class PluginMarketplaceStore {
+ private readonly sourcesPath: string
+ private readonly snapshotDirectory: string
+ private sources: PluginMarketplaceRegisteredSource[] | null = null
+ private writeChain: Promise