mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
feat(plugins): add experimental P0 kernel and enforcement
This commit is contained in:
@@ -109,6 +109,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/**',
|
||||
|
||||
@@ -4173,6 +4173,113 @@
|
||||
"Windows runtime watches bypass this path; SSH registration ownership is deterministic-contract tested but not live-relay fault-injected."
|
||||
],
|
||||
"demotionRule": "Demote or quarantine if the fault harness flakes without a product or harness bug, if healthy operation exceeds four runtime watcher children, if total healthy plus quarantine operation exceeds eight, 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": [
|
||||
"docs/plugins/02-architecture.md#4-capability-system",
|
||||
"docs/plugins/04-roadmap.md#phase-0--kernel-m-46-wks"
|
||||
],
|
||||
"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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -446,6 +446,10 @@ export class AgentHookServer {
|
||||
private onAgentStatus: ((payload: EnrichedAgentHookEventPayload) => void) | null = null
|
||||
private onPaneStatusCleared: PaneStatusClearListener | null = null
|
||||
private statusChangeListeners = new Set<StatusChangeListener>()
|
||||
// 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: directory that holds the on-disk endpoint file. Set via start()'s
|
||||
// `userDataPath` option so the class has no direct Electron dependency
|
||||
// (keeps it mockable in the vitest node environment).
|
||||
@@ -506,6 +510,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
|
||||
}
|
||||
@@ -827,6 +839,13 @@ export class AgentHookServer {
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
this.onAgentStatus?.(enriched)
|
||||
for (const listener of this.enrichedStatusListeners) {
|
||||
try {
|
||||
listener(enriched)
|
||||
} catch (err) {
|
||||
console.error('[agent-hooks] enriched status listener threw', err)
|
||||
}
|
||||
}
|
||||
return enriched
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +116,10 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
terminalScopeHistoryByWorktree: true,
|
||||
defaultTuiAgent: null,
|
||||
disabledTuiAgents: [],
|
||||
pluginSystemEnabled: false,
|
||||
disabledPlugins: [],
|
||||
pluginConsents: {},
|
||||
devPluginPaths: [],
|
||||
skipDeleteWorktreeConfirm: false,
|
||||
skipCloseTerminalWithRunningProcessConfirm: false,
|
||||
skipDeleteAutomationConfirm: false,
|
||||
|
||||
@@ -121,6 +121,10 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
terminalScopeHistoryByWorktree: true,
|
||||
defaultTuiAgent: null,
|
||||
disabledTuiAgents: [],
|
||||
pluginSystemEnabled: false,
|
||||
disabledPlugins: [],
|
||||
pluginConsents: {},
|
||||
devPluginPaths: [],
|
||||
skipDeleteWorktreeConfirm: false,
|
||||
skipCloseTerminalWithRunningProcessConfirm: false,
|
||||
skipDeleteAutomationConfirm: false,
|
||||
|
||||
+91
-4
@@ -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 { OrcaRuntimeRpcServer } from './runtime/runtime-rpc'
|
||||
import { awaitRuntimeFileWatcherUnsubscribes } from './runtime/orca-runtime-files'
|
||||
import { clearRuntimeMetadataIfOwned } from './runtime/runtime-metadata'
|
||||
@@ -159,6 +159,14 @@ import { createHeadlessAutomationOutputSnapshotBuffer } from './automations/head
|
||||
import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headless-workspace-create'
|
||||
import { AgentAwakeService } from './agent-awake-service'
|
||||
import { registerSystemResumeBroadcast } from './system-resume-broadcast'
|
||||
import { PluginService } from './plugins/plugin-service'
|
||||
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
|
||||
@@ -220,7 +228,17 @@ 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 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/teardown intent set for one renderer must not leak to a later load.
|
||||
// The recovery reload re-fires did-finish-load, so its flag lets the local-PTY orphan
|
||||
// sweep spare live sessions across that one reload (#5787).
|
||||
@@ -953,7 +971,8 @@ function openMainWindow(): BrowserWindow {
|
||||
isQuitting = true
|
||||
await preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store })
|
||||
}
|
||||
}
|
||||
},
|
||||
pluginService ?? undefined
|
||||
)
|
||||
automations.setWebContents(window.webContents)
|
||||
automations.start()
|
||||
@@ -975,7 +994,8 @@ function openMainWindow(): BrowserWindow {
|
||||
// that re-fires did-finish-load, so live local sessions survive it (#5787).
|
||||
isRecoveryReloadInFlight,
|
||||
onBeforeUpdateQuit: () =>
|
||||
preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store })
|
||||
preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }),
|
||||
onWorktreeLifecycle: emitPluginWorktreeLifecycle
|
||||
}
|
||||
)
|
||||
rateLimits.attach(window)
|
||||
@@ -1909,6 +1929,60 @@ app.whenReady().then(async () => {
|
||||
prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch,
|
||||
prepareForClaudeLaunch: (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target)
|
||||
})
|
||||
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),
|
||||
hostEntryPath: resolvePluginHostEntryPath(app.getAppPath(), app.isPackaged)
|
||||
})
|
||||
// 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.
|
||||
const pluginSystemStartupStartedAt = performance.now()
|
||||
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)
|
||||
})
|
||||
pluginService.onChanged(() => {
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
if (!window.isDestroyed()) {
|
||||
window.webContents.send('plugins:changed')
|
||||
}
|
||||
}
|
||||
})
|
||||
// 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()
|
||||
@@ -2254,6 +2328,13 @@ app.on('will-quit', (e) => {
|
||||
// agent_start events with no matching stops.
|
||||
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 allSettled barrier below — quitting before it resolves would let
|
||||
// Electron exit first and orphan the hosts.
|
||||
setPluginServiceForRpc(null)
|
||||
const pluginHostShutdown = pluginService?.dispose() ?? Promise.resolve()
|
||||
pluginService = null
|
||||
setUnreadDockBadgeCount(0)
|
||||
agentHookServer.stop()
|
||||
// Why: cancels relay restart/reinstall timers and kills wsl.exe children
|
||||
@@ -2318,7 +2399,13 @@ app.on('will-quit', (e) => {
|
||||
// Why: normal quits preserve the detached daemon for warm reattach, but a
|
||||
// dev parent dying means the temp/dev profile has no owner left to reattach.
|
||||
const daemonTeardown = isDevParentShutdownRequested() ? shutdownDaemon() : disconnectDaemon()
|
||||
Promise.allSettled([daemonTeardown, rpcStopAndClear, watcherShutdown, emulatorShutdown])
|
||||
Promise.allSettled([
|
||||
daemonTeardown,
|
||||
rpcStopAndClear,
|
||||
watcherShutdown,
|
||||
emulatorShutdown,
|
||||
pluginHostShutdown
|
||||
])
|
||||
.then(() => shutdownTelemetry())
|
||||
.then(() => shutdownObservability())
|
||||
.catch(() => {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PluginService } from '../plugins/plugin-service'
|
||||
import type { Store } from '../persistence'
|
||||
|
||||
const electronMocks = vi.hoisted(() => ({ handle: vi.fn() }))
|
||||
vi.mock('electron', () => ({ ipcMain: { handle: electronMocks.handle } }))
|
||||
|
||||
import {
|
||||
canRemoveInstalledPlugin,
|
||||
parsePluginConsentArgs,
|
||||
parsePluginInstallArgs,
|
||||
registerPluginHandlers
|
||||
} from './plugins'
|
||||
|
||||
beforeEach(() => electronMocks.handle.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)
|
||||
})
|
||||
})
|
||||
|
||||
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,223 @@
|
||||
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 } from '../../shared/plugins/plugin-install-lockfile'
|
||||
|
||||
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): boolean {
|
||||
return 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
|
||||
): 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: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 result =
|
||||
parsed.kind === 'local-path'
|
||||
? await installPluginFromLocalPath({ pluginsDir, sourcePath: parsed.path, hostVersion })
|
||||
: await installPluginFromGit({ pluginsDir, url: parsed.url, ref: parsed.ref, hostVersion })
|
||||
if (result.ok) {
|
||||
await pluginService.refresh()
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
ipcMain.handle('plugins:remove', async (event, args: unknown) => {
|
||||
await pluginService.whenReady()
|
||||
const parsed = removeArgsSchema.parse(args)
|
||||
if (!canRemoveInstalledPlugin(pluginService, parsed.pluginKey)) {
|
||||
throw new Error(`cannot remove non-installed plugin ${parsed.pluginKey}`)
|
||||
}
|
||||
await pluginService.deactivatePlugin(parsed.pluginKey)
|
||||
await removeInstalledPlugin({
|
||||
pluginsDir: getUserPluginsDir(pluginService.options.userDataPath),
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -46,6 +46,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'
|
||||
@@ -76,6 +77,7 @@ import {
|
||||
getSavedRuntimeAiVaultHostInfos,
|
||||
scanRuntimeAiVaultSessions
|
||||
} from '../ai-vault/runtime-session-scanner'
|
||||
import type { PluginService } from '../plugins/plugin-service'
|
||||
|
||||
let registered = false
|
||||
|
||||
@@ -100,7 +102,8 @@ export function registerCoreHandlers(
|
||||
agentAwakeService?: AgentAwakeService,
|
||||
crashReports?: CrashReportStore,
|
||||
keybindings?: KeybindingService,
|
||||
lifecycleOptions: CoreHandlerLifecycleOptions = {}
|
||||
lifecycleOptions: CoreHandlerLifecycleOptions = {},
|
||||
pluginService?: PluginService
|
||||
): void {
|
||||
// Why: on macOS the app can stay alive after all windows close, then
|
||||
// openMainWindow() is called again on 'activate'. ipcMain.handle() throws
|
||||
@@ -158,6 +161,9 @@ export function registerCoreHandlers(
|
||||
if (keybindings) {
|
||||
registerKeybindingHandlers(keybindings)
|
||||
}
|
||||
if (pluginService) {
|
||||
registerPluginHandlers(store, pluginService, runtime)
|
||||
}
|
||||
registerTelemetryHandlers(store)
|
||||
registerOrcaProfileHandlers(store, {
|
||||
onBeforeRelaunch: lifecycleOptions.onBeforeRelaunch
|
||||
|
||||
@@ -325,6 +325,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 })
|
||||
|
||||
@@ -36,6 +36,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
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ import {
|
||||
registerWorktreeRootsForRepo
|
||||
} from './filesystem-auth'
|
||||
import { closeLocalWatcherForWorktreePath } from './filesystem-watcher'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import type { OrcaRuntimeService, RuntimeWorktreeLifecycleEvent } from '../runtime/orca-runtime'
|
||||
import { killAllProcessesForWorktree } from '../runtime/worktree-teardown'
|
||||
import { clearProviderPtyState, getLocalPtyProvider } from './pty'
|
||||
import { removeWorktreeLinkedPaths } from './worktree-symlinks'
|
||||
@@ -969,7 +969,8 @@ function buildDisconnectedDetectedWorktrees(
|
||||
export function registerWorktreeHandlers(
|
||||
mainWindow: BrowserWindow,
|
||||
store: Store,
|
||||
runtime: OrcaRuntimeService
|
||||
runtime: OrcaRuntimeService,
|
||||
options?: { onWorktreeLifecycle?: (event: RuntimeWorktreeLifecycleEvent) => void }
|
||||
): void {
|
||||
// Remove any previously registered handlers so we can re-register them
|
||||
// (e.g. when macOS re-activates the app and creates a new window).
|
||||
@@ -1292,6 +1293,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
|
||||
})
|
||||
}
|
||||
@@ -1888,7 +1896,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)
|
||||
|
||||
@@ -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,121 @@
|
||||
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'
|
||||
|
||||
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
|
||||
|
||||
function declaredArtifactPaths(
|
||||
manifest: PluginManifest
|
||||
): { label: string; path: string; maxBytes: number }[] {
|
||||
return [
|
||||
...(manifest.icon
|
||||
? [{ label: 'icon', path: manifest.icon, maxBytes: PLUGIN_ICON_MAX_BYTES }]
|
||||
: []),
|
||||
...(manifest.main
|
||||
? [
|
||||
{
|
||||
label: 'worker entry',
|
||||
path: manifest.main,
|
||||
maxBytes: PLUGIN_WORKER_ENTRY_MAX_BYTES
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...manifest.contributes.panels.map((panel) => ({
|
||||
label: `panel "${panel.id}" entry`,
|
||||
path: panel.entry,
|
||||
maxBytes: PLUGIN_PANEL_ENTRY_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 resolveArtifactFromRealRoot(rootDir, rootReal, relativePath, 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 resolveArtifactFromRealRoot(
|
||||
rootDir: string,
|
||||
rootReal: string,
|
||||
relativePath: string,
|
||||
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 (!artifactStat.isFile()) {
|
||||
throw new Error('is not a regular file')
|
||||
}
|
||||
if (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 resolveArtifactFromRealRoot(rootDir, rootReal, artifact.path, artifact.maxBytes)
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `${artifact.label} ${artifact.path}: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
@@ -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,129 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { readdir, lstat } 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,224 @@
|
||||
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
|
||||
} 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
|
||||
}
|
||||
|
||||
function manifest(overrides: Partial<PluginManifest> = {}): PluginManifest {
|
||||
return pluginManifestSchema.parse({
|
||||
manifestVersion: 1,
|
||||
id: 'demo',
|
||||
publisher: 'orca-samples',
|
||||
name: 'Demo',
|
||||
version: '1.0.0',
|
||||
engines: { orca: '>=1.0.0' },
|
||||
pluginApi: 1,
|
||||
contributes: { panels: [], commands: [], events: [] },
|
||||
capabilities: [],
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('declared plugin artifacts', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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,51 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
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> {
|
||||
const target = join(pluginDir, PLUGIN_CURRENT_POINTER_FILENAME)
|
||||
const temporary = `${target}.tmp`
|
||||
await writeFile(temporary, contentHash, 'utf8')
|
||||
await rename(temporary, target)
|
||||
}
|
||||
|
||||
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,75 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,234 @@
|
||||
import { readdir } from 'node:fs/promises'
|
||||
import type { Dirent } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
PLUGIN_MANIFEST_FILENAME,
|
||||
isQualifiedPluginKey,
|
||||
parsePluginManifest,
|
||||
qualifiedPluginKey,
|
||||
satisfiesOrcaEngineRange,
|
||||
type PluginManifest
|
||||
} from '../../shared/plugins/plugin-manifest'
|
||||
import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint'
|
||||
import { validateDeclaredPluginArtifacts } from './plugin-artifact-validation'
|
||||
import { readPluginManifestText } from './plugin-manifest-file'
|
||||
import { readPluginCurrentPointer } from './plugin-current-pointer'
|
||||
|
||||
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
|
||||
/** 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
|
||||
): 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
|
||||
}
|
||||
}
|
||||
return {
|
||||
pluginKey,
|
||||
rootDir,
|
||||
manifest,
|
||||
consentFingerprint: fingerprintPluginConsent(manifest),
|
||||
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 (!/^(?:[0-9a-f]{32}|[0-9a-f]{64})$/.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)
|
||||
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,109 @@
|
||||
import { tmpdir } from 'node:os'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultSettings } from '../../shared/constants'
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
import type { Store } from '../persistence'
|
||||
import { applyPluginConsent, applyPluginEnablement } from './plugin-enablement'
|
||||
import type { PluginService } from './plugin-service'
|
||||
|
||||
const pluginKey = 'orca-samples.demo'
|
||||
|
||||
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): PluginService {
|
||||
return {
|
||||
findValidPlugin: (requestedKey: string) =>
|
||||
requestedKey === pluginKey
|
||||
? ({ pluginKey, consentFingerprint: getFingerprint() } as ReturnType<
|
||||
PluginService['findValidPlugin']
|
||||
>)
|
||||
: 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,81 @@
|
||||
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'
|
||||
|
||||
/**
|
||||
* 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`)
|
||||
}
|
||||
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,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,87 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
emptyPluginLockfile,
|
||||
parsePluginLockfile,
|
||||
serializePluginLockfile,
|
||||
type PluginLockfile
|
||||
} from '../../shared/plugins/plugin-install-lockfile'
|
||||
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 })
|
||||
const target = pluginLockfilePath(pluginsDir)
|
||||
const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`
|
||||
try {
|
||||
await writeFile(temporary, JSON.stringify(serializePluginLockfile(lock), null, 2), 'utf8')
|
||||
await rename(temporary, target)
|
||||
} finally {
|
||||
await rm(temporary, { force: true })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { mkdir, readdir, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
pluginLockEntrySchema,
|
||||
type PluginLockEntry,
|
||||
type PluginLockfile
|
||||
} from '../../shared/plugins/plugin-install-lockfile'
|
||||
import { isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest'
|
||||
import { readPluginCurrentPointer } from './plugin-current-pointer'
|
||||
|
||||
const PROVENANCE_DIRECTORY = '.install-provenance'
|
||||
const PROVENANCE_MAX_BYTES = 64 * 1024
|
||||
const CONTENT_HASH_PATTERN = /^(?:[0-9a-f]{32}|[0-9a-f]{64})$/
|
||||
|
||||
function provenancePath(pluginDir: string, contentHash: string): string {
|
||||
if (!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 })
|
||||
const target = provenancePath(pluginDir, parsedEntry.contentHash)
|
||||
const temporary = `${target}.tmp`
|
||||
try {
|
||||
await writeFile(temporary, JSON.stringify({ version: 1, entry: parsedEntry }, null, 2), {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600
|
||||
})
|
||||
await rename(temporary, target)
|
||||
} finally {
|
||||
await rm(temporary, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
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 || !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') &&
|
||||
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,92 @@
|
||||
import { readdir, rm } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
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'
|
||||
|
||||
const CONTENT_HASH_PATTERN = /^(?:[0-9a-f]{32}|[0-9a-f]{64})$/
|
||||
|
||||
/** 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' && 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() && 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,497 @@
|
||||
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 } 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'
|
||||
|
||||
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 () => {
|
||||
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('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,326 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { cp, mkdir, mkdtemp, realpath, rename, 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 { promisify } from 'node:util'
|
||||
import {
|
||||
PLUGIN_MANIFEST_FILENAME,
|
||||
isQualifiedPluginKey,
|
||||
parsePluginManifest,
|
||||
qualifiedPluginKey,
|
||||
satisfiesOrcaEngineRange
|
||||
} from '../../shared/plugins/plugin-manifest'
|
||||
import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint'
|
||||
import {
|
||||
isAllowedPluginGitUrl,
|
||||
removePluginLock,
|
||||
type PluginInstallSource,
|
||||
type PluginLockEntry
|
||||
} from '../../shared/plugins/plugin-install-lockfile'
|
||||
import { hashPluginTree } from './plugin-content-hash'
|
||||
import { validateDeclaredPluginArtifacts } from './plugin-artifact-validation'
|
||||
import { readPluginManifestText } from './plugin-manifest-file'
|
||||
import { readPluginLockfile, writePluginLockfile } from './plugin-install-lockfile-store'
|
||||
import { publishPluginInstall } from './plugin-install-publication'
|
||||
|
||||
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 execFileAsync = promisify(execFile)
|
||||
const GIT_TIMEOUT_MS = 120_000
|
||||
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 type PluginInstallResult =
|
||||
| {
|
||||
ok: true
|
||||
pluginKey: string
|
||||
version: string
|
||||
contentHash: string
|
||||
consentFingerprint: string
|
||||
resolvedCommit: string | null
|
||||
}
|
||||
| { ok: false; error: string }
|
||||
|
||||
/** Installs a validated staging tree into the hash-addressed layout. */
|
||||
async function installStagedTree(input: {
|
||||
pluginsDir: string
|
||||
stagingDir: string
|
||||
hostVersion: string
|
||||
source: PluginInstallSource
|
||||
resolvedCommit: string | null
|
||||
}): Promise<PluginInstallResult> {
|
||||
let manifestRaw: unknown
|
||||
try {
|
||||
manifestRaw = JSON.parse(await readPluginManifestText(input.stagingDir))
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `unreadable ${PLUGIN_MANIFEST_FILENAME}: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
}
|
||||
const parsed = parsePluginManifest(manifestRaw)
|
||||
if (!parsed.ok) {
|
||||
return { ok: false, error: `invalid manifest: ${parsed.error}` }
|
||||
}
|
||||
const manifest = parsed.manifest
|
||||
if (!satisfiesOrcaEngineRange(input.hostVersion, manifest.engines.orca)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `plugin requires Orca ${manifest.engines.orca} (this is ${input.hostVersion})`
|
||||
}
|
||||
}
|
||||
const declaredArtifacts = await validateDeclaredPluginArtifacts(input.stagingDir, manifest)
|
||||
if (!declaredArtifacts.ok) {
|
||||
return { ok: false, error: `invalid declared artifact: ${declaredArtifacts.error}` }
|
||||
}
|
||||
// Hash before copy: also enforces the symlink/entry-count/size limits.
|
||||
const treeHash = await hashPluginTree(input.stagingDir)
|
||||
if (!treeHash.ok) {
|
||||
return { ok: false, error: treeHash.error }
|
||||
}
|
||||
const pluginKey = qualifiedPluginKey(manifest)
|
||||
const pluginDir = join(input.pluginsDir, pluginKey)
|
||||
const versionDir = join(pluginDir, treeHash.hash)
|
||||
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 !== treeHash.hash) {
|
||||
return {
|
||||
ok: false,
|
||||
error: copiedHash.ok
|
||||
? 'plugin content changed while it was being copied'
|
||||
: copiedHash.error
|
||||
}
|
||||
}
|
||||
const copiedArtifacts = await validateDeclaredPluginArtifacts(stagedVersionDir, manifest)
|
||||
if (!copiedArtifacts.ok) {
|
||||
return { ok: false, error: `copied artifact validation failed: ${copiedArtifacts.error}` }
|
||||
}
|
||||
await rename(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 !== treeHash.hash) {
|
||||
return {
|
||||
ok: false,
|
||||
error: existingHash.ok
|
||||
? 'existing plugin content failed integrity verification'
|
||||
: existingHash.error
|
||||
}
|
||||
}
|
||||
const existingArtifacts = await validateDeclaredPluginArtifacts(versionDir, manifest)
|
||||
if (!existingArtifacts.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `installed artifact validation failed: ${existingArtifacts.error}`
|
||||
}
|
||||
}
|
||||
}
|
||||
const consentFingerprint = fingerprintPluginConsent(manifest)
|
||||
const entry: PluginLockEntry = {
|
||||
pluginKey,
|
||||
version: manifest.version,
|
||||
source: input.source,
|
||||
resolvedCommit: input.resolvedCommit,
|
||||
contentHash: treeHash.hash,
|
||||
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: treeHash.hash,
|
||||
consentFingerprint,
|
||||
resolvedCommit: input.resolvedCommit
|
||||
}
|
||||
}
|
||||
|
||||
export async function installPluginFromLocalPath(input: {
|
||||
pluginsDir: string
|
||||
sourcePath: string
|
||||
hostVersion: string
|
||||
}): 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 installStagedTree({
|
||||
pluginsDir: input.pluginsDir,
|
||||
stagingDir: input.sourcePath,
|
||||
hostVersion: input.hostVersion,
|
||||
source: { kind: 'local-path', path: input.sourcePath },
|
||||
resolvedCommit: null
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function runGit(args: string[], cwd: string): Promise<string> {
|
||||
const { stdout } = await execFileAsync('git', args, {
|
||||
cwd,
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...process.env,
|
||||
// Never block an install on an interactive credential/host prompt.
|
||||
GIT_TERMINAL_PROMPT: '0'
|
||||
}
|
||||
})
|
||||
return stdout.trim()
|
||||
}
|
||||
|
||||
export async function installPluginFromGit(input: {
|
||||
pluginsDir: string
|
||||
url: string
|
||||
/** `#ref` suffix: branch, tag, or full commit SHA. Empty = default branch. */
|
||||
ref: string
|
||||
hostVersion: string
|
||||
}): 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()
|
||||
if (/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(ref)) {
|
||||
// Exact commit: shallow-fetch just that object.
|
||||
await runGit(['init', '--quiet', stagingDir], tmpdir())
|
||||
await runGit(['remote', 'add', 'origin', input.url], stagingDir)
|
||||
await runGit(['fetch', '--quiet', '--depth', '1', 'origin', ref], stagingDir)
|
||||
await runGit(['checkout', '--quiet', 'FETCH_HEAD'], stagingDir)
|
||||
} else {
|
||||
const args = ['clone', '--quiet', '--depth', '1']
|
||||
if (ref.length > 0) {
|
||||
args.push('--branch', ref)
|
||||
}
|
||||
args.push('--', input.url, stagingDir)
|
||||
await runGit(args, tmpdir())
|
||||
}
|
||||
const resolvedCommit = await runGit(['rev-parse', 'HEAD'], stagingDir)
|
||||
return await installStagedTree({
|
||||
pluginsDir: input.pluginsDir,
|
||||
stagingDir,
|
||||
hostVersion: input.hostVersion,
|
||||
source: { kind: 'git', url: input.url, ref },
|
||||
resolvedCommit
|
||||
})
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
||||
} finally {
|
||||
await rm(stagingDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 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,123 @@
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { emptyPluginLockfile } from '../../shared/plugins/plugin-install-lockfile'
|
||||
import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest'
|
||||
import type { InvalidDiscoveredPlugin, ValidDiscoveredPlugin } from './plugin-discovery'
|
||||
import { buildPluginList } from './plugin-list-projection'
|
||||
import type { PluginService } from './plugin-service'
|
||||
|
||||
const manifest = pluginManifestSchema.parse({
|
||||
manifestVersion: 1,
|
||||
id: 'demo',
|
||||
publisher: 'orca-samples',
|
||||
name: 'Demo',
|
||||
version: '1.0.0',
|
||||
engines: { orca: '>=1.0.0' },
|
||||
pluginApi: 1,
|
||||
contributes: { panels: [], commands: [], events: [] },
|
||||
capabilities: [{ kind: 'workspace:read' }]
|
||||
})
|
||||
|
||||
function serviceWith(
|
||||
discovered: ValidDiscoveredPlugin,
|
||||
options: {
|
||||
activation?: ReturnType<PluginService['activationState']>
|
||||
worker?: ReturnType<PluginService['workerState']>
|
||||
} = {}
|
||||
): PluginService {
|
||||
return {
|
||||
options: {
|
||||
getPluginConsents: () => ({}),
|
||||
getDisabledPlugins: () => []
|
||||
},
|
||||
getDiscovered: () => [discovered],
|
||||
activationState: () => options.activation ?? 'pending',
|
||||
workerState: () => options.worker ?? { state: 'inactive', restarts: 0 },
|
||||
activationError: () => null
|
||||
} as unknown as PluginService
|
||||
}
|
||||
|
||||
describe('buildPluginList consent identity', () => {
|
||||
it('projects the exact current fingerprint for an optimistic consent write', () => {
|
||||
const plugin: ValidDiscoveredPlugin = {
|
||||
pluginKey: 'orca-samples.demo',
|
||||
rootDir: join(tmpdir(), 'plugins', 'demo'),
|
||||
manifest,
|
||||
consentFingerprint: 'sha256-current',
|
||||
contentHash: null,
|
||||
isDev: true
|
||||
}
|
||||
|
||||
expect(buildPluginList(serviceWith(plugin), emptyPluginLockfile())[0]).toMatchObject({
|
||||
pluginKey: plugin.pluginKey,
|
||||
consentFingerprint: 'sha256-current',
|
||||
status: 'pending'
|
||||
})
|
||||
})
|
||||
|
||||
it('projects supervised backoff as restarting instead of running', () => {
|
||||
const plugin: ValidDiscoveredPlugin = {
|
||||
pluginKey: 'orca-samples.demo',
|
||||
rootDir: join(tmpdir(), 'plugins', 'demo'),
|
||||
manifest,
|
||||
consentFingerprint: 'sha256-current',
|
||||
contentHash: null,
|
||||
isDev: true
|
||||
}
|
||||
|
||||
expect(
|
||||
buildPluginList(
|
||||
serviceWith(plugin, {
|
||||
activation: 'approved',
|
||||
worker: { state: 'restarting', restarts: 2 }
|
||||
}),
|
||||
emptyPluginLockfile()
|
||||
)[0]
|
||||
).toMatchObject({ status: 'restarting', restarts: 2 })
|
||||
})
|
||||
|
||||
it('does not attribute a shadowing dev plugin to the installed source', () => {
|
||||
const plugin: ValidDiscoveredPlugin = {
|
||||
pluginKey: 'orca-samples.demo',
|
||||
rootDir: join(tmpdir(), 'development', 'demo'),
|
||||
manifest,
|
||||
consentFingerprint: 'sha256-current',
|
||||
contentHash: null,
|
||||
isDev: true
|
||||
}
|
||||
const lock = {
|
||||
version: 1 as const,
|
||||
plugins: {
|
||||
[plugin.pluginKey]: {
|
||||
pluginKey: plugin.pluginKey,
|
||||
version: '1.0.0',
|
||||
source: { kind: 'git' as const, url: 'https://example.com/demo.git', ref: 'v1' },
|
||||
resolvedCommit: 'a'.repeat(40),
|
||||
contentHash: 'b'.repeat(64),
|
||||
consentFingerprint: 'sha256-installed',
|
||||
installedAt: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(buildPluginList(serviceWith(plugin), lock)[0]).not.toHaveProperty('source')
|
||||
})
|
||||
|
||||
it('does not expose an invalid development plugin absolute path as identity', () => {
|
||||
const invalid: InvalidDiscoveredPlugin = {
|
||||
rootDir: join(tmpdir(), 'private', 'secret-plugin-path'),
|
||||
error: 'missing orca-plugin.json',
|
||||
isDev: true
|
||||
}
|
||||
const service = {
|
||||
options: { getPluginConsents: () => ({}), getDisabledPlugins: () => [] },
|
||||
getDiscovered: () => [invalid]
|
||||
} as unknown as PluginService
|
||||
|
||||
const projected = buildPluginList(service, emptyPluginLockfile())[0]!
|
||||
expect(projected.pluginKey).toBe('invalid-development-plugin-1')
|
||||
expect(projected.name).toBe('invalid-development-plugin-1')
|
||||
expect(JSON.stringify(projected)).not.toContain(invalid.rootDir)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
PLUGIN_CAPABILITY_DESCRIPTIONS,
|
||||
type PluginCapabilityKind
|
||||
} from '../../shared/plugins/plugin-capabilities'
|
||||
import { needsReconsent } from '../../shared/plugins/plugin-consent-state'
|
||||
import { pluginPanelTabKey } from '../../shared/plugins/plugin-manifest'
|
||||
import type { PluginLockfile } from '../../shared/plugins/plugin-install-lockfile'
|
||||
import { isInvalidDiscoveredPlugin } from './plugin-discovery'
|
||||
import type { PluginService } from './plugin-service'
|
||||
|
||||
/**
|
||||
* Wire projection of installed plugins for the renderer and serve RPC.
|
||||
* `invalid` = unreadable/failed manifest; `pending` = awaiting (re-)consent;
|
||||
* `idle` = enabled with no worker running (lazy); `restarting` = waiting for
|
||||
* supervised backoff; `errored` = crashed past the budget or failed to activate.
|
||||
*/
|
||||
|
||||
export type PluginListPanelEntry = {
|
||||
id: string
|
||||
title: string
|
||||
icon?: string
|
||||
tabKey: `plugin:${string}`
|
||||
}
|
||||
|
||||
export type PluginListStatus =
|
||||
| 'running'
|
||||
| 'restarting'
|
||||
| 'idle'
|
||||
| 'pending'
|
||||
| 'disabled'
|
||||
| 'errored'
|
||||
| 'invalid'
|
||||
|
||||
export type PluginListEntry = {
|
||||
pluginKey: string
|
||||
/** Opaque identity of the exact capabilities and worker tier shown for review. */
|
||||
consentFingerprint: string | null
|
||||
name: string
|
||||
version: string
|
||||
publisher: string
|
||||
description?: string
|
||||
status: PluginListStatus
|
||||
needsReconsent: boolean
|
||||
error?: string
|
||||
isDev: boolean
|
||||
capabilities: { kind: PluginCapabilityKind; description: string }[]
|
||||
panels: PluginListPanelEntry[]
|
||||
commands: { id: string; title: string }[]
|
||||
hasWorker: boolean
|
||||
restarts: number
|
||||
source?: {
|
||||
kind: 'local-path' | 'git'
|
||||
reference: string
|
||||
resolvedCommit: string | null
|
||||
contentHash: string
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPluginList(service: PluginService, lock: PluginLockfile): PluginListEntry[] {
|
||||
const consents = {
|
||||
pluginConsents: service.options.getPluginConsents(),
|
||||
disabledPlugins: service.options.getDisabledPlugins()
|
||||
}
|
||||
return service.getDiscovered().map((plugin, index) => {
|
||||
if (isInvalidDiscoveredPlugin(plugin)) {
|
||||
// Why: invalid dev paths can contain private absolute desktop paths;
|
||||
// never project those as identity over desktop/serve transports.
|
||||
const fallbackKey = plugin.pluginKey ?? `invalid-development-plugin-${index + 1}`
|
||||
return {
|
||||
pluginKey: fallbackKey,
|
||||
consentFingerprint: null,
|
||||
name: fallbackKey,
|
||||
version: '0.0.0',
|
||||
publisher: '',
|
||||
status: 'invalid' as const,
|
||||
needsReconsent: false,
|
||||
error: plugin.error,
|
||||
isDev: plugin.isDev,
|
||||
capabilities: [],
|
||||
panels: [],
|
||||
commands: [],
|
||||
hasWorker: false,
|
||||
restarts: 0
|
||||
}
|
||||
}
|
||||
const activation = service.activationState(plugin)
|
||||
const worker = service.workerState(plugin.pluginKey)
|
||||
const activationError = service.activationError(plugin.pluginKey)
|
||||
let status: PluginListStatus
|
||||
if (activation === 'disabled') {
|
||||
status = 'disabled'
|
||||
} else if (activation === 'pending') {
|
||||
status = 'pending'
|
||||
} else if (worker.state === 'errored' || activationError) {
|
||||
status = 'errored'
|
||||
} else if (worker.state === 'restarting') {
|
||||
status = 'restarting'
|
||||
} else {
|
||||
status = worker.state === 'running' ? 'running' : 'idle'
|
||||
}
|
||||
const candidateLockEntry = lock.plugins[plugin.pluginKey]
|
||||
// Why: never show provenance for bytes other than the current executable
|
||||
// identity. Dev overrides execute outside the immutable installed tree and
|
||||
// must never inherit the shadowed install's pinned-source attribution.
|
||||
const lockEntry =
|
||||
candidateLockEntry &&
|
||||
!plugin.isDev &&
|
||||
plugin.contentHash !== null &&
|
||||
candidateLockEntry.contentHash === plugin.contentHash
|
||||
? candidateLockEntry
|
||||
: undefined
|
||||
return {
|
||||
pluginKey: plugin.pluginKey,
|
||||
consentFingerprint: plugin.consentFingerprint,
|
||||
name: plugin.manifest.name,
|
||||
version: plugin.manifest.version,
|
||||
publisher: plugin.manifest.publisher,
|
||||
...(plugin.manifest.description ? { description: plugin.manifest.description } : {}),
|
||||
status,
|
||||
needsReconsent: needsReconsent(plugin.pluginKey, plugin.consentFingerprint, consents),
|
||||
...(status === 'errored'
|
||||
? { error: activationError ?? 'plugin worker crashed repeatedly' }
|
||||
: {}),
|
||||
isDev: plugin.isDev,
|
||||
capabilities: plugin.manifest.capabilities.map((capability) => ({
|
||||
kind: capability.kind,
|
||||
description: PLUGIN_CAPABILITY_DESCRIPTIONS[capability.kind]
|
||||
})),
|
||||
panels: plugin.manifest.contributes.panels.map((panel) => ({
|
||||
id: panel.id,
|
||||
title: panel.title,
|
||||
...(panel.icon ? { icon: panel.icon } : {}),
|
||||
tabKey: pluginPanelTabKey(plugin.pluginKey, panel.id)
|
||||
})),
|
||||
commands: plugin.manifest.contributes.commands.map((command) => ({
|
||||
id: command.id,
|
||||
title: command.title
|
||||
})),
|
||||
hasWorker: Boolean(plugin.manifest.main),
|
||||
restarts: worker.restarts,
|
||||
...(lockEntry
|
||||
? {
|
||||
source: {
|
||||
kind: lockEntry.source.kind,
|
||||
reference:
|
||||
lockEntry.source.kind === 'git' ? lockEntry.source.url : lockEntry.source.path,
|
||||
resolvedCommit: lockEntry.resolvedCommit,
|
||||
contentHash: lockEntry.contentHash
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export type PluginLogLine = { ts: number; level: 'info' | 'warn' | 'error'; line: string }
|
||||
|
||||
const LOG_RING_LIMIT = 200
|
||||
|
||||
export class PluginLogBuffer {
|
||||
private readonly logs = new Map<string, PluginLogLine[]>()
|
||||
|
||||
get(pluginKey: string): PluginLogLine[] {
|
||||
return this.logs.get(pluginKey) ?? []
|
||||
}
|
||||
|
||||
append(pluginKey: string, level: PluginLogLine['level'], line: string): void {
|
||||
const ring = this.logs.get(pluginKey) ?? []
|
||||
ring.push({ ts: Date.now(), level, line })
|
||||
if (ring.length > LOG_RING_LIMIT) {
|
||||
ring.splice(0, ring.length - LOG_RING_LIMIT)
|
||||
}
|
||||
this.logs.set(pluginKey, ring)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { PLUGIN_MANIFEST_FILENAME } from '../../shared/plugins/plugin-manifest'
|
||||
|
||||
/** A manifest is startup metadata, not an artifact payload. Bounding it keeps
|
||||
* discovery and install preview from allocating an attacker-sized JSON file. */
|
||||
export const PLUGIN_MANIFEST_MAX_BYTES = 1024 * 1024
|
||||
|
||||
export async function readPluginManifestText(rootDir: string): Promise<string> {
|
||||
const chunks: Buffer[] = []
|
||||
let totalBytes = 0
|
||||
const stream = createReadStream(join(rootDir, PLUGIN_MANIFEST_FILENAME))
|
||||
for await (const chunk of stream) {
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
||||
totalBytes += bytes.byteLength
|
||||
if (totalBytes > PLUGIN_MANIFEST_MAX_BYTES) {
|
||||
throw new Error(`${PLUGIN_MANIFEST_FILENAME} exceeds ${PLUGIN_MANIFEST_MAX_BYTES} bytes`)
|
||||
}
|
||||
chunks.push(bytes)
|
||||
}
|
||||
return Buffer.concat(chunks, totalBytes).toString('utf8')
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest'
|
||||
import { createPluginPanelCallAdmission } from '../../shared/plugins/plugin-panel-call-admission'
|
||||
import type { ValidDiscoveredPlugin } from './plugin-discovery'
|
||||
import { PluginPanelController } from './plugin-panel-controller'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function createPlugin(): Promise<ValidDiscoveredPlugin> {
|
||||
const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-panel-controller-'))
|
||||
roots.push(rootDir)
|
||||
await writeFile(join(rootDir, 'panel.html'), '<h1>Panel</h1>')
|
||||
return {
|
||||
pluginKey: 'orca-samples.demo',
|
||||
rootDir,
|
||||
manifest: pluginManifestSchema.parse({
|
||||
manifestVersion: 1,
|
||||
id: 'demo',
|
||||
publisher: 'orca-samples',
|
||||
name: 'Demo',
|
||||
version: '1.0.0',
|
||||
engines: { orca: '>=1.0.0' },
|
||||
pluginApi: 1,
|
||||
contributes: {
|
||||
panels: [{ id: 'dashboard', title: 'Dashboard', entry: 'panel.html' }],
|
||||
commands: [],
|
||||
events: []
|
||||
},
|
||||
capabilities: [{ kind: 'notifications:show' }]
|
||||
}),
|
||||
consentFingerprint: 'sha256-consented',
|
||||
contentHash: null,
|
||||
isDev: true
|
||||
}
|
||||
}
|
||||
|
||||
describe('PluginPanelController identity binding', () => {
|
||||
it('uses the session identity and rejects caller-supplied plugin claims', async () => {
|
||||
const plugin = await createPlugin()
|
||||
const executeHostCall = vi.fn().mockResolvedValue({ ok: true, value: { delivered: true } })
|
||||
const controller = new PluginPanelController({
|
||||
resolveApprovedPlugin: (pluginKey) => (pluginKey === plugin.pluginKey ? plugin : null),
|
||||
contentVerifier: { verify: vi.fn().mockResolvedValue(undefined) },
|
||||
executeHostCall,
|
||||
log: vi.fn()
|
||||
})
|
||||
const entry = await controller.open('runtime:one', plugin.pluginKey, 'dashboard')
|
||||
expect(entry).not.toBeNull()
|
||||
|
||||
await expect(
|
||||
controller.execute('runtime:one', {
|
||||
sessionToken: entry!.sessionToken,
|
||||
pluginId: 'orca-samples.other',
|
||||
action: 'notifications.show',
|
||||
params: { title: 'Hello' }
|
||||
})
|
||||
).resolves.toMatchObject({ ok: false, code: 'invalid_request' })
|
||||
expect(executeHostCall).not.toHaveBeenCalled()
|
||||
|
||||
await expect(
|
||||
controller.execute('runtime:one', {
|
||||
sessionToken: entry!.sessionToken,
|
||||
action: 'notifications.show',
|
||||
params: { title: 'Hello' }
|
||||
})
|
||||
).resolves.toMatchObject({ ok: true })
|
||||
expect(executeHostCall).toHaveBeenCalledWith(plugin.pluginKey, 'notifications.show', {
|
||||
title: 'Hello'
|
||||
})
|
||||
await expect(
|
||||
controller.execute('runtime:other', {
|
||||
sessionToken: entry!.sessionToken,
|
||||
action: 'notifications.show',
|
||||
params: { title: 'Hello' }
|
||||
})
|
||||
).resolves.toMatchObject({ ok: false, code: 'invalid_request' })
|
||||
})
|
||||
|
||||
it('charges raw malformed and oversized calls before strict parsing', async () => {
|
||||
const plugin = await createPlugin()
|
||||
const executeHostCall = vi.fn()
|
||||
const controller = new PluginPanelController({
|
||||
resolveApprovedPlugin: () => plugin,
|
||||
contentVerifier: { verify: vi.fn().mockResolvedValue(undefined) },
|
||||
executeHostCall,
|
||||
log: vi.fn(),
|
||||
panelAdmission: createPluginPanelCallAdmission({
|
||||
limits: { maxBytes: 128, maxMessages: 2, perMs: 10_000 },
|
||||
now: () => 0
|
||||
})
|
||||
})
|
||||
const entry = await controller.open('runtime:one', plugin.pluginKey, 'dashboard')
|
||||
|
||||
await expect(
|
||||
controller.execute('runtime:one', {
|
||||
sessionToken: entry!.sessionToken,
|
||||
action: 'notifications.show',
|
||||
unexpected: true
|
||||
})
|
||||
).resolves.toMatchObject({ ok: false, code: 'invalid_request' })
|
||||
await expect(
|
||||
controller.execute('runtime:one', {
|
||||
sessionToken: entry!.sessionToken,
|
||||
action: 'notifications.show',
|
||||
params: { title: 'x'.repeat(256) }
|
||||
})
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
code: 'invalid_request',
|
||||
error: 'panel message exceeds the size limit'
|
||||
})
|
||||
await expect(
|
||||
controller.execute('runtime:one', {
|
||||
sessionToken: entry!.sessionToken,
|
||||
action: 'notifications.show',
|
||||
params: { title: 'third' }
|
||||
})
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
code: 'rate_limited',
|
||||
error: 'too many panel requests'
|
||||
})
|
||||
expect(executeHostCall).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not publish stale panel code after approval changes during verification', async () => {
|
||||
const plugin = await createPlugin()
|
||||
let approved = true
|
||||
let finishVerification!: () => void
|
||||
const verification = new Promise<void>((resolve) => {
|
||||
finishVerification = resolve
|
||||
})
|
||||
const controller = new PluginPanelController({
|
||||
resolveApprovedPlugin: () => (approved ? plugin : null),
|
||||
contentVerifier: { verify: () => verification },
|
||||
executeHostCall: vi.fn(),
|
||||
log: vi.fn()
|
||||
})
|
||||
|
||||
const opening = controller.open('runtime:one', plugin.pluginKey, 'dashboard')
|
||||
approved = false
|
||||
finishVerification()
|
||||
|
||||
await expect(opening).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('invalidates an open dev-panel session when its manifest revision changes', async () => {
|
||||
const plugin = await createPlugin()
|
||||
let current = plugin
|
||||
const executeHostCall = vi.fn().mockResolvedValue({ ok: true, value: { delivered: true } })
|
||||
const controller = new PluginPanelController({
|
||||
resolveApprovedPlugin: () => current,
|
||||
contentVerifier: { verify: vi.fn().mockResolvedValue(undefined) },
|
||||
executeHostCall,
|
||||
log: vi.fn()
|
||||
})
|
||||
const entry = await controller.open('runtime:one', plugin.pluginKey, 'dashboard')
|
||||
current = {
|
||||
...plugin,
|
||||
manifest: pluginManifestSchema.parse({ ...plugin.manifest, version: '1.0.1' })
|
||||
}
|
||||
|
||||
await expect(
|
||||
controller.execute('runtime:one', {
|
||||
sessionToken: entry!.sessionToken,
|
||||
action: 'notifications.show',
|
||||
params: { title: 'Hello' }
|
||||
})
|
||||
).resolves.toMatchObject({ ok: false, code: 'unavailable' })
|
||||
expect(executeHostCall).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,170 @@
|
||||
import type {
|
||||
PluginPanelActionOutcome,
|
||||
PluginPanelEntry
|
||||
} from '../../shared/plugins/plugin-panel-bridge'
|
||||
import { panelActionCallSchema } from '../../shared/plugins/plugin-panel-bridge'
|
||||
import {
|
||||
admitPluginPanelCall,
|
||||
createPluginPanelCallAdmission,
|
||||
type PluginPanelCallAdmission
|
||||
} from '../../shared/plugins/plugin-panel-call-admission'
|
||||
import { buildPluginPanelShellHtml } from '../../shared/plugins/plugin-panel-shell'
|
||||
import type { ValidDiscoveredPlugin } from './plugin-discovery'
|
||||
import type { PluginContentVerifier } from './plugin-content-integrity'
|
||||
import {
|
||||
PLUGIN_PANEL_ENTRY_MAX_BYTES,
|
||||
readContainedPluginArtifactText
|
||||
} from './plugin-artifact-validation'
|
||||
import { PluginPanelSessions, type PluginPanelSessionBinding } from './plugin-panel-sessions'
|
||||
|
||||
type PluginPanelControllerOptions = {
|
||||
resolveApprovedPlugin: (pluginKey: string) => ValidDiscoveredPlugin | null
|
||||
contentVerifier: Pick<PluginContentVerifier, 'verify'>
|
||||
executeHostCall: (
|
||||
pluginKey: string,
|
||||
method: string,
|
||||
params: unknown
|
||||
) => Promise<PluginPanelActionOutcome>
|
||||
log: (pluginKey: string, line: string) => void
|
||||
panelAdmission?: PluginPanelCallAdmission
|
||||
}
|
||||
|
||||
type LoadedPluginPanel = {
|
||||
entry: { html: string }
|
||||
binding: PluginPanelSessionBinding
|
||||
}
|
||||
|
||||
export class PluginPanelController {
|
||||
private readonly sessions = new PluginPanelSessions()
|
||||
private readonly boundOwnerSignals = new WeakSet<AbortSignal>()
|
||||
private readonly panelAdmission: PluginPanelCallAdmission
|
||||
|
||||
constructor(private readonly options: PluginPanelControllerOptions) {
|
||||
this.panelAdmission = options.panelAdmission ?? createPluginPanelCallAdmission()
|
||||
}
|
||||
|
||||
async readEntry(pluginKey: string, panelId: string): Promise<{ html: string } | null> {
|
||||
return (await this.load(pluginKey, panelId))?.entry ?? null
|
||||
}
|
||||
|
||||
async open(
|
||||
ownerKey: string,
|
||||
pluginKey: string,
|
||||
panelId: string
|
||||
): Promise<PluginPanelEntry | null> {
|
||||
const loaded = await this.load(pluginKey, panelId)
|
||||
if (!loaded) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...loaded.entry,
|
||||
sessionToken: this.sessions.issue(ownerKey, loaded.binding)
|
||||
}
|
||||
}
|
||||
|
||||
async execute(ownerKey: string, call: unknown): Promise<PluginPanelActionOutcome> {
|
||||
const sessionToken = this.extractSessionToken(call)
|
||||
if (!sessionToken) {
|
||||
return { ok: false, code: 'invalid_request', error: 'invalid panel session' }
|
||||
}
|
||||
const binding = this.sessions.resolve(ownerKey, sessionToken)
|
||||
if (!binding) {
|
||||
return { ok: false, code: 'invalid_request', error: 'invalid panel session' }
|
||||
}
|
||||
const admissionRefusal = admitPluginPanelCall(this.panelAdmission, binding.pluginKey, call)
|
||||
if (admissionRefusal) {
|
||||
return admissionRefusal
|
||||
}
|
||||
const parsed = panelActionCallSchema.safeParse(call)
|
||||
if (!parsed.success) {
|
||||
return { ok: false, code: 'invalid_request', error: 'malformed panel action call' }
|
||||
}
|
||||
const plugin = this.options.resolveApprovedPlugin(binding.pluginKey)
|
||||
const panelExists = plugin?.manifest.contributes.panels.some(
|
||||
(panel) => panel.id === binding.panelId
|
||||
)
|
||||
if (
|
||||
!plugin ||
|
||||
plugin.rootDir !== binding.rootDir ||
|
||||
JSON.stringify(plugin.manifest) !== binding.manifestRevision ||
|
||||
!panelExists
|
||||
) {
|
||||
return { ok: false, code: 'unavailable', error: 'panel session is no longer available' }
|
||||
}
|
||||
return this.options.executeHostCall(binding.pluginKey, parsed.data.action, parsed.data.params)
|
||||
}
|
||||
|
||||
revokeOwner(ownerKey: string): void {
|
||||
this.sessions.revokeOwner(ownerKey)
|
||||
}
|
||||
|
||||
bindOwnerSignal(ownerKey: string, signal: AbortSignal | undefined): void {
|
||||
if (!signal || this.boundOwnerSignals.has(signal)) {
|
||||
return
|
||||
}
|
||||
this.boundOwnerSignals.add(signal)
|
||||
if (signal.aborted) {
|
||||
this.revokeOwner(ownerKey)
|
||||
return
|
||||
}
|
||||
signal.addEventListener('abort', () => this.revokeOwner(ownerKey), { once: true })
|
||||
}
|
||||
|
||||
revokeAll(): void {
|
||||
this.sessions.clear()
|
||||
this.panelAdmission.clear()
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.revokeAll()
|
||||
}
|
||||
|
||||
private extractSessionToken(call: unknown): string | null {
|
||||
if (typeof call !== 'object' || call === null) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const token = (call as { sessionToken?: unknown }).sessionToken
|
||||
return typeof token === 'string' && token.length >= 32 && token.length <= 128 ? token : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private async load(pluginKey: string, panelId: string): Promise<LoadedPluginPanel | null> {
|
||||
const plugin = this.options.resolveApprovedPlugin(pluginKey)
|
||||
const panel = plugin?.manifest.contributes.panels.find((entry) => entry.id === panelId)
|
||||
if (!plugin || !panel) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
await this.options.contentVerifier.verify(plugin)
|
||||
const html = buildPluginPanelShellHtml(
|
||||
await readContainedPluginArtifactText(
|
||||
plugin.rootDir,
|
||||
panel.entry,
|
||||
PLUGIN_PANEL_ENTRY_MAX_BYTES
|
||||
)
|
||||
)
|
||||
const current = this.options.resolveApprovedPlugin(pluginKey)
|
||||
if (current !== plugin || current.rootDir !== plugin.rootDir) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
entry: { html },
|
||||
binding: {
|
||||
pluginKey,
|
||||
panelId,
|
||||
rootDir: plugin.rootDir,
|
||||
manifestRevision: JSON.stringify(plugin.manifest)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.options.log(
|
||||
pluginKey,
|
||||
`panel entry ${panel.entry} rejected: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
bindPluginPanelOwnerLifecycle,
|
||||
type PluginPanelOwnerSender
|
||||
} from './plugin-panel-owner-lifecycle'
|
||||
|
||||
describe('bindPluginPanelOwnerLifecycle', () => {
|
||||
it('deduplicates hooks, revokes on renderer loss, and invalidates in-flight loads', () => {
|
||||
const sender = new EventEmitter() as PluginPanelOwnerSender & EventEmitter
|
||||
const revoke = vi.fn()
|
||||
const first = bindPluginPanelOwnerLifecycle(sender, revoke)
|
||||
const duplicate = bindPluginPanelOwnerLifecycle(sender, revoke)
|
||||
|
||||
expect(sender.listenerCount('destroyed')).toBe(1)
|
||||
expect(sender.listenerCount('render-process-gone')).toBe(1)
|
||||
expect(first.isCurrent()).toBe(true)
|
||||
expect(duplicate.isCurrent()).toBe(true)
|
||||
|
||||
sender.emit('render-process-gone')
|
||||
|
||||
expect(revoke).toHaveBeenCalledTimes(1)
|
||||
expect(first.isCurrent()).toBe(false)
|
||||
expect(duplicate.isCurrent()).toBe(false)
|
||||
expect(sender.listenerCount('destroyed')).toBe(0)
|
||||
|
||||
const restarted = bindPluginPanelOwnerLifecycle(sender, revoke)
|
||||
expect(restarted.isCurrent()).toBe(true)
|
||||
sender.emit('destroyed')
|
||||
expect(revoke).toHaveBeenCalledTimes(2)
|
||||
expect(restarted.isCurrent()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { WebContents } from 'electron'
|
||||
|
||||
export type PluginPanelOwnerSender = Pick<WebContents, 'once' | 'removeListener'>
|
||||
|
||||
type OwnerState = {
|
||||
bound: boolean
|
||||
generation: number
|
||||
}
|
||||
|
||||
const ownerStates = new WeakMap<PluginPanelOwnerSender, OwnerState>()
|
||||
|
||||
/** Deduplicates WebContents lifecycle hooks and returns a generation lease so
|
||||
* an async panel load cannot publish a session after its renderer died. */
|
||||
export function bindPluginPanelOwnerLifecycle(
|
||||
sender: PluginPanelOwnerSender,
|
||||
revoke: () => void
|
||||
): { isCurrent: () => boolean } {
|
||||
let state = ownerStates.get(sender)
|
||||
if (!state) {
|
||||
state = { bound: false, generation: 0 }
|
||||
ownerStates.set(sender, state)
|
||||
}
|
||||
if (!state.bound) {
|
||||
state.bound = true
|
||||
let finished = false
|
||||
const cleanup = (): void => {
|
||||
if (finished) {
|
||||
return
|
||||
}
|
||||
finished = true
|
||||
sender.removeListener('destroyed', cleanup)
|
||||
sender.removeListener('render-process-gone', cleanup)
|
||||
state!.bound = false
|
||||
state!.generation += 1
|
||||
revoke()
|
||||
}
|
||||
sender.once('destroyed', cleanup)
|
||||
sender.once('render-process-gone', cleanup)
|
||||
}
|
||||
const generation = state.generation
|
||||
return { isCurrent: () => state!.bound && state!.generation === generation }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { PluginPanelSessions } from './plugin-panel-sessions'
|
||||
|
||||
const binding = {
|
||||
pluginKey: 'orca-samples.demo',
|
||||
panelId: 'dashboard',
|
||||
rootDir: '/plugins/orca-samples.demo/hash-one',
|
||||
manifestRevision: 'manifest-v1'
|
||||
}
|
||||
|
||||
describe('PluginPanelSessions', () => {
|
||||
it('binds an opaque token to its transport owner and panel revision', () => {
|
||||
const sessions = new PluginPanelSessions()
|
||||
const token = sessions.issue('renderer:1', binding)
|
||||
|
||||
expect(token).toHaveLength(43)
|
||||
expect(sessions.resolve('renderer:1', token)).toEqual(binding)
|
||||
expect(sessions.resolve('renderer:2', token)).toBeNull()
|
||||
expect(sessions.issue('renderer:1', binding)).toBe(token)
|
||||
expect(sessions.issue('renderer:1', { ...binding, rootDir: '/plugins/new' })).not.toBe(token)
|
||||
expect(sessions.issue('renderer:1', { ...binding, manifestRevision: 'manifest-v2' })).not.toBe(
|
||||
token
|
||||
)
|
||||
})
|
||||
|
||||
it('revokes every session owned by a disconnected transport', () => {
|
||||
const sessions = new PluginPanelSessions()
|
||||
const first = sessions.issue('connection:one', binding)
|
||||
const second = sessions.issue('connection:one', { ...binding, panelId: 'secondary' })
|
||||
const other = sessions.issue('connection:two', binding)
|
||||
|
||||
sessions.revokeOwner('connection:one')
|
||||
|
||||
expect(sessions.resolve('connection:one', first)).toBeNull()
|
||||
expect(sessions.resolve('connection:one', second)).toBeNull()
|
||||
expect(sessions.resolve('connection:two', other)).toEqual(binding)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
|
||||
export type PluginPanelSessionBinding = {
|
||||
pluginKey: string
|
||||
panelId: string
|
||||
rootDir: string
|
||||
manifestRevision: string
|
||||
}
|
||||
|
||||
type PluginPanelSession = PluginPanelSessionBinding & {
|
||||
ownerKey: string
|
||||
}
|
||||
|
||||
const MAX_PANEL_SESSIONS = 1_024
|
||||
|
||||
function bindingKey(ownerKey: string, binding: PluginPanelSessionBinding): string {
|
||||
return JSON.stringify([
|
||||
ownerKey,
|
||||
binding.pluginKey,
|
||||
binding.panelId,
|
||||
binding.rootDir,
|
||||
binding.manifestRevision
|
||||
])
|
||||
}
|
||||
|
||||
/** Opaque bearer sessions bind a loaded panel to its transport owner without
|
||||
* accepting a plugin identity on later action calls. */
|
||||
export class PluginPanelSessions {
|
||||
private readonly sessions = new Map<string, PluginPanelSession>()
|
||||
private readonly tokensByBinding = new Map<string, string>()
|
||||
|
||||
issue(ownerKey: string, binding: PluginPanelSessionBinding): string {
|
||||
const key = bindingKey(ownerKey, binding)
|
||||
const existing = this.tokensByBinding.get(key)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
while (this.sessions.size >= MAX_PANEL_SESSIONS) {
|
||||
const oldest = this.sessions.entries().next().value as
|
||||
| [string, PluginPanelSession]
|
||||
| undefined
|
||||
if (!oldest) {
|
||||
break
|
||||
}
|
||||
this.delete(oldest[0], oldest[1])
|
||||
}
|
||||
const token = randomBytes(32).toString('base64url')
|
||||
const session = { ownerKey, ...binding }
|
||||
this.sessions.set(token, session)
|
||||
this.tokensByBinding.set(key, token)
|
||||
return token
|
||||
}
|
||||
|
||||
resolve(ownerKey: string, token: string): PluginPanelSessionBinding | null {
|
||||
const session = this.sessions.get(token)
|
||||
if (!session || session.ownerKey !== ownerKey) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
pluginKey: session.pluginKey,
|
||||
panelId: session.panelId,
|
||||
rootDir: session.rootDir,
|
||||
manifestRevision: session.manifestRevision
|
||||
}
|
||||
}
|
||||
|
||||
revokeOwner(ownerKey: string): void {
|
||||
for (const [token, session] of this.sessions) {
|
||||
if (session.ownerKey === ownerKey) {
|
||||
this.delete(token, session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.sessions.clear()
|
||||
this.tokensByBinding.clear()
|
||||
}
|
||||
|
||||
private delete(token: string, session: PluginPanelSession): void {
|
||||
this.sessions.delete(token)
|
||||
this.tokensByBinding.delete(bindingKey(session.ownerKey, session))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const storageMocks = vi.hoisted(() => ({
|
||||
available: true,
|
||||
encryptString: vi.fn((value: string) => Buffer.from(`encrypted:${value}`, 'utf8')),
|
||||
decryptString: vi.fn((value: Buffer) => {
|
||||
const text = value.toString('utf8')
|
||||
if (!text.startsWith('encrypted:')) {
|
||||
throw new Error('wrong key or corrupt ciphertext')
|
||||
}
|
||||
return text.slice('encrypted:'.length)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => storageMocks.available,
|
||||
encryptString: storageMocks.encryptString,
|
||||
decryptString: storageMocks.decryptString
|
||||
}
|
||||
}))
|
||||
|
||||
import { PluginSecretsStore } from './plugin-secrets-store'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
async function tempRoot(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-secrets-'))
|
||||
roots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
storageMocks.available = true
|
||||
storageMocks.encryptString.mockImplementation((value) =>
|
||||
Buffer.from(`encrypted:${value}`, 'utf8')
|
||||
)
|
||||
storageMocks.decryptString.mockImplementation((value) => {
|
||||
const text = value.toString('utf8')
|
||||
if (!text.startsWith('encrypted:')) {
|
||||
throw new Error('wrong key or corrupt ciphertext')
|
||||
}
|
||||
return text.slice('encrypted:'.length)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('PluginSecretsStore', () => {
|
||||
it('encrypts, persists, decrypts, deletes, and isolates plugin namespaces', async () => {
|
||||
const root = await tempRoot()
|
||||
const first = new PluginSecretsStore(root, 'acme.first')
|
||||
const second = new PluginSecretsStore(root, 'acme.second')
|
||||
|
||||
expect(first.set('token', 'top-secret')).toEqual({ ok: true, value: true })
|
||||
expect(first.get('token')).toEqual({ ok: true, value: 'top-secret' })
|
||||
expect(second.get('token')).toEqual({ ok: true, value: null })
|
||||
const persisted = await readFile(join(root, 'acme.first', 'secrets.json.enc'), 'utf8')
|
||||
expect(persisted).not.toContain('top-secret')
|
||||
if (process.platform !== 'win32') {
|
||||
expect((await stat(join(root, 'acme.first', 'secrets.json.enc'))).mode & 0o077).toBe(0)
|
||||
}
|
||||
|
||||
first.delete('token')
|
||||
expect(first.get('token')).toEqual({ ok: true, value: null })
|
||||
})
|
||||
|
||||
it('fails closed without OS encryption and writes no plaintext file', async () => {
|
||||
const root = await tempRoot()
|
||||
storageMocks.available = false
|
||||
const store = new PluginSecretsStore(root, 'acme.demo')
|
||||
|
||||
expect(store.set('token', 'plaintext')).toMatchObject({ ok: false })
|
||||
expect(store.get('token')).toMatchObject({ ok: true, value: null })
|
||||
await expect(readFile(join(root, 'acme.demo', 'secrets.json.enc'))).rejects.toMatchObject({
|
||||
code: 'ENOENT'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports corrupt or wrong-key ciphertext without returning bytes', async () => {
|
||||
const root = await tempRoot()
|
||||
const pluginDir = join(root, 'acme.demo')
|
||||
await mkdir(pluginDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(pluginDir, 'secrets.json.enc'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
format: 'electron-safe-storage-v1',
|
||||
ciphertexts: { token: Buffer.from('not-encrypted').toString('base64') }
|
||||
})
|
||||
)
|
||||
const store = new PluginSecretsStore(root, 'acme.demo')
|
||||
|
||||
expect(store.get('token')).toEqual({ ok: false, error: 'failed to decrypt stored secret' })
|
||||
})
|
||||
|
||||
it('refuses ciphertext that would exceed the bounded vault', async () => {
|
||||
const root = await tempRoot()
|
||||
storageMocks.encryptString.mockReturnValue(Buffer.alloc(6 * 1024 * 1024))
|
||||
const store = new PluginSecretsStore(root, 'acme.demo')
|
||||
|
||||
expect(store.set('token', 'small-input')).toMatchObject({ ok: false })
|
||||
await expect(readFile(join(root, 'acme.demo', 'secrets.json.enc'))).rejects.toMatchObject({
|
||||
code: 'ENOENT'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects unsafe plugin namespaces', async () => {
|
||||
const root = await tempRoot()
|
||||
expect(() => new PluginSecretsStore(root, 'constructor.demo')).toThrow('unsafe plugin key')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { safeStorage } from 'electron'
|
||||
import { writeSecureFile } from '../../shared/secure-file'
|
||||
import {
|
||||
PLUGIN_STORAGE_KEY_LIMIT,
|
||||
PLUGIN_STORAGE_TOTAL_MAX_BYTES
|
||||
} from '../../shared/plugins/plugin-host-api'
|
||||
import { pluginDataDir } from './plugin-storage-store'
|
||||
|
||||
/**
|
||||
* Per-plugin secret vault, following the repo's safeStorage-backed
|
||||
* credential-file pattern (versioned envelope + base64 ciphertext via the
|
||||
* atomic secure-file writer). No plaintext fallback: when OS encryption is
|
||||
* unavailable, writes fail loudly instead of silently downgrading — plugin
|
||||
* secrets are API-token grade.
|
||||
*/
|
||||
|
||||
type PersistedSecretsFile = {
|
||||
version: 1
|
||||
format: 'electron-safe-storage-v1'
|
||||
/** key → base64 ciphertext of the secret value. */
|
||||
ciphertexts: Record<string, string>
|
||||
}
|
||||
|
||||
export type PluginSecretsResult<T> = { ok: true; value: T } | { ok: false; error: string }
|
||||
|
||||
export class PluginSecretsStore {
|
||||
private readonly filePath: string
|
||||
|
||||
constructor(pluginsDataDir: string, qualifiedKey: string) {
|
||||
this.filePath = join(pluginDataDir(pluginsDataDir, qualifiedKey), 'secrets.json.enc')
|
||||
}
|
||||
|
||||
private read(): PersistedSecretsFile {
|
||||
const empty: PersistedSecretsFile = {
|
||||
version: 1,
|
||||
format: 'electron-safe-storage-v1',
|
||||
ciphertexts: {}
|
||||
}
|
||||
try {
|
||||
if (!existsSync(this.filePath)) {
|
||||
return empty
|
||||
}
|
||||
if (statSync(this.filePath).size > PLUGIN_STORAGE_TOTAL_MAX_BYTES) {
|
||||
return empty
|
||||
}
|
||||
const parsed = JSON.parse(readFileSync(this.filePath, 'utf8')) as PersistedSecretsFile
|
||||
if (
|
||||
parsed &&
|
||||
parsed.version === 1 &&
|
||||
parsed.format === 'electron-safe-storage-v1' &&
|
||||
parsed.ciphertexts &&
|
||||
typeof parsed.ciphertexts === 'object' &&
|
||||
!Array.isArray(parsed.ciphertexts)
|
||||
) {
|
||||
return parsed
|
||||
}
|
||||
} catch {
|
||||
// Corrupt vaults read as empty; set() rewrites a valid file.
|
||||
}
|
||||
return empty
|
||||
}
|
||||
|
||||
get(key: string): PluginSecretsResult<string | null> {
|
||||
const file = this.read()
|
||||
const ciphertext = file.ciphertexts[key]
|
||||
if (typeof ciphertext !== 'string') {
|
||||
return { ok: true, value: null }
|
||||
}
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
return { ok: false, error: 'OS-backed encryption is unavailable' }
|
||||
}
|
||||
try {
|
||||
return { ok: true, value: safeStorage.decryptString(Buffer.from(ciphertext, 'base64')) }
|
||||
} catch {
|
||||
return { ok: false, error: 'failed to decrypt stored secret' }
|
||||
}
|
||||
}
|
||||
|
||||
set(key: string, value: string): PluginSecretsResult<true> {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
return { ok: false, error: 'OS-backed encryption is unavailable; secret not stored' }
|
||||
}
|
||||
const file = this.read()
|
||||
if (
|
||||
!Object.hasOwn(file.ciphertexts, key) &&
|
||||
Object.keys(file.ciphertexts).length >= PLUGIN_STORAGE_KEY_LIMIT
|
||||
) {
|
||||
return { ok: false, error: `secret vault exceeds the ${PLUGIN_STORAGE_KEY_LIMIT}-key limit` }
|
||||
}
|
||||
file.ciphertexts[key] = safeStorage.encryptString(value).toString('base64')
|
||||
const nextFile = JSON.stringify(file, null, 2)
|
||||
if (Buffer.byteLength(nextFile, 'utf8') > PLUGIN_STORAGE_TOTAL_MAX_BYTES) {
|
||||
return { ok: false, error: `secret vault exceeds ${PLUGIN_STORAGE_TOTAL_MAX_BYTES} bytes` }
|
||||
}
|
||||
writeSecureFile(this.filePath, nextFile)
|
||||
return { ok: true, value: true }
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
const file = this.read()
|
||||
if (Object.hasOwn(file.ciphertexts, key)) {
|
||||
delete file.ciphertexts[key]
|
||||
writeSecureFile(this.filePath, JSON.stringify(file, null, 2))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { PluginDevWatcher } from './plugin-dev-watcher'
|
||||
|
||||
/** Starts and stops lifecycle maintenance as the feature flag and dev paths change. */
|
||||
export class PluginServiceHousekeeping {
|
||||
private readonly devWatcher = new PluginDevWatcher()
|
||||
private reapTimer: ReturnType<typeof setInterval> | null = null
|
||||
private watchedPathsKey: string | null = null
|
||||
|
||||
sync(options: {
|
||||
enabled: boolean
|
||||
devPaths: readonly string[]
|
||||
reapIdle: () => void
|
||||
refresh: () => void
|
||||
}): void {
|
||||
if (!options.enabled) {
|
||||
this.stop()
|
||||
return
|
||||
}
|
||||
if (!this.reapTimer) {
|
||||
this.reapTimer = setInterval(options.reapIdle, 60_000)
|
||||
this.reapTimer.unref?.()
|
||||
}
|
||||
const pathsKey = JSON.stringify(options.devPaths)
|
||||
if (pathsKey !== this.watchedPathsKey) {
|
||||
this.devWatcher.dispose()
|
||||
this.devWatcher.start(options.devPaths, options.refresh, () => {
|
||||
// The next refresh retries a failed watcher even when the configured
|
||||
// path list itself did not change.
|
||||
this.watchedPathsKey = null
|
||||
})
|
||||
this.watchedPathsKey = pathsKey
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.stop()
|
||||
}
|
||||
|
||||
private stop(): void {
|
||||
if (this.reapTimer) {
|
||||
clearInterval(this.reapTimer)
|
||||
this.reapTimer = null
|
||||
}
|
||||
this.devWatcher.dispose()
|
||||
this.watchedPathsKey = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { mkdtemp, mkdir, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint'
|
||||
import { pluginManifestSchema, type PluginManifest } from '../../shared/plugins/plugin-manifest'
|
||||
import { hashPluginTree } from './plugin-content-hash'
|
||||
import { PluginContentVerifier } from './plugin-content-integrity'
|
||||
import { PluginService } from './plugin-service'
|
||||
import type { PluginWorkerFactory } from './plugin-worker-manager'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
async function createInstalledPlugin(options: { worker: boolean }): Promise<{
|
||||
userDataPath: string
|
||||
pluginKey: string
|
||||
rootDir: string
|
||||
manifest: PluginManifest
|
||||
}> {
|
||||
const userDataPath = await mkdtemp(join(tmpdir(), 'orca-plugin-service-integrity-'))
|
||||
roots.push(userDataPath)
|
||||
const pluginKey = 'orca-samples.demo'
|
||||
const pluginDir = join(userDataPath, 'plugins', pluginKey)
|
||||
const stagingDir = join(pluginDir, 'staging')
|
||||
await mkdir(stagingDir, { recursive: true })
|
||||
const manifest = pluginManifestSchema.parse({
|
||||
manifestVersion: 1,
|
||||
id: 'demo',
|
||||
publisher: 'orca-samples',
|
||||
name: 'Demo',
|
||||
version: '1.0.0',
|
||||
engines: { orca: '>=1.0.0' },
|
||||
pluginApi: 1,
|
||||
...(options.worker ? { main: 'worker.js' } : {}),
|
||||
contributes: {
|
||||
panels: [{ id: 'panel', title: 'Panel', entry: 'panel.html' }],
|
||||
commands: options.worker ? [{ id: 'run', title: 'Run' }] : [],
|
||||
events: []
|
||||
},
|
||||
capabilities: []
|
||||
})
|
||||
await writeFile(join(stagingDir, 'orca-plugin.json'), JSON.stringify(manifest))
|
||||
await writeFile(join(stagingDir, 'panel.html'), '<h1>Panel</h1>')
|
||||
await writeFile(join(stagingDir, 'payload.txt'), 'original')
|
||||
if (options.worker) {
|
||||
await writeFile(join(stagingDir, 'worker.js'), 'export default async function () {}')
|
||||
}
|
||||
const content = await hashPluginTree(stagingDir)
|
||||
if (!content.ok) {
|
||||
throw new Error(content.error)
|
||||
}
|
||||
const rootDir = join(pluginDir, content.hash)
|
||||
await rename(stagingDir, rootDir)
|
||||
await writeFile(join(pluginDir, 'current'), content.hash)
|
||||
return { userDataPath, pluginKey, rootDir, manifest }
|
||||
}
|
||||
|
||||
function createService(
|
||||
plugin: Awaited<ReturnType<typeof createInstalledPlugin>>,
|
||||
workerFactory?: PluginWorkerFactory
|
||||
): PluginService {
|
||||
const consentFingerprint = fingerprintPluginConsent(plugin.manifest)
|
||||
return new PluginService({
|
||||
userDataPath: plugin.userDataPath,
|
||||
hostVersion: '1.4.0',
|
||||
isPluginSystemEnabled: () => true,
|
||||
getDisabledPlugins: () => [],
|
||||
getPluginConsents: () => ({ [plugin.pluginKey]: consentFingerprint }),
|
||||
getDevPluginPaths: () => [],
|
||||
workerFactory
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('PluginService lazy content verification', () => {
|
||||
it('does not share an in-flight verification across same-key content revisions', async () => {
|
||||
const oldPlugin = await createInstalledPlugin({ worker: false })
|
||||
const newPlugin = await createInstalledPlugin({ worker: false })
|
||||
await writeFile(join(oldPlugin.rootDir, 'payload.txt'), 'tampered old revision')
|
||||
const verifier = new PluginContentVerifier()
|
||||
|
||||
const oldVerification = verifier.verify({
|
||||
pluginKey: oldPlugin.pluginKey,
|
||||
rootDir: oldPlugin.rootDir,
|
||||
contentHash: basename(oldPlugin.rootDir)
|
||||
})
|
||||
const newVerification = verifier.verify({
|
||||
pluginKey: newPlugin.pluginKey,
|
||||
rootDir: newPlugin.rootDir,
|
||||
contentHash: basename(newPlugin.rootDir)
|
||||
})
|
||||
|
||||
await expect(oldVerification).rejects.toThrow('integrity verification')
|
||||
await expect(newVerification).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('detects tampering only when panel code is first consumed', async () => {
|
||||
const plugin = await createInstalledPlugin({ worker: false })
|
||||
const service = createService(plugin)
|
||||
await service.initialize()
|
||||
expect(service.findValidPlugin(plugin.pluginKey)).not.toBeNull()
|
||||
|
||||
await writeFile(join(plugin.rootDir, 'payload.txt'), 'tampered after discovery')
|
||||
|
||||
await expect(service.panels.readEntry(plugin.pluginKey, 'panel')).resolves.toBeNull()
|
||||
await service.dispose()
|
||||
})
|
||||
|
||||
it('blocks a worker fork when installed content changed after discovery', async () => {
|
||||
const plugin = await createInstalledPlugin({ worker: true })
|
||||
const workerFactory = vi.fn<PluginWorkerFactory>()
|
||||
const service = createService(plugin, workerFactory)
|
||||
await service.initialize()
|
||||
await writeFile(join(plugin.rootDir, 'payload.txt'), 'tampered after discovery')
|
||||
|
||||
await expect(service.invokeCommand(plugin.pluginKey, 'run')).rejects.toThrow(
|
||||
'integrity verification'
|
||||
)
|
||||
expect(workerFactory).not.toHaveBeenCalled()
|
||||
await service.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,259 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint'
|
||||
import { pluginManifestSchema, type PluginManifest } from '../../shared/plugins/plugin-manifest'
|
||||
import type { PluginWorkerHandle } from './plugin-host-process'
|
||||
import { PluginService } from './plugin-service'
|
||||
import type { PluginWorkerFactory } from './plugin-worker-manager'
|
||||
|
||||
const roots: string[] = []
|
||||
const services: PluginService[] = []
|
||||
const pluginKey = 'orca-samples.demo'
|
||||
|
||||
function manifest(options: { main?: string; capabilities?: PluginManifest['capabilities'] } = {}) {
|
||||
return pluginManifestSchema.parse({
|
||||
manifestVersion: 1,
|
||||
id: 'demo',
|
||||
publisher: 'orca-samples',
|
||||
name: 'Demo',
|
||||
version: '1.0.0',
|
||||
engines: { orca: '>=1.0.0' },
|
||||
pluginApi: 1,
|
||||
main: options.main ?? 'worker.js',
|
||||
contributes: {
|
||||
panels: [{ id: 'panel', title: 'Panel', entry: 'panel.html' }],
|
||||
commands: [{ id: 'run', title: 'Run' }],
|
||||
events: []
|
||||
},
|
||||
capabilities: options.capabilities ?? []
|
||||
})
|
||||
}
|
||||
|
||||
async function pluginRoot(pluginManifest = manifest()): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-reconcile-'))
|
||||
roots.push(root)
|
||||
await writeFile(join(root, 'orca-plugin.json'), JSON.stringify(pluginManifest))
|
||||
await writeFile(join(root, 'worker.js'), 'export default async function () {}')
|
||||
await writeFile(join(root, 'worker-v2.js'), 'export default async function () {}')
|
||||
await writeFile(join(root, 'panel.html'), '<h1>Panel</h1>')
|
||||
return root
|
||||
}
|
||||
|
||||
function testWorker(): PluginWorkerHandle & { dispose: ReturnType<typeof vi.fn> } {
|
||||
return {
|
||||
commands: ['run'],
|
||||
invokeCommand: vi.fn(async () => null),
|
||||
deliverEvent: vi.fn(),
|
||||
lastActivityAt: () => Date.now(),
|
||||
inFlightCount: () => 0,
|
||||
dispose: vi.fn(async () => undefined),
|
||||
kill: vi.fn(),
|
||||
onExit: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function createHarness(root: string) {
|
||||
let enabled = true
|
||||
let disabled: string[] = []
|
||||
let devPaths = [root]
|
||||
const consent = fingerprintPluginConsent(manifest())
|
||||
const workers: ReturnType<typeof testWorker>[] = []
|
||||
const factory = vi.fn<PluginWorkerFactory>(async () => {
|
||||
const handle = testWorker()
|
||||
workers.push(handle)
|
||||
return handle
|
||||
})
|
||||
const service = new PluginService({
|
||||
userDataPath: root,
|
||||
hostVersion: '1.4.0',
|
||||
isPluginSystemEnabled: () => enabled,
|
||||
getDisabledPlugins: () => disabled,
|
||||
getPluginConsents: () => ({ [pluginKey]: consent }),
|
||||
getDevPluginPaths: () => devPaths,
|
||||
workerFactory: factory
|
||||
})
|
||||
services.push(service)
|
||||
return {
|
||||
service,
|
||||
factory,
|
||||
workers,
|
||||
setEnabled: (value: boolean) => {
|
||||
enabled = value
|
||||
},
|
||||
setDisabled: (value: string[]) => {
|
||||
disabled = value
|
||||
},
|
||||
setDevPaths: (value: string[]) => {
|
||||
devPaths = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function activate(service: PluginService): Promise<void> {
|
||||
await service.initialize()
|
||||
await service.invokeCommand(pluginKey, 'run')
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers()
|
||||
await Promise.all(services.splice(0).map((service) => service.dispose()))
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('PluginService worker reconciliation', () => {
|
||||
it('denies every authority boundary immediately when the feature flag turns off', async () => {
|
||||
const root = await pluginRoot()
|
||||
const harness = createHarness(root)
|
||||
await activate(harness.service)
|
||||
const plugin = harness.service.findValidPlugin(pluginKey)!
|
||||
const opened = await harness.service.panels.open('renderer:1', pluginKey, 'panel')
|
||||
expect(opened).not.toBeNull()
|
||||
|
||||
harness.setEnabled(false)
|
||||
|
||||
expect(harness.service.activationState(plugin)).toBe('disabled')
|
||||
expect(harness.service.getGrantedCapabilities(pluginKey)).toBeNull()
|
||||
await expect(harness.service.invokeCommand(pluginKey, 'run')).rejects.toThrow('not enabled')
|
||||
await expect(harness.service.panels.readEntry(pluginKey, 'panel')).resolves.toBeNull()
|
||||
|
||||
await harness.service.refresh()
|
||||
expect(harness.workers[0]!.dispose).toHaveBeenCalledOnce()
|
||||
harness.setEnabled(true)
|
||||
await harness.service.refresh()
|
||||
await expect(
|
||||
harness.service.panels.execute('renderer:1', {
|
||||
sessionToken: opened!.sessionToken,
|
||||
action: 'notifications.show',
|
||||
params: { title: 'stale' }
|
||||
})
|
||||
).resolves.toMatchObject({ ok: false, error: 'invalid panel session' })
|
||||
})
|
||||
|
||||
it('deactivates a worker when its plugin becomes disabled', async () => {
|
||||
const root = await pluginRoot()
|
||||
const harness = createHarness(root)
|
||||
await activate(harness.service)
|
||||
|
||||
harness.setDisabled([pluginKey])
|
||||
await harness.service.refresh()
|
||||
|
||||
expect(harness.workers[0]!.dispose).toHaveBeenCalledOnce()
|
||||
expect(harness.service.workerState(pluginKey).state).toBe('inactive')
|
||||
})
|
||||
|
||||
it('deactivates a worker when changed capabilities make consent pending', async () => {
|
||||
const root = await pluginRoot()
|
||||
const harness = createHarness(root)
|
||||
await activate(harness.service)
|
||||
await writeFile(
|
||||
join(root, 'orca-plugin.json'),
|
||||
JSON.stringify(manifest({ capabilities: [{ kind: 'storage' }] }))
|
||||
)
|
||||
|
||||
await harness.service.refresh()
|
||||
|
||||
expect(harness.workers[0]!.dispose).toHaveBeenCalledOnce()
|
||||
expect(harness.service.activationState(harness.service.findValidPlugin(pluginKey)!)).toBe(
|
||||
'pending'
|
||||
)
|
||||
})
|
||||
|
||||
it('cancels the old generation when a worker spec changes without eager reactivation', async () => {
|
||||
const root = await pluginRoot()
|
||||
const harness = createHarness(root)
|
||||
await activate(harness.service)
|
||||
await writeFile(
|
||||
join(root, 'orca-plugin.json'),
|
||||
JSON.stringify(manifest({ main: 'worker-v2.js' }))
|
||||
)
|
||||
|
||||
await harness.service.refresh()
|
||||
|
||||
expect(harness.workers[0]!.dispose).toHaveBeenCalledOnce()
|
||||
expect(harness.factory).toHaveBeenCalledTimes(1)
|
||||
await harness.service.invokeCommand(pluginKey, 'run')
|
||||
expect(harness.factory).toHaveBeenCalledTimes(2)
|
||||
expect(harness.factory.mock.calls[1]?.[0].mainEntry).toBe('worker-v2.js')
|
||||
})
|
||||
|
||||
it('cannot reactivate the old revision while refresh awaits worker shutdown', async () => {
|
||||
const root = await pluginRoot()
|
||||
let finishOldDispose!: () => void
|
||||
const oldDispose = new Promise<void>((resolve) => {
|
||||
finishOldDispose = resolve
|
||||
})
|
||||
const workers: ReturnType<typeof testWorker>[] = []
|
||||
const factory = vi.fn<PluginWorkerFactory>(async () => {
|
||||
const handle = testWorker()
|
||||
if (workers.length === 0) {
|
||||
handle.dispose.mockImplementation(() => oldDispose)
|
||||
}
|
||||
workers.push(handle)
|
||||
return handle
|
||||
})
|
||||
const consent = fingerprintPluginConsent(manifest())
|
||||
const service = new PluginService({
|
||||
userDataPath: root,
|
||||
hostVersion: '1.4.0',
|
||||
isPluginSystemEnabled: () => true,
|
||||
getDisabledPlugins: () => [],
|
||||
getPluginConsents: () => ({ [pluginKey]: consent }),
|
||||
getDevPluginPaths: () => [root],
|
||||
workerFactory: factory
|
||||
})
|
||||
services.push(service)
|
||||
await activate(service)
|
||||
await writeFile(
|
||||
join(root, 'orca-plugin.json'),
|
||||
JSON.stringify(manifest({ main: 'worker-v2.js' }))
|
||||
)
|
||||
|
||||
const refreshing = service.refresh()
|
||||
await vi.waitFor(() => expect(workers[0]!.dispose).toHaveBeenCalledOnce())
|
||||
const invoking = service.invokeCommand(pluginKey, 'run')
|
||||
await vi.waitFor(() => expect(factory).toHaveBeenCalledTimes(2))
|
||||
expect(factory.mock.calls[1]?.[0].mainEntry).toBe('worker-v2.js')
|
||||
finishOldDispose()
|
||||
|
||||
await expect(invoking).resolves.toBeNull()
|
||||
await refreshing
|
||||
})
|
||||
|
||||
it('deactivates removed and replaced dev paths without eager activation', async () => {
|
||||
const firstRoot = await pluginRoot()
|
||||
const secondRoot = await pluginRoot()
|
||||
const harness = createHarness(firstRoot)
|
||||
await activate(harness.service)
|
||||
|
||||
harness.setDevPaths([])
|
||||
await harness.service.refresh()
|
||||
expect(harness.workers[0]!.dispose).toHaveBeenCalledOnce()
|
||||
expect(harness.service.findValidPlugin(pluginKey)).toBeNull()
|
||||
|
||||
harness.setDevPaths([secondRoot])
|
||||
await harness.service.refresh()
|
||||
expect(harness.factory).toHaveBeenCalledTimes(1)
|
||||
await harness.service.invokeCommand(pluginKey, 'run')
|
||||
expect(harness.factory.mock.calls[1]?.[0].rootDir).toBe(secondRoot)
|
||||
})
|
||||
|
||||
it('starts and stops housekeeping on feature-flag transitions', async () => {
|
||||
vi.useFakeTimers()
|
||||
const root = await pluginRoot()
|
||||
const harness = createHarness(root)
|
||||
await harness.service.initialize()
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
harness.setEnabled(false)
|
||||
await harness.service.refresh()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(harness.service.getDiscovered()).toEqual([])
|
||||
|
||||
harness.setEnabled(true)
|
||||
await harness.service.refresh()
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
expect(harness.factory).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,334 @@
|
||||
import type { PluginEventName } from '../../shared/plugins/plugin-manifest'
|
||||
import {
|
||||
capabilityKinds,
|
||||
type PluginCapabilityKind
|
||||
} from '../../shared/plugins/plugin-capabilities'
|
||||
import {
|
||||
getPluginActivationState,
|
||||
type PluginConsentLists
|
||||
} from '../../shared/plugins/plugin-consent-state'
|
||||
import type { PluginPanelActionOutcome } from '../../shared/plugins/plugin-panel-bridge'
|
||||
import {
|
||||
createPluginExtensionRegistry,
|
||||
type PluginExtensionRegistry
|
||||
} from '../../shared/plugins/plugin-extension-registry'
|
||||
import {
|
||||
discoverPlugins,
|
||||
getPluginsDataDir,
|
||||
getUserPluginsDir,
|
||||
isInvalidDiscoveredPlugin,
|
||||
type DiscoveredPlugin,
|
||||
type ValidDiscoveredPlugin
|
||||
} from './plugin-discovery'
|
||||
import type { PluginWorkerFactory } from './plugin-worker-manager'
|
||||
import { PluginEventBus } from './plugin-event-bus'
|
||||
import { PluginAuditLog } from './plugin-audit-log'
|
||||
import { executePluginHostCallRequest } from './plugin-host-call-adapter'
|
||||
import { PluginContentVerifier } from './plugin-content-integrity'
|
||||
import { bindPluginHostServices, type PluginRuntimeDelegate } from './plugin-host-service-bindings'
|
||||
import { PluginLogBuffer, type PluginLogLine } from './plugin-log-buffer'
|
||||
import { PluginPanelController } from './plugin-panel-controller'
|
||||
import { PluginWorkerController } from './plugin-worker-controller'
|
||||
import { PluginServiceHousekeeping } from './plugin-service-housekeeping'
|
||||
import { collectApprovedWorkerSpecs } from './plugin-worker-reconciliation'
|
||||
import type { PluginRunState } from './plugin-supervisor'
|
||||
import { isPluginApproved, snapshotPluginConsentLists } from './plugin-activation-policy'
|
||||
|
||||
export type { PluginRuntimeDelegate } from './plugin-host-service-bindings'
|
||||
export type { PluginLogLine } from './plugin-log-buffer'
|
||||
|
||||
export type PluginServiceOptions = {
|
||||
userDataPath: string
|
||||
hostVersion: string
|
||||
isPluginSystemEnabled: () => boolean
|
||||
getDisabledPlugins: () => string[]
|
||||
getPluginConsents: () => Record<string, string>
|
||||
getDevPluginPaths: () => string[]
|
||||
hostEntryPath?: string
|
||||
workerFactory?: PluginWorkerFactory
|
||||
maxActiveWorkers?: number
|
||||
idleReapMs?: number
|
||||
}
|
||||
|
||||
export class PluginService {
|
||||
readonly options: PluginServiceOptions
|
||||
private readonly registry: PluginExtensionRegistry = createPluginExtensionRegistry()
|
||||
private readonly eventBus = new PluginEventBus()
|
||||
private readonly audit: PluginAuditLog
|
||||
private readonly workerController: PluginWorkerController
|
||||
private readonly logBuffer = new PluginLogBuffer()
|
||||
private readonly contentVerifier = new PluginContentVerifier()
|
||||
readonly panels: PluginPanelController
|
||||
private readonly changeListeners = new Set<() => void>()
|
||||
private readonly housekeeping = new PluginServiceHousekeeping()
|
||||
private discovered: DiscoveredPlugin[] = []
|
||||
private runtimeDelegate: PluginRuntimeDelegate | null = null
|
||||
private initPromise: Promise<void> | null = null
|
||||
private refreshChain: Promise<void> = Promise.resolve()
|
||||
private disposed = false
|
||||
|
||||
constructor(options: PluginServiceOptions) {
|
||||
this.options = options
|
||||
this.audit = new PluginAuditLog(getPluginsDataDir(options.userDataPath))
|
||||
this.panels = new PluginPanelController({
|
||||
resolveApprovedPlugin: (pluginKey) => {
|
||||
const plugin = this.findValidPlugin(pluginKey)
|
||||
return plugin && this.activationState(plugin) === 'approved' ? plugin : null
|
||||
},
|
||||
contentVerifier: this.contentVerifier,
|
||||
executeHostCall: (pluginKey, method, params) =>
|
||||
this.executeHostCall(pluginKey, method, params, { viaPanel: true }),
|
||||
log: (pluginKey, line) => this.logBuffer.append(pluginKey, 'error', line)
|
||||
})
|
||||
this.workerController = new PluginWorkerController({
|
||||
entryPath: options.hostEntryPath ?? '',
|
||||
maxActive: options.maxActiveWorkers,
|
||||
idleReapMs: options.idleReapMs,
|
||||
workerFactory: options.workerFactory,
|
||||
registry: this.registry,
|
||||
contentVerifier: this.contentVerifier,
|
||||
capabilities: (pluginKey) => this.getGrantedCapabilities(pluginKey),
|
||||
isCurrentApproved: (plugin) =>
|
||||
this.findValidPlugin(plugin.pluginKey) === plugin &&
|
||||
this.activationState(plugin) === 'approved',
|
||||
invokeCommand: (pluginKey, commandId, args) => this.invokeCommand(pluginKey, commandId, args),
|
||||
executeHostCall: (pluginKey, method, params) =>
|
||||
this.executeHostCall(pluginKey, method, params, { viaPanel: false }),
|
||||
log: (pluginKey, level, line) => this.logBuffer.append(pluginKey, level, line),
|
||||
onStateChanged: () => this.notifyChanged(),
|
||||
onWorkerGone: (pluginKey) => this.eventBus.clear(pluginKey)
|
||||
})
|
||||
}
|
||||
|
||||
setRuntimeDelegate(delegate: PluginRuntimeDelegate | null): void {
|
||||
this.runtimeDelegate = delegate
|
||||
}
|
||||
|
||||
onChanged(listener: () => void): () => void {
|
||||
this.changeListeners.add(listener)
|
||||
return () => this.changeListeners.delete(listener)
|
||||
}
|
||||
|
||||
private notifyChanged(): void {
|
||||
for (const listener of this.changeListeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
this.initPromise ??= this.refresh()
|
||||
return this.initPromise
|
||||
}
|
||||
|
||||
async whenReady(): Promise<void> {
|
||||
await (this.initPromise ?? Promise.resolve()).catch(() => undefined)
|
||||
}
|
||||
|
||||
refresh(): Promise<void> {
|
||||
// Snapshot settings at request time so a quick off→on sequence still
|
||||
// processes the off transition and revokes old workers/panel sessions.
|
||||
const enabled = this.options.isPluginSystemEnabled()
|
||||
const devPaths = this.options.getDevPluginPaths()
|
||||
const consentLists = snapshotPluginConsentLists(this.options)
|
||||
const refresh = this.refreshChain.then(() =>
|
||||
this.performRefresh(enabled, devPaths, consentLists)
|
||||
)
|
||||
this.refreshChain = refresh.catch(() => undefined)
|
||||
return refresh
|
||||
}
|
||||
|
||||
private async performRefresh(
|
||||
enabled: boolean,
|
||||
devPaths: string[],
|
||||
consentLists: PluginConsentLists
|
||||
): Promise<void> {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
this.contentVerifier.clear()
|
||||
if (!enabled) {
|
||||
this.panels.revokeAll()
|
||||
}
|
||||
const next = enabled
|
||||
? await discoverPlugins({
|
||||
pluginsDir: getUserPluginsDir(this.options.userDataPath),
|
||||
devPluginPaths: devPaths,
|
||||
hostVersion: this.options.hostVersion
|
||||
})
|
||||
: []
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
const nextSpecs = collectApprovedWorkerSpecs(next, (plugin) =>
|
||||
isPluginApproved(enabled, plugin, consentLists)
|
||||
)
|
||||
// Publish identity before shutdown so triggers cannot restart old code.
|
||||
this.discovered = next
|
||||
// Notify before slow shutdown so feature-off unmounts panels immediately.
|
||||
this.notifyChanged()
|
||||
await this.workerController.reconcile(nextSpecs)
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
this.housekeeping.sync({
|
||||
enabled,
|
||||
devPaths,
|
||||
reapIdle: () => this.workerController.reapIdle(),
|
||||
refresh: () => void this.refresh()
|
||||
})
|
||||
this.notifyChanged()
|
||||
}
|
||||
|
||||
getDiscovered(): readonly DiscoveredPlugin[] {
|
||||
return this.discovered
|
||||
}
|
||||
|
||||
getRegistry(): PluginExtensionRegistry {
|
||||
return this.registry
|
||||
}
|
||||
|
||||
getAudit(): PluginAuditLog {
|
||||
return this.audit
|
||||
}
|
||||
|
||||
getLogs(pluginKey: string): PluginLogLine[] {
|
||||
return this.logBuffer.get(pluginKey)
|
||||
}
|
||||
|
||||
findValidPlugin(pluginKey: string): ValidDiscoveredPlugin | null {
|
||||
for (const plugin of this.discovered) {
|
||||
if (!isInvalidDiscoveredPlugin(plugin) && plugin.pluginKey === pluginKey) {
|
||||
return plugin
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
activationState(plugin: ValidDiscoveredPlugin): ReturnType<typeof getPluginActivationState> {
|
||||
// The feature flag is an authority boundary, not only a discovery hint:
|
||||
// callers fail closed immediately even before async reconciliation ends.
|
||||
if (!this.options.isPluginSystemEnabled()) {
|
||||
return 'disabled'
|
||||
}
|
||||
return getPluginActivationState(plugin.pluginKey, plugin.consentFingerprint, {
|
||||
pluginConsents: this.options.getPluginConsents(),
|
||||
disabledPlugins: this.options.getDisabledPlugins()
|
||||
})
|
||||
}
|
||||
|
||||
workerState(pluginKey: string): { state: PluginRunState; restarts: number } {
|
||||
return this.workerController.state(pluginKey)
|
||||
}
|
||||
|
||||
activationError(pluginKey: string): string | null {
|
||||
return this.workerController.activationError(pluginKey)
|
||||
}
|
||||
|
||||
/** Consented capability kinds for an approved plugin; null otherwise so
|
||||
* callers deny uniformly (no probe-able distinction). */
|
||||
getGrantedCapabilities(pluginKey: string): PluginCapabilityKind[] | null {
|
||||
const plugin = this.findValidPlugin(pluginKey)
|
||||
if (!plugin || this.activationState(plugin) !== 'approved') {
|
||||
return null
|
||||
}
|
||||
return capabilityKinds(plugin.manifest.capabilities)
|
||||
}
|
||||
|
||||
/** Host API chokepoint for both transports (worker fork IPC + panel
|
||||
* bridge); serve RPC reuses it through the same entry points. */
|
||||
async executeHostCall(
|
||||
pluginKey: string,
|
||||
method: string,
|
||||
params: unknown,
|
||||
options: { viaPanel: boolean }
|
||||
): Promise<PluginPanelActionOutcome> {
|
||||
return executePluginHostCallRequest({
|
||||
pluginKey,
|
||||
request: { method, params },
|
||||
viaPanel: options.viaPanel,
|
||||
resolvePolicy: (boundPluginKey) => ({
|
||||
grantedCapabilities: this.getGrantedCapabilities(boundPluginKey),
|
||||
services: this.runtimeDelegate
|
||||
? bindPluginHostServices({
|
||||
delegate: this.runtimeDelegate,
|
||||
pluginsDataDir: getPluginsDataDir(this.options.userDataPath),
|
||||
subscribeEvents: (key, events) => this.eventBus.subscribe(key, events)
|
||||
})
|
||||
: null,
|
||||
audit: this.audit
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async invokeCommand(pluginKey: string, commandId: string, args?: unknown): Promise<unknown> {
|
||||
const plugin = this.findValidPlugin(pluginKey)
|
||||
if (!plugin || this.activationState(plugin) !== 'approved') {
|
||||
throw new Error(`plugin ${pluginKey} is not enabled`)
|
||||
}
|
||||
if (!plugin.manifest.contributes.commands.some((command) => command.id === commandId)) {
|
||||
throw new Error(`plugin ${pluginKey} does not contribute command ${commandId}`)
|
||||
}
|
||||
const handle = await this.workerController.ensure(plugin)
|
||||
if (!handle.commands.includes(commandId)) {
|
||||
throw new Error(`plugin ${pluginKey} registered no handler for ${commandId}`)
|
||||
}
|
||||
return handle.invokeCommand(commandId, args)
|
||||
}
|
||||
|
||||
emitEvent(event: PluginEventName, payload: unknown): void {
|
||||
if (!this.options.isPluginSystemEnabled() || this.disposed) {
|
||||
return
|
||||
}
|
||||
const projected = this.eventBus.projectPayload(event, payload)
|
||||
if (!projected.ok) {
|
||||
return
|
||||
}
|
||||
for (const plugin of this.discovered) {
|
||||
if (isInvalidDiscoveredPlugin(plugin) || this.activationState(plugin) !== 'approved') {
|
||||
continue
|
||||
}
|
||||
const manifestSubscribed = plugin.manifest.contributes.events.some(
|
||||
(subscription) => subscription.on === event
|
||||
)
|
||||
if (manifestSubscribed && plugin.manifest.main) {
|
||||
void this.workerController
|
||||
.ensure(plugin)
|
||||
.then((handle) => handle.deliverEvent(event, projected.payload))
|
||||
.catch((error) => {
|
||||
this.logBuffer.append(
|
||||
plugin.pluginKey,
|
||||
'warn',
|
||||
`event ${event} dropped: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
})
|
||||
} else if (this.eventBus.isDynamicallySubscribed(plugin.pluginKey, event)) {
|
||||
this.workerController.deliverEventIfRunning(plugin.pluginKey, event, projected.payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async deactivatePlugin(pluginKey: string): Promise<void> {
|
||||
await this.workerController.deactivate(pluginKey)
|
||||
this.notifyChanged()
|
||||
}
|
||||
|
||||
/** Reconciles live workers and client projections after consent or
|
||||
* enablement changes without re-reading plugin files or starting workers. */
|
||||
async reconcileActivationState(): Promise<void> {
|
||||
const nextSpecs = collectApprovedWorkerSpecs(
|
||||
this.discovered,
|
||||
(plugin) => this.activationState(plugin) === 'approved'
|
||||
)
|
||||
await this.workerController.reconcile(nextSpecs)
|
||||
this.notifyChanged()
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true
|
||||
this.housekeeping.dispose()
|
||||
this.panels.dispose()
|
||||
await this.refreshChain.catch(() => undefined)
|
||||
await this.workerController.dispose()
|
||||
await this.audit.flush()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint'
|
||||
import {
|
||||
PLUGIN_MANIFEST_FILENAME,
|
||||
pluginManifestSchema,
|
||||
qualifiedPluginKey,
|
||||
type PluginManifest
|
||||
} from '../../shared/plugins/plugin-manifest'
|
||||
import { PluginService, type PluginServiceOptions } from './plugin-service'
|
||||
import type { PluginWorkerFactory } from './plugin-worker-manager'
|
||||
|
||||
const PLUGIN_COUNT = 20
|
||||
const SAMPLE_COUNT = 20
|
||||
const STARTUP_P95_BUDGET_MS = 50
|
||||
|
||||
let userDataPath = ''
|
||||
let markerPaths: string[] = []
|
||||
let consents: Record<string, string> = {}
|
||||
|
||||
function dummyManifest(index: number): PluginManifest {
|
||||
return pluginManifestSchema.parse({
|
||||
manifestVersion: 1,
|
||||
id: `dummy-${index}`,
|
||||
publisher: 'startup-budget',
|
||||
name: `Startup Dummy ${index}`,
|
||||
version: '1.0.0',
|
||||
engines: { orca: '>=1.0.0' },
|
||||
pluginApi: 1,
|
||||
main: 'main.mjs',
|
||||
contributes: { panels: [], commands: [], events: [] },
|
||||
capabilities: []
|
||||
})
|
||||
}
|
||||
|
||||
async function installDummy(index: number): Promise<{ pluginKey: string; markerPath: string }> {
|
||||
const manifest = dummyManifest(index)
|
||||
const pluginKey = qualifiedPluginKey(manifest)
|
||||
const contentHash = (index + 1).toString(16).padStart(64, '0')
|
||||
const pluginDir = join(userDataPath, 'plugins', pluginKey)
|
||||
const versionDir = join(pluginDir, contentHash)
|
||||
const markerPath = join(userDataPath, `activation-${index}.marker`)
|
||||
await mkdir(versionDir, { recursive: true })
|
||||
await Promise.all([
|
||||
writeFile(join(pluginDir, 'current'), contentHash),
|
||||
writeFile(join(versionDir, PLUGIN_MANIFEST_FILENAME), JSON.stringify(manifest)),
|
||||
writeFile(
|
||||
join(versionDir, 'main.mjs'),
|
||||
`import { writeFileSync } from 'node:fs'; writeFileSync(${JSON.stringify(markerPath)}, 'executed'); export default async function activate() { return {}; }`
|
||||
)
|
||||
])
|
||||
consents[pluginKey] = fingerprintPluginConsent(manifest)
|
||||
return { pluginKey, markerPath }
|
||||
}
|
||||
|
||||
function nearestRankP95(samples: readonly number[]): number {
|
||||
const sorted = [...samples].sort((left, right) => left - right)
|
||||
return sorted[Math.ceil(sorted.length * 0.95) - 1]!
|
||||
}
|
||||
|
||||
describe('plugin startup budget', () => {
|
||||
beforeAll(async () => {
|
||||
userDataPath = await mkdtemp(join(tmpdir(), 'orca-plugin-startup-budget-'))
|
||||
const installed = await Promise.all(
|
||||
Array.from({ length: PLUGIN_COUNT }, (_, index) => installDummy(index))
|
||||
)
|
||||
markerPaths = installed.map(({ markerPath }) => markerPath)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('stays below 50ms P95 with 20 approved plugins and executes no plugin code', async () => {
|
||||
const workerFactory = vi.fn<PluginWorkerFactory>(async () => {
|
||||
throw new Error('startup must not create a plugin worker')
|
||||
})
|
||||
const options: PluginServiceOptions = {
|
||||
userDataPath,
|
||||
hostVersion: '1.4.0',
|
||||
isPluginSystemEnabled: () => true,
|
||||
getDisabledPlugins: () => [],
|
||||
getPluginConsents: () => consents,
|
||||
getDevPluginPaths: () => [],
|
||||
workerFactory
|
||||
}
|
||||
const measure = async (): Promise<number> => {
|
||||
const startedAt = performance.now()
|
||||
const service = new PluginService(options)
|
||||
await service.initialize()
|
||||
const elapsedMs = performance.now() - startedAt
|
||||
expect(service.getDiscovered()).toHaveLength(PLUGIN_COUNT)
|
||||
await service.dispose()
|
||||
return elapsedMs
|
||||
}
|
||||
|
||||
await measure()
|
||||
const samples: number[] = []
|
||||
for (let index = 0; index < SAMPLE_COUNT; index += 1) {
|
||||
samples.push(await measure())
|
||||
}
|
||||
|
||||
const p95 = nearestRankP95(samples)
|
||||
if (process.env.ORCA_PLUGIN_STARTUP_BUDGET_REPORT === '1') {
|
||||
process.stdout.write(`plugin startup P95 ${p95.toFixed(2)}ms (${SAMPLE_COUNT} samples)\n`)
|
||||
}
|
||||
expect(workerFactory).not.toHaveBeenCalled()
|
||||
await Promise.all(
|
||||
markerPaths.map((markerPath) =>
|
||||
expect(readFile(markerPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
)
|
||||
)
|
||||
expect(
|
||||
p95,
|
||||
`plugin startup P95 ${p95.toFixed(2)}ms; samples: ${samples.map((sample) => sample.toFixed(2)).join(', ')}`
|
||||
).toBeLessThan(STARTUP_P95_BUDGET_MS)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { PLUGIN_STORAGE_VALUE_MAX_BYTES } from '../../shared/plugins/plugin-host-api'
|
||||
import { PluginKvStore } from './plugin-storage-store'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('PluginKvStore limits', () => {
|
||||
it('enforces the value cap in UTF-8 bytes rather than JavaScript code units', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-storage-'))
|
||||
roots.push(root)
|
||||
const store = new PluginKvStore(root, 'orca-samples.demo', 'storage.json')
|
||||
const value = '😀'.repeat(Math.ceil(PLUGIN_STORAGE_VALUE_MAX_BYTES / 4) + 1)
|
||||
|
||||
expect(store.set('large', value)).toMatchObject({
|
||||
ok: false,
|
||||
error: expect.stringContaining('exceeds')
|
||||
})
|
||||
expect(store.get('large')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { writeSecureFile } from '../../shared/secure-file'
|
||||
import { isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest'
|
||||
import {
|
||||
PLUGIN_STORAGE_KEY_LIMIT,
|
||||
PLUGIN_STORAGE_TOTAL_MAX_BYTES,
|
||||
PLUGIN_STORAGE_VALUE_MAX_BYTES
|
||||
} from '../../shared/plugins/plugin-host-api'
|
||||
|
||||
/**
|
||||
* Per-plugin JSON key-value persistence backing both `storage.*` (plugin
|
||||
* data) and `settings.*` (settings:own). Each plugin's data lives in its OWN
|
||||
* file under `<userData>/plugins-data/<publisher>.<id>/` — never a shared
|
||||
* namespaced blob, so one plugin's path can never resolve into another's.
|
||||
* Adapted from community PR #5801's per-plugin settings store.
|
||||
*/
|
||||
|
||||
export function pluginDataDir(pluginsDataDir: string, qualifiedKey: string): string {
|
||||
if (!isQualifiedPluginKey(qualifiedKey)) {
|
||||
throw new Error(`unsafe plugin key: ${qualifiedKey}`)
|
||||
}
|
||||
return join(pluginsDataDir, qualifiedKey)
|
||||
}
|
||||
|
||||
export type PluginKvWriteResult = { ok: true } | { ok: false; error: string }
|
||||
|
||||
export class PluginKvStore {
|
||||
private readonly filePath: string
|
||||
|
||||
constructor(
|
||||
pluginsDataDir: string,
|
||||
qualifiedKey: string,
|
||||
fileName: 'storage.json' | 'settings.json'
|
||||
) {
|
||||
this.filePath = join(pluginDataDir(pluginsDataDir, qualifiedKey), fileName)
|
||||
}
|
||||
|
||||
private read(): Record<string, unknown> {
|
||||
try {
|
||||
if (!existsSync(this.filePath)) {
|
||||
return {}
|
||||
}
|
||||
if (statSync(this.filePath).size > PLUGIN_STORAGE_TOTAL_MAX_BYTES) {
|
||||
return {}
|
||||
}
|
||||
const parsed: unknown = JSON.parse(readFileSync(this.filePath, 'utf8'))
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
} catch {
|
||||
// Corrupt files reset to empty rather than wedging the plugin.
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
get(key: string): unknown {
|
||||
return this.read()[key]
|
||||
}
|
||||
|
||||
getAll(): Record<string, unknown> {
|
||||
return this.read()
|
||||
}
|
||||
|
||||
keys(): string[] {
|
||||
return Object.keys(this.read())
|
||||
}
|
||||
|
||||
set(key: string, value: unknown): PluginKvWriteResult {
|
||||
let serialized: string
|
||||
try {
|
||||
serialized = JSON.stringify(value)
|
||||
} catch {
|
||||
return { ok: false, error: 'value is not JSON-serializable' }
|
||||
}
|
||||
if (serialized === undefined) {
|
||||
return { ok: false, error: 'value is not JSON-serializable' }
|
||||
}
|
||||
if (Buffer.byteLength(serialized, 'utf8') > PLUGIN_STORAGE_VALUE_MAX_BYTES) {
|
||||
return { ok: false, error: `value exceeds ${PLUGIN_STORAGE_VALUE_MAX_BYTES} bytes` }
|
||||
}
|
||||
const settings = this.read()
|
||||
if (!Object.hasOwn(settings, key) && Object.keys(settings).length >= PLUGIN_STORAGE_KEY_LIMIT) {
|
||||
return { ok: false, error: `storage exceeds the ${PLUGIN_STORAGE_KEY_LIMIT}-key limit` }
|
||||
}
|
||||
settings[key] = value
|
||||
const nextFile = JSON.stringify(settings, null, 2)
|
||||
if (Buffer.byteLength(nextFile, 'utf8') > PLUGIN_STORAGE_TOTAL_MAX_BYTES) {
|
||||
return { ok: false, error: `storage exceeds ${PLUGIN_STORAGE_TOTAL_MAX_BYTES} bytes` }
|
||||
}
|
||||
writeSecureFile(this.filePath, nextFile)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
const settings = this.read()
|
||||
if (Object.hasOwn(settings, key)) {
|
||||
delete settings[key]
|
||||
writeSecureFile(this.filePath, JSON.stringify(settings, null, 2))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Crash/restart supervision policy for plugin workers — the pure decision
|
||||
* half of the runtime. Decides whether a worker that exited should be
|
||||
* restarted (with backoff) or marked errored after too many crashes.
|
||||
*
|
||||
* Pure + deterministic: callers own the actual timers and forking; this just
|
||||
* tracks per-plugin state and returns decisions. Ported from community PR
|
||||
* #5801 (gsxdsm).
|
||||
*/
|
||||
|
||||
export type PluginRunState = 'inactive' | 'running' | 'restarting' | 'errored'
|
||||
|
||||
export type PluginExitInfo = {
|
||||
/** Clean exit from a host-initiated deactivate vs. an unexpected crash. */
|
||||
crashed: boolean
|
||||
}
|
||||
|
||||
export type PluginRestartDecision =
|
||||
| { restart: true; delayMs: number; attempt: number }
|
||||
| { restart: false; state: PluginRunState }
|
||||
|
||||
export type PluginSupervisionConfig = {
|
||||
/** Max crash-restarts before a plugin is marked errored. `0` means no
|
||||
* restart attempts — the first crash goes straight to errored. */
|
||||
maxRestarts: number
|
||||
/** Backoff schedule indexed by attempt; the last entry is reused past its
|
||||
* end. Must be non-empty (enforced in the constructor). */
|
||||
backoffMs: number[]
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: PluginSupervisionConfig = {
|
||||
maxRestarts: 3,
|
||||
backoffMs: [500, 2000, 5000]
|
||||
}
|
||||
|
||||
type Entry = { state: PluginRunState; restarts: number }
|
||||
|
||||
export class PluginSupervisor {
|
||||
private readonly entries = new Map<string, Entry>()
|
||||
private readonly config: PluginSupervisionConfig
|
||||
|
||||
constructor(config: Partial<PluginSupervisionConfig> = {}) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config }
|
||||
// Guard misconfiguration: an empty backoff schedule would index [-1] →
|
||||
// undefined delay (immediate restart loop); a negative cap is meaningless.
|
||||
if (this.config.maxRestarts < 0) {
|
||||
throw new Error('PluginSupervisionConfig.maxRestarts must be >= 0')
|
||||
}
|
||||
if (this.config.backoffMs.length === 0) {
|
||||
throw new Error('PluginSupervisionConfig.backoffMs must be non-empty')
|
||||
}
|
||||
}
|
||||
|
||||
getState(id: string): PluginRunState {
|
||||
return this.entries.get(id)?.state ?? 'inactive'
|
||||
}
|
||||
|
||||
restartCount(id: string): number {
|
||||
return this.entries.get(id)?.restarts ?? 0
|
||||
}
|
||||
|
||||
/** Mark a plugin as running. A fresh activation (not a restart) resets the
|
||||
* crash counter so a previously-flaky plugin gets a clean slate. */
|
||||
markRunning(id: string, options: { resetRestarts?: boolean } = {}): void {
|
||||
const prior = this.entries.get(id)
|
||||
this.entries.set(id, {
|
||||
state: 'running',
|
||||
restarts: options.resetRestarts ? 0 : (prior?.restarts ?? 0)
|
||||
})
|
||||
}
|
||||
|
||||
/** Record that the worker exited and decide what to do next. */
|
||||
markExited(id: string, info: PluginExitInfo): PluginRestartDecision {
|
||||
if (!info.crashed) {
|
||||
// Host-initiated stop (or idle reap): go inactive, clear history.
|
||||
this.entries.set(id, { state: 'inactive', restarts: 0 })
|
||||
return { restart: false, state: 'inactive' }
|
||||
}
|
||||
const entry = this.entries.get(id)
|
||||
// An exit for an untracked plugin is not a running crash to restart.
|
||||
if (!entry) {
|
||||
return { restart: false, state: 'inactive' }
|
||||
}
|
||||
if (entry.restarts >= this.config.maxRestarts) {
|
||||
this.entries.set(id, { state: 'errored', restarts: entry.restarts })
|
||||
return { restart: false, state: 'errored' }
|
||||
}
|
||||
const attempt = entry.restarts + 1
|
||||
const idx = Math.max(0, Math.min(entry.restarts, this.config.backoffMs.length - 1))
|
||||
this.entries.set(id, { state: 'restarting', restarts: attempt })
|
||||
return { restart: true, delayMs: this.config.backoffMs[idx]!, attempt }
|
||||
}
|
||||
|
||||
/** Clear all state (deactivate/remove or a manual re-enable). */
|
||||
reset(id: string): void {
|
||||
this.entries.delete(id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPluginExtensionRegistry } from '../../shared/plugins/plugin-extension-registry'
|
||||
import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest'
|
||||
import type { PluginContentVerifier } from './plugin-content-integrity'
|
||||
import type { ValidDiscoveredPlugin } from './plugin-discovery'
|
||||
import type { PluginWorkerHandle } from './plugin-host-process'
|
||||
import { PluginWorkerController } from './plugin-worker-controller'
|
||||
import type { PluginWorkerFactory } from './plugin-worker-manager'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function plugin(): Promise<ValidDiscoveredPlugin> {
|
||||
const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-worker-controller-'))
|
||||
roots.push(rootDir)
|
||||
await writeFile(join(rootDir, 'main.mjs'), 'export default function activate() {}')
|
||||
return {
|
||||
pluginKey: 'orca-samples.demo',
|
||||
rootDir,
|
||||
manifest: pluginManifestSchema.parse({
|
||||
manifestVersion: 1,
|
||||
id: 'demo',
|
||||
publisher: 'orca-samples',
|
||||
name: 'Demo',
|
||||
version: '1.0.0',
|
||||
engines: { orca: '>=1.0.0' },
|
||||
pluginApi: 1,
|
||||
main: 'main.mjs',
|
||||
contributes: {
|
||||
panels: [],
|
||||
commands: [{ id: 'run', title: 'Run' }],
|
||||
events: []
|
||||
},
|
||||
capabilities: []
|
||||
}),
|
||||
consentFingerprint: 'sha256-current',
|
||||
contentHash: null,
|
||||
isDev: true
|
||||
}
|
||||
}
|
||||
|
||||
function worker(commands: string[]): PluginWorkerHandle & { dispose: ReturnType<typeof vi.fn> } {
|
||||
return {
|
||||
commands,
|
||||
invokeCommand: vi.fn(async () => null),
|
||||
deliverEvent: vi.fn(),
|
||||
lastActivityAt: () => Date.now(),
|
||||
inFlightCount: () => 0,
|
||||
dispose: vi.fn(async () => undefined),
|
||||
kill: vi.fn(),
|
||||
onExit: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function controller(options: {
|
||||
factory: PluginWorkerFactory
|
||||
verify: () => Promise<void>
|
||||
isApproved: () => boolean
|
||||
}): PluginWorkerController {
|
||||
return new PluginWorkerController({
|
||||
entryPath: '/host-entry.js',
|
||||
workerFactory: options.factory,
|
||||
registry: createPluginExtensionRegistry(),
|
||||
contentVerifier: { verify: options.verify } as unknown as PluginContentVerifier,
|
||||
capabilities: () => (options.isApproved() ? [] : null),
|
||||
isCurrentApproved: () => options.isApproved(),
|
||||
invokeCommand: vi.fn(async () => null),
|
||||
executeHostCall: vi.fn(async () => ({ ok: true as const, value: null })),
|
||||
log: vi.fn(),
|
||||
onStateChanged: vi.fn(),
|
||||
onWorkerGone: vi.fn()
|
||||
})
|
||||
}
|
||||
|
||||
describe('PluginWorkerController activation authority', () => {
|
||||
it('does not start code after approval is revoked during integrity verification', async () => {
|
||||
const subjectPlugin = await plugin()
|
||||
let approved = true
|
||||
let finishVerification!: () => void
|
||||
const verification = new Promise<void>((resolve) => {
|
||||
finishVerification = resolve
|
||||
})
|
||||
const factory = vi.fn<PluginWorkerFactory>()
|
||||
const subject = controller({ factory, verify: () => verification, isApproved: () => approved })
|
||||
|
||||
const activation = subject.ensure(subjectPlugin)
|
||||
approved = false
|
||||
finishVerification()
|
||||
|
||||
await expect(activation).rejects.toThrow('no longer approved')
|
||||
expect(factory).not.toHaveBeenCalled()
|
||||
await subject.dispose()
|
||||
})
|
||||
|
||||
it('disposes a worker whose approval changes while the process starts', async () => {
|
||||
const subjectPlugin = await plugin()
|
||||
let approved = true
|
||||
let finishStart!: (handle: PluginWorkerHandle) => void
|
||||
const factory = vi.fn<PluginWorkerFactory>(
|
||||
() => new Promise<PluginWorkerHandle>((resolve) => (finishStart = resolve))
|
||||
)
|
||||
const subject = controller({
|
||||
factory,
|
||||
verify: async () => undefined,
|
||||
isApproved: () => approved
|
||||
})
|
||||
const startedWorker = worker(['run'])
|
||||
|
||||
const activation = subject.ensure(subjectPlugin)
|
||||
await vi.waitFor(() => expect(factory).toHaveBeenCalledOnce())
|
||||
approved = false
|
||||
finishStart(startedWorker)
|
||||
|
||||
await expect(activation).rejects.toThrow('disabled during activation')
|
||||
expect(startedWorker.dispose).toHaveBeenCalledOnce()
|
||||
await subject.dispose()
|
||||
})
|
||||
|
||||
it('rejects and stops workers that register undeclared commands', async () => {
|
||||
const subjectPlugin = await plugin()
|
||||
const startedWorker = worker(['run', 'undeclared'])
|
||||
const subject = controller({
|
||||
factory: vi.fn<PluginWorkerFactory>().mockResolvedValue(startedWorker),
|
||||
verify: async () => undefined,
|
||||
isApproved: () => true
|
||||
})
|
||||
|
||||
await expect(subject.ensure(subjectPlugin)).rejects.toThrow(
|
||||
'registered undeclared command undeclared'
|
||||
)
|
||||
expect(startedWorker.dispose).toHaveBeenCalledOnce()
|
||||
await subject.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,169 @@
|
||||
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 {
|
||||
PLUGIN_COMMAND_EXTENSION_POINT,
|
||||
type PluginExtensionRegistry
|
||||
} from '../../shared/plugins/plugin-extension-registry'
|
||||
import type { ValidDiscoveredPlugin } from './plugin-discovery'
|
||||
import { resolveContainedPluginArtifact } from './plugin-artifact-validation'
|
||||
import type { PluginContentVerifier } from './plugin-content-integrity'
|
||||
import {
|
||||
PluginWorkerManager,
|
||||
type PluginWorkerFactory,
|
||||
type PluginWorkerSpawnSpec
|
||||
} from './plugin-worker-manager'
|
||||
import { buildPluginWorkerSpawnSpec, pluginWorkerSpawnSpecsEqual } from './plugin-worker-spawn-spec'
|
||||
import type { PluginWorkerHandle } from './plugin-host-process'
|
||||
import type { PluginRunState } from './plugin-supervisor'
|
||||
|
||||
export type PluginWorkerControllerOptions = {
|
||||
entryPath: string
|
||||
maxActive?: number
|
||||
idleReapMs?: number
|
||||
workerFactory?: PluginWorkerFactory
|
||||
registry: PluginExtensionRegistry
|
||||
contentVerifier: PluginContentVerifier
|
||||
capabilities: (pluginKey: string) => readonly PluginCapabilityKind[] | null
|
||||
isCurrentApproved: (plugin: ValidDiscoveredPlugin) => boolean
|
||||
invokeCommand: (pluginKey: string, commandId: string, args: unknown) => Promise<unknown>
|
||||
executeHostCall: (
|
||||
pluginKey: string,
|
||||
method: string,
|
||||
params: unknown
|
||||
) => Promise<PluginPanelActionOutcome>
|
||||
log: (pluginKey: string, level: 'info' | 'warn' | 'error', line: string) => void
|
||||
onStateChanged: (pluginKey: string) => void
|
||||
onWorkerGone: (pluginKey: string) => void
|
||||
}
|
||||
|
||||
export class PluginWorkerController {
|
||||
private readonly manager: PluginWorkerManager
|
||||
private readonly activationErrors = new Map<string, string>()
|
||||
private readonly registeredSpecs = new Map<string, PluginWorkerSpawnSpec>()
|
||||
|
||||
constructor(private readonly options: PluginWorkerControllerOptions) {
|
||||
this.manager = new PluginWorkerManager({
|
||||
entryPath: options.entryPath,
|
||||
maxActive: options.maxActive,
|
||||
idleReapMs: options.idleReapMs,
|
||||
workerFactory: options.workerFactory,
|
||||
executeHostCall: options.executeHostCall,
|
||||
log: options.log,
|
||||
onWorkerStateChange: options.onStateChanged,
|
||||
onWorkerGone: options.onWorkerGone
|
||||
})
|
||||
}
|
||||
|
||||
state(pluginKey: string): { state: PluginRunState; restarts: number } {
|
||||
return {
|
||||
state: this.manager.runState(pluginKey),
|
||||
restarts: this.manager.restartCount(pluginKey)
|
||||
}
|
||||
}
|
||||
|
||||
activationError(pluginKey: string): string | null {
|
||||
return this.activationErrors.get(pluginKey) ?? null
|
||||
}
|
||||
|
||||
async ensure(plugin: ValidDiscoveredPlugin): Promise<PluginWorkerHandle> {
|
||||
if (!plugin.manifest.main) {
|
||||
throw new Error(`plugin ${plugin.pluginKey} has no worker entry`)
|
||||
}
|
||||
try {
|
||||
this.assertCurrentApproved(plugin)
|
||||
await this.options.contentVerifier.verify(plugin)
|
||||
await resolveContainedPluginArtifact(plugin.rootDir, plugin.manifest.main)
|
||||
this.assertCurrentApproved(plugin)
|
||||
const capabilities = this.options.capabilities(plugin.pluginKey)
|
||||
if (!capabilities) {
|
||||
throw new Error(`plugin ${plugin.pluginKey} is no longer approved`)
|
||||
}
|
||||
const spec = buildPluginWorkerSpawnSpec(plugin, capabilities)
|
||||
const handle = await this.manager.ensureActive(spec)
|
||||
if (!this.options.isCurrentApproved(plugin)) {
|
||||
await this.manager.deactivate(plugin.pluginKey)
|
||||
throw new Error(`plugin ${plugin.pluginKey} changed or was disabled during activation`)
|
||||
}
|
||||
const declaredCommands = new Set(
|
||||
plugin.manifest.contributes.commands.map((command) => command.id)
|
||||
)
|
||||
const undeclaredCommand = handle.commands.find((command) => !declaredCommands.has(command))
|
||||
if (undeclaredCommand) {
|
||||
await this.manager.deactivate(plugin.pluginKey)
|
||||
throw new Error(
|
||||
`plugin ${plugin.pluginKey} registered undeclared command ${undeclaredCommand}`
|
||||
)
|
||||
}
|
||||
this.activationErrors.delete(plugin.pluginKey)
|
||||
this.registerCommands(plugin, spec, handle.commands)
|
||||
return handle
|
||||
} catch (error) {
|
||||
this.activationErrors.set(
|
||||
plugin.pluginKey,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
this.options.onStateChanged(plugin.pluginKey)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private assertCurrentApproved(plugin: ValidDiscoveredPlugin): void {
|
||||
if (!this.options.isCurrentApproved(plugin)) {
|
||||
throw new Error(`plugin ${plugin.pluginKey} changed or is no longer approved`)
|
||||
}
|
||||
}
|
||||
|
||||
async reconcile(nextSpecs: ReadonlyMap<string, PluginWorkerSpawnSpec>): Promise<void> {
|
||||
const current = new Map([...this.registeredSpecs, ...this.manager.trackedSpecs()])
|
||||
for (const [pluginKey, spec] of current) {
|
||||
const next = nextSpecs.get(pluginKey)
|
||||
if (next && pluginWorkerSpawnSpecsEqual(spec, next)) {
|
||||
continue
|
||||
}
|
||||
this.options.registry.clearPlugin(pluginKey)
|
||||
this.registeredSpecs.delete(pluginKey)
|
||||
this.activationErrors.delete(pluginKey)
|
||||
await this.manager.deactivate(pluginKey)
|
||||
}
|
||||
}
|
||||
|
||||
async deactivate(pluginKey: string): Promise<void> {
|
||||
this.options.registry.clearPlugin(pluginKey)
|
||||
this.registeredSpecs.delete(pluginKey)
|
||||
this.activationErrors.delete(pluginKey)
|
||||
await this.manager.deactivate(pluginKey)
|
||||
}
|
||||
|
||||
reapIdle(): void {
|
||||
this.manager.reapIdle()
|
||||
}
|
||||
|
||||
deliverEventIfRunning(pluginKey: string, event: PluginEventName, payload: unknown): void {
|
||||
this.manager.deliverEventIfRunning(pluginKey, event, payload)
|
||||
}
|
||||
|
||||
dispose(): Promise<void> {
|
||||
return this.manager.disposeAll()
|
||||
}
|
||||
|
||||
private registerCommands(
|
||||
plugin: ValidDiscoveredPlugin,
|
||||
spec: PluginWorkerSpawnSpec,
|
||||
commands: readonly string[]
|
||||
): void {
|
||||
this.options.registry.clearPlugin(plugin.pluginKey)
|
||||
for (const commandId of commands) {
|
||||
this.options.registry.register(
|
||||
PLUGIN_COMMAND_EXTENSION_POINT,
|
||||
plugin.pluginKey,
|
||||
{
|
||||
commandId,
|
||||
invoke: (args) => this.options.invokeCommand(plugin.pluginKey, commandId, args)
|
||||
},
|
||||
commandId
|
||||
)
|
||||
}
|
||||
this.registeredSpecs.set(plugin.pluginKey, spec)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildPluginWorkerEnv } from './plugin-worker-env'
|
||||
|
||||
describe('buildPluginWorkerEnv', () => {
|
||||
it('matches allowlisted keys case-sensitively on POSIX', () => {
|
||||
expect(
|
||||
buildPluginWorkerEnv(
|
||||
{ PATH: '/safe', path: '/wrong', HOME: '/home', NODE_OPTIONS: '--inspect' },
|
||||
'linux'
|
||||
)
|
||||
).toEqual({ PATH: '/safe', HOME: '/home', ELECTRON_RUN_AS_NODE: '1' })
|
||||
})
|
||||
|
||||
it('matches Windows environment keys case-insensitively', () => {
|
||||
expect(buildPluginWorkerEnv({ Path: 'C:\\safe', systemroot: 'C:\\Windows' }, 'win32')).toEqual({
|
||||
PATH: 'C:\\safe',
|
||||
SystemRoot: 'C:\\Windows',
|
||||
ELECTRON_RUN_AS_NODE: '1'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Scrubbed environment for plugin workers. Deliberately an allowlist — the
|
||||
* app's own environment can carry secrets (tokens exported in the user's
|
||||
* shell, CI credentials); plugins must not inherit it. This intentionally
|
||||
* diverges from the sidecar precedent, which spreads the full process.env.
|
||||
*/
|
||||
|
||||
const WORKER_ENV_ALLOWLIST = [
|
||||
'PATH',
|
||||
'HOME',
|
||||
'USERPROFILE',
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
'LC_CTYPE',
|
||||
'TZ',
|
||||
'TMPDIR',
|
||||
'TEMP',
|
||||
'TMP',
|
||||
// Why: Windows Node/libuv need these to resolve DLLs and the machine root.
|
||||
'SYSTEMROOT',
|
||||
'SYSTEMDRIVE',
|
||||
'WINDIR',
|
||||
'COMSPEC',
|
||||
'PATHEXT',
|
||||
'PROCESSOR_ARCHITECTURE',
|
||||
'NUMBER_OF_PROCESSORS'
|
||||
] as const
|
||||
|
||||
export function buildPluginWorkerEnv(
|
||||
baseEnv: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Record<string, string> {
|
||||
const env: Record<string, string> = {}
|
||||
const windowsLookup = new Map<string, string>()
|
||||
if (platform === 'win32') {
|
||||
// Why: Windows environment keys are case-insensitive, while POSIX keys
|
||||
// are not; folding on every platform could promote an attacker-set `path`.
|
||||
for (const [key, value] of Object.entries(baseEnv)) {
|
||||
if (typeof value === 'string') {
|
||||
windowsLookup.set(key.toUpperCase(), value)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key of WORKER_ENV_ALLOWLIST) {
|
||||
const value = platform === 'win32' ? windowsLookup.get(key) : baseEnv[key]
|
||||
if (value !== undefined) {
|
||||
env[key === 'SYSTEMROOT' ? 'SystemRoot' : key] = value
|
||||
}
|
||||
}
|
||||
env.ELECTRON_RUN_AS_NODE = '1'
|
||||
return env
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PluginWorkerHandle } from './plugin-host-process'
|
||||
import {
|
||||
PluginWorkerManager,
|
||||
type PluginWorkerFactory,
|
||||
type PluginWorkerSpawnSpec
|
||||
} from './plugin-worker-manager'
|
||||
|
||||
type TestWorker = PluginWorkerHandle & {
|
||||
exit(code?: number | null): void
|
||||
dispose: ReturnType<typeof vi.fn<() => Promise<void>>>
|
||||
}
|
||||
|
||||
function worker(lastActivity = Date.now()): TestWorker {
|
||||
const exitCallbacks: ((code: number | null) => void)[] = []
|
||||
return {
|
||||
commands: ['run'],
|
||||
invokeCommand: vi.fn(async () => null),
|
||||
deliverEvent: vi.fn(),
|
||||
lastActivityAt: () => lastActivity,
|
||||
inFlightCount: () => 0,
|
||||
dispose: vi.fn(async () => undefined),
|
||||
kill: vi.fn(),
|
||||
onExit: (callback) => exitCallbacks.push(callback),
|
||||
exit: (code = 1) => {
|
||||
for (const callback of exitCallbacks) {
|
||||
callback(code)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function spec(pluginKey: string): PluginWorkerSpawnSpec {
|
||||
return {
|
||||
pluginKey,
|
||||
rootDir: `/plugins/${pluginKey}`,
|
||||
mainEntry: 'worker.js',
|
||||
grantedCapabilities: []
|
||||
}
|
||||
}
|
||||
|
||||
function manager(
|
||||
factory: PluginWorkerFactory,
|
||||
options: { maxActive?: number; idleReapMs?: number } = {}
|
||||
): PluginWorkerManager {
|
||||
return new PluginWorkerManager({
|
||||
entryPath: '/host.js',
|
||||
workerFactory: factory,
|
||||
maxActive: options.maxActive,
|
||||
idleReapMs: options.idleReapMs,
|
||||
executeHostCall: async () => ({ ok: true, value: null }),
|
||||
log: vi.fn(),
|
||||
onWorkerStateChange: vi.fn(),
|
||||
onWorkerGone: vi.fn()
|
||||
})
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('PluginWorkerManager capacity', () => {
|
||||
it('atomically counts in-flight starts against maxActive', async () => {
|
||||
const starts: { key: string; resolve: (handle: TestWorker) => void }[] = []
|
||||
const factory = vi.fn<PluginWorkerFactory>(
|
||||
({ pluginId }) => new Promise((resolve) => starts.push({ key: pluginId, resolve }))
|
||||
)
|
||||
const subject = manager(factory, { maxActive: 1 })
|
||||
|
||||
const first = subject.ensureActive(spec('one'))
|
||||
const second = subject.ensureActive(spec('two'))
|
||||
const third = subject.ensureActive(spec('three'))
|
||||
await flush()
|
||||
|
||||
expect(starts.map((start) => start.key)).toEqual(['one'])
|
||||
starts[0]!.resolve(worker())
|
||||
await first
|
||||
await subject.deactivate('one')
|
||||
await flush()
|
||||
expect(starts.map((start) => start.key)).toEqual(['one', 'two'])
|
||||
|
||||
starts[1]!.resolve(worker())
|
||||
await second
|
||||
await subject.deactivate('two')
|
||||
await flush()
|
||||
expect(starts.map((start) => start.key)).toEqual(['one', 'two', 'three'])
|
||||
starts[2]!.resolve(worker())
|
||||
await third
|
||||
await subject.disposeAll()
|
||||
})
|
||||
|
||||
it('removes a cancelled waiter without disturbing FIFO order', async () => {
|
||||
const starts: { key: string; resolve: (handle: TestWorker) => void }[] = []
|
||||
const factory = vi.fn<PluginWorkerFactory>(
|
||||
({ pluginId }) => new Promise((resolve) => starts.push({ key: pluginId, resolve }))
|
||||
)
|
||||
const subject = manager(factory, { maxActive: 1 })
|
||||
const first = subject.ensureActive(spec('one'))
|
||||
const cancelled = subject.ensureActive(spec('two'))
|
||||
const third = subject.ensureActive(spec('three'))
|
||||
await flush()
|
||||
starts[0]!.resolve(worker())
|
||||
await first
|
||||
|
||||
await subject.deactivate('two')
|
||||
await expect(cancelled).rejects.toThrow('cancelled')
|
||||
await subject.deactivate('one')
|
||||
await flush()
|
||||
|
||||
expect(starts.map((start) => start.key)).toEqual(['one', 'three'])
|
||||
starts[1]!.resolve(worker())
|
||||
await third
|
||||
await subject.disposeAll()
|
||||
})
|
||||
|
||||
it('releases a failed start so the next FIFO waiter can run', async () => {
|
||||
vi.useFakeTimers()
|
||||
const secondWorker = worker()
|
||||
const factory = vi.fn<PluginWorkerFactory>(async ({ pluginId }) => {
|
||||
if (pluginId === 'one') {
|
||||
throw new Error('ready failed')
|
||||
}
|
||||
return secondWorker
|
||||
})
|
||||
const subject = manager(factory, { maxActive: 1 })
|
||||
const first = subject.ensureActive(spec('one'))
|
||||
const firstSettled = first.catch(() => undefined)
|
||||
const second = subject.ensureActive(spec('two'))
|
||||
|
||||
await flush()
|
||||
await expect(second).resolves.toBe(secondWorker)
|
||||
expect(factory.mock.calls.map(([options]) => options.pluginId)).toEqual(['one', 'two'])
|
||||
await subject.deactivate('one')
|
||||
await firstSettled
|
||||
await subject.disposeAll()
|
||||
})
|
||||
|
||||
it('cancels an in-flight start without allowing its generation to land', async () => {
|
||||
const factory = vi.fn<PluginWorkerFactory>(
|
||||
({ signal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(new Error('factory cancelled')), {
|
||||
once: true
|
||||
})
|
||||
})
|
||||
)
|
||||
const subject = manager(factory)
|
||||
const activation = subject.ensureActive(spec('starting'))
|
||||
await flush()
|
||||
|
||||
await subject.deactivate('starting')
|
||||
|
||||
await expect(activation).rejects.toThrow('cancelled')
|
||||
expect(subject.runState('starting')).toBe('inactive')
|
||||
expect(subject.trackedSpecs().has('starting')).toBe(false)
|
||||
await subject.disposeAll()
|
||||
})
|
||||
|
||||
it('disposes running workers and rejects queued waiters', async () => {
|
||||
const first = worker()
|
||||
const factory = vi.fn<PluginWorkerFactory>(async ({ pluginId }) => {
|
||||
if (pluginId === 'one') {
|
||||
return first
|
||||
}
|
||||
return new Promise<PluginWorkerHandle>(() => undefined)
|
||||
})
|
||||
const subject = manager(factory, { maxActive: 1 })
|
||||
await subject.ensureActive(spec('one'))
|
||||
const queued = subject.ensureActive(spec('two'))
|
||||
const queuedSettled = queued.catch((error) => error)
|
||||
await flush()
|
||||
|
||||
await subject.disposeAll()
|
||||
|
||||
expect(first.dispose).toHaveBeenCalledOnce()
|
||||
await expect(queuedSettled).resolves.toBeInstanceOf(Error)
|
||||
expect(factory).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PluginWorkerManager restart policy', () => {
|
||||
it('cancels a stale in-flight revision instead of joining it by plugin key', async () => {
|
||||
const currentWorker = worker()
|
||||
const factory = vi.fn<PluginWorkerFactory>(({ rootDir, signal }) => {
|
||||
if (rootDir === '/plugins/old') {
|
||||
return new Promise((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(new Error('old revision cancelled')), {
|
||||
once: true
|
||||
})
|
||||
})
|
||||
}
|
||||
return Promise.resolve(currentWorker)
|
||||
})
|
||||
const subject = manager(factory)
|
||||
const oldSpec = { ...spec('demo'), rootDir: '/plugins/old', manifestRevision: 'old' }
|
||||
const newSpec = { ...spec('demo'), rootDir: '/plugins/new', manifestRevision: 'new' }
|
||||
|
||||
const oldActivation = subject.ensureActive(oldSpec)
|
||||
await flush()
|
||||
const currentActivation = subject.ensureActive(newSpec)
|
||||
|
||||
await expect(oldActivation).rejects.toThrow('cancelled')
|
||||
await expect(currentActivation).resolves.toBe(currentWorker)
|
||||
expect(factory.mock.calls.map(([options]) => options.rootDir)).toEqual([
|
||||
'/plugins/old',
|
||||
'/plugins/new'
|
||||
])
|
||||
await subject.disposeAll()
|
||||
})
|
||||
|
||||
it('retries startup failures at 500/2000/5000ms before errored', async () => {
|
||||
vi.useFakeTimers()
|
||||
const factory = vi.fn<PluginWorkerFactory>(async () => {
|
||||
throw new Error('not ready')
|
||||
})
|
||||
const subject = manager(factory)
|
||||
const activation = subject.ensureActive(spec('demo'))
|
||||
let failure: unknown
|
||||
const settled = activation.catch((error) => {
|
||||
failure = error
|
||||
})
|
||||
|
||||
await flush()
|
||||
expect(factory).toHaveBeenCalledTimes(1)
|
||||
expect(subject.restartCount('demo')).toBe(1)
|
||||
expect(subject.runState('demo')).toBe('restarting')
|
||||
await vi.advanceTimersByTimeAsync(499)
|
||||
expect(factory).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(factory).toHaveBeenCalledTimes(2)
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
expect(factory).toHaveBeenCalledTimes(3)
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
await settled
|
||||
|
||||
expect(factory).toHaveBeenCalledTimes(4)
|
||||
expect(subject.runState('demo')).toBe('errored')
|
||||
expect(failure).toBeInstanceOf(Error)
|
||||
await subject.disposeAll()
|
||||
})
|
||||
|
||||
it('joins triggers during backoff without resetting restart history', async () => {
|
||||
vi.useFakeTimers()
|
||||
const ready = worker()
|
||||
const factory = vi.fn<PluginWorkerFactory>(async () => {
|
||||
if (factory.mock.calls.length === 1) {
|
||||
throw new Error('first start failed')
|
||||
}
|
||||
return ready
|
||||
})
|
||||
const subject = manager(factory)
|
||||
const first = subject.ensureActive(spec('demo'))
|
||||
await flush()
|
||||
const joined = subject.ensureActive(spec('demo'))
|
||||
|
||||
expect(subject.restartCount('demo')).toBe(1)
|
||||
expect(factory).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
|
||||
await expect(Promise.all([first, joined])).resolves.toEqual([ready, ready])
|
||||
expect(factory).toHaveBeenCalledTimes(2)
|
||||
expect(subject.restartCount('demo')).toBe(1)
|
||||
await subject.disposeAll()
|
||||
})
|
||||
|
||||
it('applies the same backoff history to unexpected post-ready exits', async () => {
|
||||
vi.useFakeTimers()
|
||||
const workers = [worker(), worker(), worker(), worker()]
|
||||
const factory = vi.fn<PluginWorkerFactory>(async () => workers[factory.mock.calls.length - 1]!)
|
||||
const subject = manager(factory)
|
||||
|
||||
await subject.ensureActive(spec('demo'))
|
||||
workers[0]!.exit(11)
|
||||
expect(subject.runState('demo')).toBe('restarting')
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(factory).toHaveBeenCalledTimes(2)
|
||||
workers[1]!.exit(12)
|
||||
expect(subject.runState('demo')).toBe('restarting')
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
expect(factory).toHaveBeenCalledTimes(3)
|
||||
workers[2]!.exit(13)
|
||||
expect(subject.runState('demo')).toBe('restarting')
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
expect(factory).toHaveBeenCalledTimes(4)
|
||||
workers[3]!.exit(14)
|
||||
|
||||
expect(subject.runState('demo')).toBe('errored')
|
||||
expect(subject.restartCount('demo')).toBe(3)
|
||||
await subject.disposeAll()
|
||||
})
|
||||
|
||||
it('cancels a pending restart and never resurrects after deactivate', async () => {
|
||||
vi.useFakeTimers()
|
||||
const first = worker()
|
||||
const factory = vi.fn<PluginWorkerFactory>(async () => first)
|
||||
const subject = manager(factory)
|
||||
await subject.ensureActive(spec('demo'))
|
||||
first.exit()
|
||||
expect(subject.restartCount('demo')).toBe(1)
|
||||
|
||||
await subject.deactivate('demo')
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
expect(factory).toHaveBeenCalledTimes(1)
|
||||
expect(subject.runState('demo')).toBe('inactive')
|
||||
await subject.disposeAll()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PluginWorkerManager idle reap', () => {
|
||||
it('does not reap a worker while an event handler is still in flight', async () => {
|
||||
const busy = worker(100)
|
||||
busy.inFlightCount = () => 1
|
||||
const subject = manager(vi.fn<PluginWorkerFactory>().mockResolvedValue(busy), {
|
||||
idleReapMs: 100
|
||||
})
|
||||
await subject.ensureActive(spec('demo'))
|
||||
|
||||
subject.reapIdle(10_000)
|
||||
|
||||
expect(busy.dispose).not.toHaveBeenCalled()
|
||||
expect(subject.runState('demo')).toBe('running')
|
||||
await subject.disposeAll()
|
||||
})
|
||||
|
||||
it('disposes an idle worker and activates a fresh generation on demand', async () => {
|
||||
const first = worker(100)
|
||||
const second = worker(1_000)
|
||||
const factory = vi
|
||||
.fn<PluginWorkerFactory>()
|
||||
.mockResolvedValueOnce(first)
|
||||
.mockResolvedValueOnce(second)
|
||||
const subject = manager(factory, { idleReapMs: 100 })
|
||||
await subject.ensureActive(spec('demo'))
|
||||
|
||||
subject.reapIdle(201)
|
||||
await flush()
|
||||
expect(first.dispose).toHaveBeenCalledOnce()
|
||||
expect(subject.runState('demo')).toBe('inactive')
|
||||
await expect(subject.ensureActive(spec('demo'))).resolves.toBe(second)
|
||||
await subject.disposeAll()
|
||||
})
|
||||
|
||||
it('waits for an in-progress idle shutdown during manager disposal', async () => {
|
||||
let finishShutdown!: () => void
|
||||
const idle = worker(100)
|
||||
idle.dispose.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishShutdown = resolve
|
||||
})
|
||||
)
|
||||
const subject = manager(vi.fn<PluginWorkerFactory>().mockResolvedValue(idle), {
|
||||
idleReapMs: 100
|
||||
})
|
||||
await subject.ensureActive(spec('demo'))
|
||||
subject.reapIdle(201)
|
||||
let disposed = false
|
||||
const disposal = subject.disposeAll().then(() => {
|
||||
disposed = true
|
||||
})
|
||||
await flush()
|
||||
expect(disposed).toBe(false)
|
||||
|
||||
finishShutdown()
|
||||
await disposal
|
||||
expect(disposed).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,309 @@
|
||||
import {
|
||||
PLUGIN_WORKER_IDLE_REAP_MS,
|
||||
PLUGIN_WORKER_MAX_ACTIVE_DEFAULT
|
||||
} from '../../shared/plugins/plugin-host-protocol'
|
||||
import type { PluginEventName } from '../../shared/plugins/plugin-manifest'
|
||||
import {
|
||||
PluginSupervisor,
|
||||
type PluginRestartDecision,
|
||||
type PluginRunState
|
||||
} from './plugin-supervisor'
|
||||
import type { PluginWorkerHandle, PluginWorkerHostCallExecutor } from './plugin-host-process'
|
||||
import { PluginWorkerSlotPool } from './plugin-worker-slot-pool'
|
||||
import {
|
||||
startPluginWorkerAttempt,
|
||||
type PluginWorkerFactory,
|
||||
type PluginWorkerSpawnSpec,
|
||||
type StartedPluginWorker
|
||||
} from './plugin-worker-startup'
|
||||
import { runPluginWorkerRestartLoop } from './plugin-worker-restart-loop'
|
||||
import { pluginWorkerSpawnSpecsEqual } from './plugin-worker-spawn-spec'
|
||||
|
||||
export type { PluginWorkerFactory, PluginWorkerSpawnSpec } from './plugin-worker-startup'
|
||||
|
||||
export type PluginWorkerManagerOptions = {
|
||||
entryPath: string
|
||||
maxActive?: number
|
||||
idleReapMs?: number
|
||||
workerFactory?: PluginWorkerFactory
|
||||
executeHostCall: (
|
||||
pluginKey: string,
|
||||
method: string,
|
||||
params: unknown
|
||||
) => ReturnType<PluginWorkerHostCallExecutor>
|
||||
log: (pluginKey: string, level: 'info' | 'warn' | 'error', line: string) => void
|
||||
onWorkerStateChange: (pluginKey: string) => void
|
||||
onWorkerGone: (pluginKey: string) => void
|
||||
}
|
||||
|
||||
type ActivationRecord = {
|
||||
spec: PluginWorkerSpawnSpec
|
||||
generation: number
|
||||
controller: AbortController
|
||||
task: Promise<PluginWorkerHandle>
|
||||
}
|
||||
|
||||
/** Owns lazy activation, bounded capacity, restart policy, cancellation, and idle reap. */
|
||||
export class PluginWorkerManager {
|
||||
private readonly supervisor = new PluginSupervisor()
|
||||
private readonly workers = new Map<string, StartedPluginWorker>()
|
||||
private readonly activations = new Map<string, ActivationRecord>()
|
||||
private readonly knownSpecs = new Map<string, PluginWorkerSpawnSpec>()
|
||||
private readonly generations = new Map<string, number>()
|
||||
private readonly stoppingWorkers = new Set<Promise<void>>()
|
||||
private readonly slots: PluginWorkerSlotPool
|
||||
private readonly idleReapMs: number
|
||||
private disposed = false
|
||||
|
||||
constructor(private readonly options: PluginWorkerManagerOptions) {
|
||||
this.slots = new PluginWorkerSlotPool(options.maxActive ?? PLUGIN_WORKER_MAX_ACTIVE_DEFAULT)
|
||||
this.idleReapMs = options.idleReapMs ?? PLUGIN_WORKER_IDLE_REAP_MS
|
||||
}
|
||||
|
||||
runState(pluginKey: string): PluginRunState {
|
||||
return this.supervisor.getState(pluginKey)
|
||||
}
|
||||
|
||||
restartCount(pluginKey: string): number {
|
||||
return this.supervisor.restartCount(pluginKey)
|
||||
}
|
||||
|
||||
trackedSpecs(): ReadonlyMap<string, PluginWorkerSpawnSpec> {
|
||||
return new Map(this.knownSpecs)
|
||||
}
|
||||
|
||||
async ensureActive(spec: PluginWorkerSpawnSpec): Promise<PluginWorkerHandle> {
|
||||
if (this.disposed) {
|
||||
throw new Error('plugin workers are shut down')
|
||||
}
|
||||
if (this.supervisor.getState(spec.pluginKey) === 'errored') {
|
||||
throw new Error(`plugin ${spec.pluginKey} is errored after repeated failures`)
|
||||
}
|
||||
for (;;) {
|
||||
const existing = this.workers.get(spec.pluginKey)
|
||||
const pending = this.activations.get(spec.pluginKey)
|
||||
const activeSpec = existing?.spec ?? pending?.spec
|
||||
if (!activeSpec) {
|
||||
break
|
||||
}
|
||||
if (pluginWorkerSpawnSpecsEqual(activeSpec, spec)) {
|
||||
return existing?.handle ?? pending!.task
|
||||
}
|
||||
// Why: refresh/trigger races can present a new dev manifest while the
|
||||
// old revision is still starting. Cancel and re-check atomically enough
|
||||
// that callers never join a stale activation by key alone.
|
||||
await this.deactivate(spec.pluginKey)
|
||||
if (this.disposed) {
|
||||
throw new Error('plugin workers are shut down')
|
||||
}
|
||||
}
|
||||
const generation = this.nextGeneration(spec.pluginKey)
|
||||
this.knownSpecs.set(spec.pluginKey, spec)
|
||||
this.supervisor.markRunning(spec.pluginKey, { resetRestarts: true })
|
||||
return this.beginActivation(spec, generation)
|
||||
}
|
||||
|
||||
private beginActivation(
|
||||
spec: PluginWorkerSpawnSpec,
|
||||
generation: number,
|
||||
firstRestart?: Extract<PluginRestartDecision, { restart: true }>
|
||||
): Promise<PluginWorkerHandle> {
|
||||
const controller = new AbortController()
|
||||
const task = this.activate(spec, generation, controller.signal, firstRestart)
|
||||
const record: ActivationRecord = { spec, generation, controller, task }
|
||||
this.activations.set(spec.pluginKey, record)
|
||||
void task.then(
|
||||
() => this.finishActivation(spec.pluginKey, record),
|
||||
() => this.finishActivation(spec.pluginKey, record)
|
||||
)
|
||||
return task
|
||||
}
|
||||
|
||||
private finishActivation(pluginKey: string, record: ActivationRecord): void {
|
||||
if (this.activations.get(pluginKey) === record) {
|
||||
this.activations.delete(pluginKey)
|
||||
}
|
||||
}
|
||||
|
||||
private async activate(
|
||||
spec: PluginWorkerSpawnSpec,
|
||||
generation: number,
|
||||
signal: AbortSignal,
|
||||
firstRestart?: Extract<PluginRestartDecision, { restart: true }>
|
||||
): Promise<PluginWorkerHandle> {
|
||||
return runPluginWorkerRestartLoop({
|
||||
signal,
|
||||
firstRestart,
|
||||
assertActive: () => this.throwIfCancelled(spec.pluginKey, generation, signal),
|
||||
start: async () => {
|
||||
const worker = await startPluginWorkerAttempt({
|
||||
spec,
|
||||
generation,
|
||||
signal,
|
||||
slots: this.slots,
|
||||
entryPath: this.options.entryPath,
|
||||
factory: this.options.workerFactory,
|
||||
executeHostCall: (method, params) =>
|
||||
this.options.executeHostCall(spec.pluginKey, method, params),
|
||||
log: (level, line) => this.options.log(spec.pluginKey, level, line),
|
||||
assertActive: () => this.throwIfCancelled(spec.pluginKey, generation, signal),
|
||||
onExit: (record, code) => this.handleUnexpectedExit(spec.pluginKey, record, code)
|
||||
})
|
||||
this.workers.set(spec.pluginKey, worker)
|
||||
const earlyExit = worker.completeStart()
|
||||
if (earlyExit.exited) {
|
||||
this.detachWorker(spec.pluginKey, worker)
|
||||
throw new Error(`worker exited immediately after ready (code ${earlyExit.code})`)
|
||||
}
|
||||
this.supervisor.markRunning(spec.pluginKey)
|
||||
this.options.onWorkerStateChange(spec.pluginKey)
|
||||
return worker.handle
|
||||
},
|
||||
recordFailure: (error) => this.recordFailure(spec.pluginKey, 'worker failed to start', error),
|
||||
erroredError: (error) =>
|
||||
new Error(
|
||||
`plugin ${spec.pluginKey} is errored after repeated failures: ${this.errorText(error)}`
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private handleUnexpectedExit(
|
||||
pluginKey: string,
|
||||
record: StartedPluginWorker,
|
||||
code: number | null
|
||||
): void {
|
||||
if (!this.detachWorker(pluginKey, record)) {
|
||||
return
|
||||
}
|
||||
if (this.isCancelled(pluginKey, record.generation)) {
|
||||
return
|
||||
}
|
||||
const decision = this.recordFailure(pluginKey, `worker exited unexpectedly (code ${code})`)
|
||||
if (decision.restart) {
|
||||
this.beginActivation(record.spec, record.generation, decision)
|
||||
}
|
||||
}
|
||||
|
||||
private recordFailure(
|
||||
pluginKey: string,
|
||||
context: string,
|
||||
error?: unknown
|
||||
): PluginRestartDecision {
|
||||
this.options.onWorkerGone(pluginKey)
|
||||
const decision = this.supervisor.markExited(pluginKey, { crashed: true })
|
||||
this.options.onWorkerStateChange(pluginKey)
|
||||
if (decision.restart) {
|
||||
this.options.log(
|
||||
pluginKey,
|
||||
'warn',
|
||||
`${context}${error ? `: ${this.errorText(error)}` : ''}; restart ${decision.attempt} in ${decision.delayMs}ms`
|
||||
)
|
||||
} else if (decision.state === 'errored') {
|
||||
this.options.log(pluginKey, 'error', `${context}; marked errored after repeated failures`)
|
||||
}
|
||||
return decision
|
||||
}
|
||||
|
||||
private detachWorker(pluginKey: string, record: StartedPluginWorker): boolean {
|
||||
if (this.workers.get(pluginKey) !== record) {
|
||||
return false
|
||||
}
|
||||
this.workers.delete(pluginKey)
|
||||
record.lease.release()
|
||||
return true
|
||||
}
|
||||
|
||||
deliverEventIfRunning(pluginKey: string, event: PluginEventName, payload: unknown): void {
|
||||
this.workers.get(pluginKey)?.handle.deliverEvent(event, payload)
|
||||
}
|
||||
|
||||
async deactivate(pluginKey: string): Promise<void> {
|
||||
this.nextGeneration(pluginKey)
|
||||
const activation = this.activations.get(pluginKey)
|
||||
activation?.controller.abort()
|
||||
const record = this.workers.get(pluginKey)
|
||||
if (record) {
|
||||
this.workers.delete(pluginKey)
|
||||
}
|
||||
this.options.onWorkerGone(pluginKey)
|
||||
this.supervisor.reset(pluginKey)
|
||||
this.knownSpecs.delete(pluginKey)
|
||||
await Promise.all([
|
||||
activation?.task.catch(() => undefined),
|
||||
record?.handle.dispose().catch(() => undefined)
|
||||
])
|
||||
record?.lease.release()
|
||||
}
|
||||
|
||||
reapIdle(now = Date.now()): void {
|
||||
for (const [pluginKey, record] of this.workers) {
|
||||
if (
|
||||
record.handle.inFlightCount() !== 0 ||
|
||||
now - record.handle.lastActivityAt() <= this.idleReapMs
|
||||
) {
|
||||
continue
|
||||
}
|
||||
this.nextGeneration(pluginKey)
|
||||
this.knownSpecs.delete(pluginKey)
|
||||
this.workers.delete(pluginKey)
|
||||
this.options.onWorkerGone(pluginKey)
|
||||
this.supervisor.markExited(pluginKey, { crashed: false })
|
||||
this.options.log(pluginKey, 'info', 'worker reaped after idle period')
|
||||
this.options.onWorkerStateChange(pluginKey)
|
||||
const stopping = record.handle
|
||||
.dispose()
|
||||
.catch(() => undefined)
|
||||
.finally(() => record.lease.release())
|
||||
this.stoppingWorkers.add(stopping)
|
||||
void stopping.then(() => this.stoppingWorkers.delete(stopping))
|
||||
}
|
||||
}
|
||||
|
||||
async disposeAll(): Promise<void> {
|
||||
this.disposed = true
|
||||
const pluginKeys = new Set([...this.activations.keys(), ...this.workers.keys()])
|
||||
for (const key of pluginKeys) {
|
||||
this.nextGeneration(key)
|
||||
this.options.onWorkerGone(key)
|
||||
}
|
||||
const activations = [...this.activations.values()]
|
||||
for (const activation of activations) {
|
||||
activation.controller.abort()
|
||||
}
|
||||
this.slots.dispose()
|
||||
const workers = [...this.workers.values()]
|
||||
this.workers.clear()
|
||||
this.knownSpecs.clear()
|
||||
const stoppingWorkers = [...this.stoppingWorkers]
|
||||
await Promise.all([
|
||||
...stoppingWorkers,
|
||||
...activations.map((activation) => activation.task.catch(() => undefined)),
|
||||
...workers.map(async (record) => {
|
||||
await record.handle.dispose().catch(() => undefined)
|
||||
record.lease.release()
|
||||
})
|
||||
])
|
||||
}
|
||||
|
||||
private nextGeneration(pluginKey: string): number {
|
||||
const generation = (this.generations.get(pluginKey) ?? 0) + 1
|
||||
this.generations.set(pluginKey, generation)
|
||||
return generation
|
||||
}
|
||||
|
||||
private isCancelled(pluginKey: string, generation: number, signal?: AbortSignal): boolean {
|
||||
return (
|
||||
this.disposed || signal?.aborted === true || this.generations.get(pluginKey) !== generation
|
||||
)
|
||||
}
|
||||
|
||||
private throwIfCancelled(pluginKey: string, generation: number, signal: AbortSignal): void {
|
||||
if (this.isCancelled(pluginKey, generation, signal)) {
|
||||
throw new Error('plugin worker activation was cancelled')
|
||||
}
|
||||
}
|
||||
|
||||
private errorText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
PLUGIN_WORKER_OUTPUT_LINE_LIMIT,
|
||||
pipePluginWorkerOutput
|
||||
} from './plugin-worker-output-buffer'
|
||||
|
||||
describe('pipePluginWorkerOutput', () => {
|
||||
it('bounds an unterminated line and resumes after its newline', () => {
|
||||
const stream = new PassThrough()
|
||||
const log = vi.fn()
|
||||
pipePluginWorkerOutput(stream, 'info', log)
|
||||
|
||||
stream.write('x'.repeat(PLUGIN_WORKER_OUTPUT_LINE_LIMIT + 1_000))
|
||||
stream.write('discarded')
|
||||
expect(log).toHaveBeenCalledOnce()
|
||||
expect(log.mock.calls[0]?.[1]).toHaveLength(PLUGIN_WORKER_OUTPUT_LINE_LIMIT)
|
||||
|
||||
stream.write('\nok\n')
|
||||
expect(log).toHaveBeenLastCalledWith('info', 'ok')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Readable } from 'node:stream'
|
||||
|
||||
type PluginWorkerOutputSink = (level: 'info' | 'warn' | 'error', line: string) => void
|
||||
|
||||
export const PLUGIN_WORKER_OUTPUT_LINE_LIMIT = 8192
|
||||
const TRUNCATION_SUFFIX = '… [truncated]'
|
||||
|
||||
/** Keeps a worker's unterminated output bounded even if it never writes a newline. */
|
||||
export function pipePluginWorkerOutput(
|
||||
stream: Readable | null,
|
||||
level: 'info' | 'error',
|
||||
log: PluginWorkerOutputSink
|
||||
): void {
|
||||
if (!stream) {
|
||||
return
|
||||
}
|
||||
let buffered = ''
|
||||
let discarding = false
|
||||
|
||||
function emit(line: string, truncated = false): void {
|
||||
if (line.trim().length > 0) {
|
||||
log(
|
||||
level,
|
||||
truncated
|
||||
? `${line.slice(0, PLUGIN_WORKER_OUTPUT_LINE_LIMIT - TRUNCATION_SUFFIX.length)}${TRUNCATION_SUFFIX}`
|
||||
: line
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
stream.setEncoding('utf8')
|
||||
stream.on('data', (chunk: string) => {
|
||||
let remaining = chunk
|
||||
while (remaining.length > 0) {
|
||||
if (discarding) {
|
||||
const newline = remaining.indexOf('\n')
|
||||
if (newline < 0) {
|
||||
return
|
||||
}
|
||||
discarding = false
|
||||
remaining = remaining.slice(newline + 1)
|
||||
continue
|
||||
}
|
||||
const newline = remaining.indexOf('\n')
|
||||
const segment = newline < 0 ? remaining : remaining.slice(0, newline)
|
||||
const available = PLUGIN_WORKER_OUTPUT_LINE_LIMIT - buffered.length
|
||||
if (segment.length > available) {
|
||||
emit(buffered + segment.slice(0, available), true)
|
||||
buffered = ''
|
||||
discarding = newline < 0
|
||||
} else {
|
||||
buffered += segment
|
||||
if (newline >= 0) {
|
||||
emit(buffered)
|
||||
buffered = ''
|
||||
}
|
||||
}
|
||||
if (newline < 0) {
|
||||
return
|
||||
}
|
||||
remaining = remaining.slice(newline + 1)
|
||||
}
|
||||
})
|
||||
stream.on('end', () => {
|
||||
if (!discarding) {
|
||||
emit(buffered)
|
||||
}
|
||||
buffered = ''
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { capabilityKinds } from '../../shared/plugins/plugin-capabilities'
|
||||
import type { DiscoveredPlugin, ValidDiscoveredPlugin } from './plugin-discovery'
|
||||
import { isInvalidDiscoveredPlugin } from './plugin-discovery'
|
||||
import type { PluginWorkerSpawnSpec } from './plugin-worker-manager'
|
||||
import { buildPluginWorkerSpawnSpec } from './plugin-worker-spawn-spec'
|
||||
|
||||
export function collectApprovedWorkerSpecs(
|
||||
plugins: readonly DiscoveredPlugin[],
|
||||
isApproved: (plugin: ValidDiscoveredPlugin) => boolean
|
||||
): ReadonlyMap<string, PluginWorkerSpawnSpec> {
|
||||
const specs = new Map<string, PluginWorkerSpawnSpec>()
|
||||
for (const plugin of plugins) {
|
||||
if (isInvalidDiscoveredPlugin(plugin) || !plugin.manifest.main || !isApproved(plugin)) {
|
||||
continue
|
||||
}
|
||||
specs.set(
|
||||
plugin.pluginKey,
|
||||
buildPluginWorkerSpawnSpec(plugin, capabilityKinds(plugin.manifest.capabilities))
|
||||
)
|
||||
}
|
||||
return specs
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { PluginRestartDecision } from './plugin-supervisor'
|
||||
|
||||
function cancellationError(): Error {
|
||||
return new Error('plugin worker activation was cancelled')
|
||||
}
|
||||
|
||||
function waitForBackoff(delayMs: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve()
|
||||
}, delayMs)
|
||||
timer.unref?.()
|
||||
function onAbort(): void {
|
||||
clearTimeout(timer)
|
||||
reject(cancellationError())
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
if (signal.aborted) {
|
||||
onAbort()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function runPluginWorkerRestartLoop<T>(options: {
|
||||
signal: AbortSignal
|
||||
firstRestart?: Extract<PluginRestartDecision, { restart: true }>
|
||||
assertActive: () => void
|
||||
start: () => Promise<T>
|
||||
recordFailure: (error: unknown) => PluginRestartDecision
|
||||
erroredError: (error: unknown) => Error
|
||||
}): Promise<T> {
|
||||
let restart = options.firstRestart
|
||||
for (;;) {
|
||||
if (restart) {
|
||||
await waitForBackoff(restart.delayMs, options.signal)
|
||||
}
|
||||
options.assertActive()
|
||||
try {
|
||||
return await options.start()
|
||||
} catch (error) {
|
||||
options.assertActive()
|
||||
const decision = options.recordFailure(error)
|
||||
if (!decision.restart) {
|
||||
throw options.erroredError(error)
|
||||
}
|
||||
restart = decision
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
export type PluginWorkerSlotLease = {
|
||||
release(): void
|
||||
}
|
||||
|
||||
type SlotWaiter = {
|
||||
signal: AbortSignal
|
||||
resolve: (lease: PluginWorkerSlotLease) => void
|
||||
reject: (error: Error) => void
|
||||
onAbort: () => void
|
||||
}
|
||||
|
||||
function cancellationError(): Error {
|
||||
return new Error('plugin worker activation was cancelled')
|
||||
}
|
||||
|
||||
/** Atomically leases the bounded worker slots and hands releases to queued
|
||||
* activations in FIFO order. */
|
||||
export class PluginWorkerSlotPool {
|
||||
private readonly waiters: SlotWaiter[] = []
|
||||
private leased = 0
|
||||
private disposed = false
|
||||
|
||||
constructor(private readonly capacity: number) {
|
||||
if (!Number.isInteger(capacity) || capacity <= 0) {
|
||||
throw new Error('plugin worker capacity must be a positive integer')
|
||||
}
|
||||
}
|
||||
|
||||
acquire(signal: AbortSignal): Promise<PluginWorkerSlotLease> {
|
||||
if (this.disposed) {
|
||||
return Promise.reject(new Error('plugin worker slots are shut down'))
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return Promise.reject(cancellationError())
|
||||
}
|
||||
if (this.leased < this.capacity && this.waiters.length === 0) {
|
||||
this.leased += 1
|
||||
return Promise.resolve(this.createLease())
|
||||
}
|
||||
return new Promise<PluginWorkerSlotLease>((resolve, reject) => {
|
||||
let cancelled = false
|
||||
const waiter: SlotWaiter = {
|
||||
signal,
|
||||
resolve,
|
||||
reject,
|
||||
onAbort: () => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
cancelled = true
|
||||
const index = this.waiters.indexOf(waiter)
|
||||
if (index >= 0) {
|
||||
this.waiters.splice(index, 1)
|
||||
}
|
||||
signal.removeEventListener('abort', waiter.onAbort)
|
||||
reject(cancellationError())
|
||||
this.drain()
|
||||
}
|
||||
}
|
||||
this.waiters.push(waiter)
|
||||
signal.addEventListener('abort', waiter.onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
this.disposed = true
|
||||
for (const waiter of this.waiters.splice(0)) {
|
||||
waiter.signal.removeEventListener('abort', waiter.onAbort)
|
||||
waiter.reject(new Error('plugin worker slots are shut down'))
|
||||
}
|
||||
}
|
||||
|
||||
private createLease(): PluginWorkerSlotLease {
|
||||
let released = false
|
||||
return {
|
||||
release: () => {
|
||||
if (released) {
|
||||
return
|
||||
}
|
||||
released = true
|
||||
this.leased -= 1
|
||||
this.drain()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private drain(): void {
|
||||
while (!this.disposed && this.leased < this.capacity && this.waiters.length > 0) {
|
||||
const waiter = this.waiters.shift()!
|
||||
waiter.signal.removeEventListener('abort', waiter.onAbort)
|
||||
if (waiter.signal.aborted) {
|
||||
waiter.reject(cancellationError())
|
||||
continue
|
||||
}
|
||||
this.leased += 1
|
||||
waiter.resolve(this.createLease())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { PluginCapabilityKind } from '../../shared/plugins/plugin-capabilities'
|
||||
import type { ValidDiscoveredPlugin } from './plugin-discovery'
|
||||
import type { PluginWorkerSpawnSpec } from './plugin-worker-startup'
|
||||
|
||||
export function buildPluginWorkerSpawnSpec(
|
||||
plugin: ValidDiscoveredPlugin,
|
||||
grantedCapabilities: readonly PluginCapabilityKind[]
|
||||
): PluginWorkerSpawnSpec {
|
||||
if (!plugin.manifest.main) {
|
||||
throw new Error(`plugin ${plugin.pluginKey} has no worker entry`)
|
||||
}
|
||||
return {
|
||||
pluginKey: plugin.pluginKey,
|
||||
rootDir: plugin.rootDir,
|
||||
mainEntry: plugin.manifest.main,
|
||||
// Dev plugins keep one root across manifest edits; include the parsed
|
||||
// manifest so hot reload cannot reuse a worker with stale contributions.
|
||||
manifestRevision: JSON.stringify(plugin.manifest),
|
||||
grantedCapabilities
|
||||
}
|
||||
}
|
||||
|
||||
export function pluginWorkerSpawnSpecsEqual(
|
||||
left: PluginWorkerSpawnSpec,
|
||||
right: PluginWorkerSpawnSpec
|
||||
): boolean {
|
||||
if (
|
||||
left.pluginKey !== right.pluginKey ||
|
||||
left.rootDir !== right.rootDir ||
|
||||
left.mainEntry !== right.mainEntry ||
|
||||
left.manifestRevision !== right.manifestRevision
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const leftCapabilities = [...left.grantedCapabilities].sort()
|
||||
const rightCapabilities = [...right.grantedCapabilities].sort()
|
||||
return (
|
||||
leftCapabilities.length === rightCapabilities.length &&
|
||||
leftCapabilities.every((capability, index) => capability === rightCapabilities[index])
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { PluginCapabilityKind } from '../../shared/plugins/plugin-capabilities'
|
||||
import {
|
||||
startPluginWorker,
|
||||
type PluginWorkerHandle,
|
||||
type PluginWorkerHostCallExecutor,
|
||||
type PluginWorkerLogSink
|
||||
} from './plugin-host-process'
|
||||
import type { PluginWorkerSlotLease, PluginWorkerSlotPool } from './plugin-worker-slot-pool'
|
||||
|
||||
export type PluginWorkerSpawnSpec = {
|
||||
pluginKey: string
|
||||
rootDir: string
|
||||
mainEntry: string
|
||||
manifestRevision?: string
|
||||
grantedCapabilities: readonly PluginCapabilityKind[]
|
||||
}
|
||||
|
||||
export type PluginWorkerFactory = (options: {
|
||||
pluginId: string
|
||||
rootDir: string
|
||||
mainEntry: string
|
||||
entryPath: string
|
||||
grantedCapabilities: readonly PluginCapabilityKind[]
|
||||
executeHostCall: PluginWorkerHostCallExecutor
|
||||
log: PluginWorkerLogSink
|
||||
signal: AbortSignal
|
||||
}) => Promise<PluginWorkerHandle>
|
||||
|
||||
export type StartedPluginWorker = {
|
||||
spec: PluginWorkerSpawnSpec
|
||||
generation: number
|
||||
handle: PluginWorkerHandle
|
||||
lease: PluginWorkerSlotLease
|
||||
completeStart(): { exited: boolean; code: number | null }
|
||||
}
|
||||
|
||||
export async function startPluginWorkerAttempt(options: {
|
||||
spec: PluginWorkerSpawnSpec
|
||||
generation: number
|
||||
signal: AbortSignal
|
||||
slots: PluginWorkerSlotPool
|
||||
entryPath: string
|
||||
factory?: PluginWorkerFactory
|
||||
executeHostCall: PluginWorkerHostCallExecutor
|
||||
log: PluginWorkerLogSink
|
||||
assertActive: () => void
|
||||
onExit: (worker: StartedPluginWorker, code: number | null) => void
|
||||
}): Promise<StartedPluginWorker> {
|
||||
const lease = await options.slots.acquire(options.signal)
|
||||
let handle: PluginWorkerHandle | null = null
|
||||
let retained = false
|
||||
try {
|
||||
options.assertActive()
|
||||
const factory = options.factory ?? startPluginWorker
|
||||
handle = await factory({
|
||||
pluginId: options.spec.pluginKey,
|
||||
rootDir: options.spec.rootDir,
|
||||
mainEntry: options.spec.mainEntry,
|
||||
entryPath: options.entryPath,
|
||||
grantedCapabilities: options.spec.grantedCapabilities,
|
||||
executeHostCall: options.executeHostCall,
|
||||
log: options.log,
|
||||
signal: options.signal
|
||||
})
|
||||
options.assertActive()
|
||||
let startCompleted = false
|
||||
let earlyExit = false
|
||||
let earlyExitCode: number | null = null
|
||||
const worker: StartedPluginWorker = {
|
||||
spec: options.spec,
|
||||
generation: options.generation,
|
||||
handle,
|
||||
lease,
|
||||
completeStart: () => {
|
||||
startCompleted = true
|
||||
return { exited: earlyExit, code: earlyExitCode }
|
||||
}
|
||||
}
|
||||
handle.onExit((code) => {
|
||||
if (!startCompleted) {
|
||||
earlyExit = true
|
||||
earlyExitCode = code
|
||||
return
|
||||
}
|
||||
options.onExit(worker, code)
|
||||
})
|
||||
retained = true
|
||||
return worker
|
||||
} catch (error) {
|
||||
if (handle) {
|
||||
await handle.dispose().catch(() => undefined)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
if (!retained) {
|
||||
lease.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { build } from 'esbuild'
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import type { PluginWorkerHandle } from './plugin-host-process'
|
||||
import { PluginWorkerManager, type PluginWorkerSpawnSpec } from './plugin-worker-manager'
|
||||
|
||||
type LogEntry = {
|
||||
at: number
|
||||
level: 'info' | 'warn' | 'error'
|
||||
line: string
|
||||
}
|
||||
|
||||
const pluginRoots: string[] = []
|
||||
const managers: PluginWorkerManager[] = []
|
||||
let bundleRoot = ''
|
||||
let hostEntryPath = ''
|
||||
|
||||
function createStateNotifications(): {
|
||||
notify: () => void
|
||||
waitFor: (predicate: () => boolean, description: string, timeoutMs?: number) => Promise<void>
|
||||
} {
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
notify: () => {
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
},
|
||||
waitFor: (predicate, description, timeoutMs = 10_000) => {
|
||||
if (predicate()) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
listeners.delete(check)
|
||||
reject(new Error(`timed out waiting for ${description}`))
|
||||
}, timeoutMs)
|
||||
const check = (): void => {
|
||||
if (!predicate()) {
|
||||
return
|
||||
}
|
||||
clearTimeout(timeout)
|
||||
listeners.delete(check)
|
||||
resolve()
|
||||
}
|
||||
listeners.add(check)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
bundleRoot = await mkdtemp(join(tmpdir(), 'orca-plugin-host-bundle-'))
|
||||
hostEntryPath = join(bundleRoot, 'plugin-host-entry.cjs')
|
||||
await build({
|
||||
entryPoints: [join(process.cwd(), 'src', 'main', 'plugins', 'plugin-host-entry.ts')],
|
||||
outfile: hostEntryPath,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'cjs',
|
||||
sourcemap: false,
|
||||
logLevel: 'silent'
|
||||
})
|
||||
}, 30_000)
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(managers.splice(0).map((manager) => manager.disposeAll()))
|
||||
await Promise.all(pluginRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (bundleRoot) {
|
||||
await rm(bundleRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
async function createPluginSpec(
|
||||
source = `export default function activate(orca) { orca.commands.register('run', async () => ({ ok: true })); }`
|
||||
): Promise<PluginWorkerSpawnSpec> {
|
||||
const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-supervision-'))
|
||||
pluginRoots.push(rootDir)
|
||||
await writeFile(join(rootDir, 'main.mjs'), source)
|
||||
return {
|
||||
pluginKey: 'orca-samples.supervision',
|
||||
rootDir,
|
||||
mainEntry: 'main.mjs',
|
||||
grantedCapabilities: []
|
||||
}
|
||||
}
|
||||
|
||||
describe('real plugin worker supervision', () => {
|
||||
it('terminates and supervises a live worker that disconnects IPC', async () => {
|
||||
const spec = await createPluginSpec(`
|
||||
export default function activate(orca) {
|
||||
orca.commands.register('disconnect', async () => {
|
||||
process.disconnect?.()
|
||||
setInterval(() => {}, 1_000)
|
||||
await new Promise(() => {})
|
||||
})
|
||||
}
|
||||
`)
|
||||
const notifications = createStateNotifications()
|
||||
const manager = new PluginWorkerManager({
|
||||
entryPath: hostEntryPath,
|
||||
executeHostCall: async () => ({ ok: true, value: null }),
|
||||
log: vi.fn(),
|
||||
onWorkerStateChange: notifications.notify,
|
||||
onWorkerGone: vi.fn()
|
||||
})
|
||||
managers.push(manager)
|
||||
|
||||
const worker = await manager.ensureActive(spec)
|
||||
const command = worker.invokeCommand('disconnect')
|
||||
|
||||
await expect(command).rejects.toThrow('disconnected')
|
||||
await notifications.waitFor(
|
||||
() => manager.runState(spec.pluginKey) === 'restarting',
|
||||
'disconnected worker to enter supervised backoff'
|
||||
)
|
||||
expect(manager.restartCount(spec.pluginKey)).toBe(1)
|
||||
})
|
||||
|
||||
it('restarts forced exits with 500/2000/5000ms backoff, then stays errored', async () => {
|
||||
const spec = await createPluginSpec()
|
||||
const notifications = createStateNotifications()
|
||||
const logs: LogEntry[] = []
|
||||
const manager = new PluginWorkerManager({
|
||||
entryPath: hostEntryPath,
|
||||
executeHostCall: async () => ({ ok: true, value: null }),
|
||||
log: (_pluginKey, level, line) => logs.push({ at: performance.now(), level, line }),
|
||||
onWorkerStateChange: notifications.notify,
|
||||
onWorkerGone: vi.fn()
|
||||
})
|
||||
managers.push(manager)
|
||||
|
||||
let current: PluginWorkerHandle = await manager.ensureActive(spec)
|
||||
expect(current.commands).toContain('run')
|
||||
expect(manager.runState(spec.pluginKey)).toBe('running')
|
||||
|
||||
for (const [index, delayMs] of [500, 2_000, 5_000].entries()) {
|
||||
const exited = current
|
||||
exited.kill()
|
||||
await notifications.waitFor(
|
||||
() =>
|
||||
manager.runState(spec.pluginKey) === 'restarting' &&
|
||||
manager.restartCount(spec.pluginKey) === index + 1,
|
||||
`restart ${index + 1} to enter backoff`
|
||||
)
|
||||
const restartLog = logs.find((entry) =>
|
||||
entry.line.includes(`restart ${index + 1} in ${delayMs}ms`)
|
||||
)
|
||||
expect(restartLog?.level).toBe('warn')
|
||||
|
||||
current = await manager.ensureActive(spec)
|
||||
|
||||
expect(current).not.toBe(exited)
|
||||
expect(manager.runState(spec.pluginKey)).toBe('running')
|
||||
expect(performance.now() - restartLog!.at).toBeGreaterThanOrEqual(delayMs - 25)
|
||||
}
|
||||
|
||||
current.kill()
|
||||
await notifications.waitFor(
|
||||
() => manager.runState(spec.pluginKey) === 'errored',
|
||||
'fourth forced exit to become terminally errored'
|
||||
)
|
||||
|
||||
expect(manager.restartCount(spec.pluginKey)).toBe(3)
|
||||
expect(
|
||||
logs.some((entry) => entry.level === 'error' && entry.line.includes('marked errored'))
|
||||
).toBe(true)
|
||||
await expect(manager.ensureActive(spec)).rejects.toThrow('errored after repeated failures')
|
||||
}, 45_000)
|
||||
})
|
||||
@@ -364,7 +364,7 @@ import {
|
||||
} from '../../shared/claude-agent-teams-tmux-compat'
|
||||
import { joinWorktreeRelativePath } from './runtime-relative-paths'
|
||||
import { collectMemorySnapshot } from '../memory/collector'
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
import { BrowserWindow, ipcMain, Notification } from 'electron'
|
||||
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
|
||||
import type { BrowserBackend } from '../browser/browser-backend'
|
||||
import { BrowserError } from '../browser/cdp-bridge'
|
||||
@@ -2031,13 +2031,17 @@ type ResolvedWorktreeInFlight = {
|
||||
|
||||
export type MobileNotificationDispatchEvent = {
|
||||
type: 'notification'
|
||||
source: 'agent-task-complete' | 'terminal-bell' | 'test'
|
||||
source: 'agent-task-complete' | 'terminal-bell' | 'test' | 'plugin'
|
||||
title: string
|
||||
body: string
|
||||
worktreeId?: string
|
||||
notificationId?: string
|
||||
}
|
||||
|
||||
export type RuntimeWorktreeLifecycleEvent =
|
||||
| { kind: 'created'; worktreeId: string; path: string; branch: string }
|
||||
| { kind: 'removed'; worktreeId: string; path: string }
|
||||
|
||||
export type MobileNotificationDismissEvent = {
|
||||
type: 'dismiss'
|
||||
notificationId: string
|
||||
@@ -2157,6 +2161,7 @@ export class OrcaRuntimeService {
|
||||
private ptyController: RuntimePtyController | null = null
|
||||
private notifier: RuntimeNotifier | null = null
|
||||
private clientEventListeners = new Set<(event: RuntimeClientEvent) => void>()
|
||||
private worktreeLifecycleListeners = new Set<(event: RuntimeWorktreeLifecycleEvent) => void>()
|
||||
private forkBackfillStarted = false
|
||||
private agentBrowserBridge: AgentBrowserBridge | null = null
|
||||
private offscreenBrowserBackend: BrowserBackend | null = null
|
||||
@@ -3010,6 +3015,28 @@ export class OrcaRuntimeService {
|
||||
this.emitClientEvent({ type: 'worktreesChanged', repoId })
|
||||
}
|
||||
|
||||
/** Detail-level worktree lifecycle tap (plugin event bus). The coarse
|
||||
* worktreesChanged client event carries only repoId, which is not enough
|
||||
* for subscribers that need the affected worktree's identity.
|
||||
* Removal payloads carry no branch: the removal target resolves before
|
||||
* the git worktree is torn down and only pins id + path. */
|
||||
onWorktreeLifecycle(listener: (event: RuntimeWorktreeLifecycleEvent) => void): () => void {
|
||||
this.worktreeLifecycleListeners.add(listener)
|
||||
return () => {
|
||||
this.worktreeLifecycleListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
private emitWorktreeLifecycle(event: RuntimeWorktreeLifecycleEvent): void {
|
||||
for (const listener of this.worktreeLifecycleListeners) {
|
||||
try {
|
||||
listener(event)
|
||||
} catch (err) {
|
||||
console.error('[runtime] worktree lifecycle listener threw', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private notifyReposChanged(): void {
|
||||
this.notifier?.reposChanged()
|
||||
this.emitClientEvent({ type: 'reposChanged' })
|
||||
@@ -7338,6 +7365,31 @@ export class OrcaRuntimeService {
|
||||
this.dispatchMobileNotification({ type: 'dismiss', notificationId })
|
||||
}
|
||||
|
||||
/** Plugin panel action notifications.show. Native on desktop, relayed to
|
||||
* paired mobile clients either way (mirrors notifications:dispatch). */
|
||||
async dispatchPluginNotification(input: {
|
||||
pluginId: string
|
||||
title: string
|
||||
body?: string
|
||||
}): Promise<{ delivered: boolean }> {
|
||||
// Why: prefix with the plugin id so a plugin cannot spoof an Orca system
|
||||
// notification or impersonate another plugin.
|
||||
const title = `${input.pluginId}: ${input.title}`
|
||||
const body = input.body ?? ''
|
||||
let delivered = false
|
||||
try {
|
||||
if (Notification.isSupported()) {
|
||||
new Notification({ title, body }).show()
|
||||
delivered = true
|
||||
}
|
||||
} catch {
|
||||
// Headless serve has no notification display; the mobile relay below
|
||||
// still runs.
|
||||
}
|
||||
this.dispatchMobileNotification({ type: 'notification', source: 'plugin', title, body })
|
||||
return { delivered }
|
||||
}
|
||||
|
||||
// ─── Account Services (mobile RPC bridge) ─────────────────────
|
||||
|
||||
setAccountServices(services: RuntimeAccountServices): void {
|
||||
@@ -10108,6 +10160,41 @@ export class OrcaRuntimeService {
|
||||
return this.getPaneKeyForTerminalHandle(handle)
|
||||
}
|
||||
|
||||
/** Read-only context of the worktree the user is focused on, for plugin
|
||||
* panels (workspace.readContext). Prefers the persisted session focus and
|
||||
* falls back to the last-focused pane's worktree; null when neither
|
||||
* resolves so panels degrade instead of erroring. */
|
||||
async resolveActiveWorktreeContext(): Promise<{
|
||||
worktreeId: string
|
||||
path: string
|
||||
branch: string
|
||||
displayName: string
|
||||
} | null> {
|
||||
let worktreeId = this.store?.getWorkspaceSession?.()?.activeWorktreeId ?? null
|
||||
if (!worktreeId && this.graphStatus === 'ready') {
|
||||
for (const tab of this.tabs.values()) {
|
||||
if (tab.activeLeafId && tab.worktreeId) {
|
||||
worktreeId = tab.worktreeId
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!worktreeId) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const resolved = await this.resolveWorktreeSelector(`id:${worktreeId}`)
|
||||
return {
|
||||
worktreeId: resolved.id,
|
||||
path: resolved.git.path,
|
||||
branch: resolved.git.branch,
|
||||
displayName: resolved.displayName
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
resolveTerminalPane(paneKey: string): RuntimeTerminalResolvePane {
|
||||
// Why: the renderer context menu only knows the stable pane key; main owns
|
||||
// the runtime terminal handle that agents and CLI commands can address.
|
||||
@@ -14719,6 +14806,12 @@ export class OrcaRuntimeService {
|
||||
const worktree = mergeRuntimeFolderWorkspace(repo, worktreeId, meta)
|
||||
this.invalidateResolvedWorktreeCache()
|
||||
this.notifyWorktreesChanged(repo.id)
|
||||
this.emitWorktreeLifecycle({
|
||||
kind: 'created',
|
||||
worktreeId: worktree.id,
|
||||
path: worktree.path,
|
||||
branch: worktree.branch
|
||||
})
|
||||
const shouldActivate = args.activate === true || args.runHooks === true
|
||||
let warning: string | undefined
|
||||
let didSpawnStartup = false
|
||||
@@ -14808,6 +14901,12 @@ export class OrcaRuntimeService {
|
||||
...(effectiveDraftPaste ? { startupDraftPaste: effectiveDraftPaste } : {})
|
||||
})
|
||||
const recordedLineage = this.recordCreatedWorktreeLineage(result.worktree, lineageResolution)
|
||||
this.emitWorktreeLifecycle({
|
||||
kind: 'created',
|
||||
worktreeId: result.worktree.id,
|
||||
path: result.worktree.path,
|
||||
branch: result.worktree.branch
|
||||
})
|
||||
return {
|
||||
...result,
|
||||
worktree: {
|
||||
@@ -15554,6 +15653,12 @@ export class OrcaRuntimeService {
|
||||
: {})
|
||||
}
|
||||
: undefined
|
||||
this.emitWorktreeLifecycle({
|
||||
kind: 'created',
|
||||
worktreeId: worktree.id,
|
||||
path: worktree.path,
|
||||
branch: worktree.branch
|
||||
})
|
||||
return {
|
||||
worktree: {
|
||||
...worktree,
|
||||
@@ -17422,7 +17527,13 @@ export class OrcaRuntimeService {
|
||||
})()
|
||||
this.removeManagedWorktreeInFlight.set(removalTarget.id, { optionsKey, promise: removal })
|
||||
try {
|
||||
return await removal
|
||||
const result = await removal
|
||||
this.emitWorktreeLifecycle({
|
||||
kind: 'removed',
|
||||
worktreeId: removalTarget.id,
|
||||
path: removalTarget.path
|
||||
})
|
||||
return result
|
||||
} finally {
|
||||
if (this.removeManagedWorktreeInFlight.get(removalTarget.id)?.promise === removal) {
|
||||
this.removeManagedWorktreeInFlight.delete(removalTarget.id)
|
||||
|
||||
@@ -30,6 +30,7 @@ import { SPEECH_METHODS } from './speech'
|
||||
import { CLIENT_UI_METHODS } from './client-ui'
|
||||
import { CLIENT_EVENT_METHODS } from './client-events'
|
||||
import { WORKSPACE_PORT_METHODS } from './workspace-ports'
|
||||
import { PLUGIN_METHODS } from './plugins'
|
||||
import { SKILL_METHODS } from './skills'
|
||||
import { CLIPBOARD_METHODS } from './clipboard'
|
||||
import { HOST_CAPABILITY_METHODS } from './host-capabilities'
|
||||
@@ -68,6 +69,7 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
|
||||
...SSH_METHODS,
|
||||
...SPEECH_METHODS,
|
||||
...WORKSPACE_PORT_METHODS,
|
||||
...PLUGIN_METHODS,
|
||||
...SKILL_METHODS,
|
||||
...CLIPBOARD_METHODS,
|
||||
...HOST_CAPABILITY_METHODS,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcContext, RpcMethod } from '../core'
|
||||
import type { PluginService } from '../../../plugins/plugin-service'
|
||||
import { PLUGIN_METHODS, setPluginServiceForRpc } from './plugins'
|
||||
|
||||
const SESSION_TOKEN = 's'.repeat(43)
|
||||
|
||||
function method(name: string): RpcMethod {
|
||||
const found = PLUGIN_METHODS.find((entry) => entry.name === name)
|
||||
if (!found) {
|
||||
throw new Error(`missing ${name}`)
|
||||
}
|
||||
if ('stream' in found) {
|
||||
throw new Error(`${name} is streaming`)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
function context(connectionId?: string): RpcContext {
|
||||
return { runtime: {} as RpcContext['runtime'], connectionId, clientId: 'paired-device' }
|
||||
}
|
||||
|
||||
afterEach(() => setPluginServiceForRpc(null))
|
||||
|
||||
describe('plugin panel serve RPC identity', () => {
|
||||
it('leaves the raw panel envelope for session resolution and admission', () => {
|
||||
const schema = method('plugins.panelAction').params!
|
||||
|
||||
expect(
|
||||
schema.safeParse({
|
||||
pluginId: 'orca-samples.other',
|
||||
unexpected: 'x'.repeat(100_000)
|
||||
}).success
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('binds panel loading and actions to the same runtime connection owner', async () => {
|
||||
const service = {
|
||||
whenReady: vi.fn().mockResolvedValue(undefined),
|
||||
panels: {
|
||||
open: vi.fn().mockResolvedValue({ html: '<p>panel</p>', sessionToken: SESSION_TOKEN }),
|
||||
execute: vi.fn().mockResolvedValue({ ok: true, value: { branch: 'main' } }),
|
||||
bindOwnerSignal: vi.fn(),
|
||||
revokeOwner: vi.fn()
|
||||
}
|
||||
} as unknown as PluginService
|
||||
setPluginServiceForRpc(service)
|
||||
const rpcContext = context('connection-one')
|
||||
|
||||
await expect(
|
||||
method('plugins.readPanelEntry').handler(
|
||||
{ pluginKey: 'orca-samples.demo', panelId: 'dashboard' },
|
||||
rpcContext
|
||||
)
|
||||
).resolves.toEqual({ html: '<p>panel</p>', sessionToken: SESSION_TOKEN })
|
||||
expect(service.panels.open).toHaveBeenCalledWith(
|
||||
'runtime:connection-one',
|
||||
'orca-samples.demo',
|
||||
'dashboard'
|
||||
)
|
||||
|
||||
await expect(
|
||||
method('plugins.panelAction').handler(
|
||||
{ sessionToken: SESSION_TOKEN, action: 'workspace.readContext', params: {} },
|
||||
rpcContext
|
||||
)
|
||||
).resolves.toEqual({ outcome: { ok: true, value: { branch: 'main' } } })
|
||||
expect(service.panels.execute).toHaveBeenCalledWith('runtime:connection-one', {
|
||||
sessionToken: SESSION_TOKEN,
|
||||
action: 'workspace.readContext',
|
||||
params: {}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,154 @@
|
||||
import { z } from 'zod'
|
||||
import { defineMethod, type RpcContext, type RpcMethod } from '../core'
|
||||
import type { PluginPanelEntry } from '../../../../shared/plugins/plugin-panel-bridge'
|
||||
import { listPluginsForClients } from '../../../ipc/plugins'
|
||||
import type { PluginListEntry } from '../../../plugins/plugin-list-projection'
|
||||
import type { PluginService } from '../../../plugins/plugin-service'
|
||||
import {
|
||||
pluginConsentRequestSchema,
|
||||
type PluginConsentRequest
|
||||
} from '../../../../shared/plugins/plugin-consent-request'
|
||||
import { isQualifiedPluginKey } from '../../../../shared/plugins/plugin-manifest'
|
||||
|
||||
/**
|
||||
* Serve/headless parity surface: the same consent, enablement, panel-action,
|
||||
* and command paths the desktop IPC handlers expose, over runtime RPC. Both
|
||||
* routes execute through PluginService's single chokepoint, so a permission
|
||||
* decision can never differ between a local window and a paired client.
|
||||
*/
|
||||
|
||||
// Why: RpcContext only carries the OrcaRuntimeService, and plugins are a
|
||||
// separate composition-root service — inject via module setter the way the
|
||||
// desktop entry wires it, instead of widening the shared RPC context type.
|
||||
let pluginServiceForRpc: PluginService | null = null
|
||||
// Consent/enablement need the settings Store too, so the entry injects bound
|
||||
// closures instead of the store itself.
|
||||
let pluginConsentForRpc: ((request: PluginConsentRequest) => Promise<void>) | null = null
|
||||
let pluginEnablementForRpc: ((pluginKey: string, enabled: boolean) => Promise<void>) | null = null
|
||||
|
||||
export function setPluginServiceForRpc(
|
||||
service: PluginService | null,
|
||||
writes?: {
|
||||
applyConsent: (request: PluginConsentRequest) => Promise<void>
|
||||
applyEnablement: (pluginKey: string, enabled: boolean) => Promise<void>
|
||||
}
|
||||
): void {
|
||||
pluginServiceForRpc = service
|
||||
pluginConsentForRpc = writes?.applyConsent ?? null
|
||||
pluginEnablementForRpc = writes?.applyEnablement ?? null
|
||||
}
|
||||
|
||||
function requirePluginService(): PluginService {
|
||||
if (!pluginServiceForRpc) {
|
||||
throw new Error('Plugin service is not available on this runtime')
|
||||
}
|
||||
return pluginServiceForRpc
|
||||
}
|
||||
|
||||
const PluginSetEnabledParams = z.object({
|
||||
pluginKey: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key'),
|
||||
enabled: z.boolean()
|
||||
})
|
||||
|
||||
const PluginReadPanelEntryParams = z.object({
|
||||
pluginKey: z.string().min(1),
|
||||
panelId: z.string().min(1)
|
||||
})
|
||||
|
||||
const PluginInvokeCommandParams = z.object({
|
||||
pluginKey: z.string().min(1),
|
||||
commandId: z.string().min(1),
|
||||
args: z.unknown().optional()
|
||||
})
|
||||
|
||||
async function listForRpc(): Promise<PluginListEntry[]> {
|
||||
return listPluginsForClients(requirePluginService())
|
||||
}
|
||||
|
||||
function rpcPanelOwner(context: RpcContext): string {
|
||||
// Why: the bearer session must not cross paired-client connections even
|
||||
// when two sockets authenticate as the same device.
|
||||
return `runtime:${context.connectionId ?? context.clientId ?? 'local'}`
|
||||
}
|
||||
|
||||
function bindRpcPanelOwner(service: PluginService, context: RpcContext): string {
|
||||
const ownerKey = rpcPanelOwner(context)
|
||||
service.panels.bindOwnerSignal(ownerKey, context.signal)
|
||||
return ownerKey
|
||||
}
|
||||
|
||||
export const PLUGIN_METHODS: readonly RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'plugins.list',
|
||||
params: null,
|
||||
handler: async () => listForRpc()
|
||||
}),
|
||||
defineMethod({
|
||||
// Why: headless serve has no consent dialog — an explicit consent call is
|
||||
// the only way a pending plugin becomes active on a server.
|
||||
name: 'plugins.consent',
|
||||
params: pluginConsentRequestSchema,
|
||||
handler: async (params) => {
|
||||
const service = requirePluginService()
|
||||
await service.whenReady()
|
||||
if (!pluginConsentForRpc) {
|
||||
throw new Error('Plugin consent is not available on this runtime')
|
||||
}
|
||||
await pluginConsentForRpc(params)
|
||||
return listForRpc()
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'plugins.setEnabled',
|
||||
params: PluginSetEnabledParams,
|
||||
handler: async (params) => {
|
||||
const service = requirePluginService()
|
||||
await service.whenReady()
|
||||
if (!pluginEnablementForRpc) {
|
||||
throw new Error('Plugin enablement is not available on this runtime')
|
||||
}
|
||||
await pluginEnablementForRpc(params.pluginKey, params.enabled)
|
||||
return listForRpc()
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
// Why: headless serve clients relay panel bridge requests over RPC, so
|
||||
// capability enforcement must live behind this method too, not only in
|
||||
// the desktop IPC handler.
|
||||
name: 'plugins.panelAction',
|
||||
// Why: raw admission must run before strict schema parsing so malformed
|
||||
// and oversized traffic cannot bypass the panel budget.
|
||||
params: z.unknown(),
|
||||
handler: async (params, context) => {
|
||||
const service = requirePluginService()
|
||||
await service.whenReady()
|
||||
return {
|
||||
outcome: await service.panels.execute(bindRpcPanelOwner(service, context), params)
|
||||
}
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'plugins.readPanelEntry',
|
||||
params: PluginReadPanelEntryParams,
|
||||
handler: async (params, context): Promise<PluginPanelEntry | null> => {
|
||||
const service = requirePluginService()
|
||||
await service.whenReady()
|
||||
const ownerKey = bindRpcPanelOwner(service, context)
|
||||
const entry = await service.panels.open(ownerKey, params.pluginKey, params.panelId)
|
||||
if (context.signal?.aborted) {
|
||||
service.panels.revokeOwner(ownerKey)
|
||||
return null
|
||||
}
|
||||
return entry
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'plugins.invokeCommand',
|
||||
params: PluginInvokeCommandParams,
|
||||
handler: async (params) => {
|
||||
const service = requirePluginService()
|
||||
await service.whenReady()
|
||||
return service.invokeCommand(params.pluginKey, params.commandId, params.args)
|
||||
}
|
||||
})
|
||||
]
|
||||
@@ -18,7 +18,7 @@ import { registerSshHandlers } from '../ipc/ssh'
|
||||
import { registerRemoteWorkspaceHandlers } from '../ipc/remote-workspace'
|
||||
import { browserManager } from '../browser/browser-manager'
|
||||
import { hasSystemMediaAccess, requestSystemMediaAccess } from '../browser/browser-media-access'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import type { OrcaRuntimeService, RuntimeWorktreeLifecycleEvent } from '../runtime/orca-runtime'
|
||||
import {
|
||||
checkForUpdatesFromMenu,
|
||||
downloadUpdate,
|
||||
@@ -78,11 +78,14 @@ export function attachMainWindowServices(
|
||||
// Why: lets the PTY orphan sweep skip the one crash-recovery reload (#5787).
|
||||
isRecoveryReloadInFlight?: (webContentsId: number) => boolean
|
||||
onBeforeUpdateQuit?: () => void | Promise<void>
|
||||
onWorktreeLifecycle?: (event: RuntimeWorktreeLifecycleEvent) => void
|
||||
}
|
||||
): void {
|
||||
registerAppReloadHandler(mainWindow, options?.onBeforeRendererReload)
|
||||
registerRepoHandlers(mainWindow, store)
|
||||
registerWorktreeHandlers(mainWindow, store, runtime)
|
||||
registerWorktreeHandlers(mainWindow, store, runtime, {
|
||||
onWorktreeLifecycle: options?.onWorktreeLifecycle
|
||||
})
|
||||
// Why: repo/settings mutations resync watchers through this attached main-window context.
|
||||
setWorktreeBaseDirectoryWatcherSyncContext(store, mainWindow)
|
||||
scheduleWorktreeBaseDirectoryWatcherSync(store, mainWindow)
|
||||
|
||||
@@ -49,6 +49,11 @@ import type { LinearIssueAttributeFilter } from '../shared/linear-issue-attribut
|
||||
import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime'
|
||||
import type { StartupCommandDelivery } from '../shared/codex-startup-delivery'
|
||||
import type { SleepingAgentLaunchConfig } from '../shared/agent-session-resume'
|
||||
import type {
|
||||
PluginPanelActionOutcome,
|
||||
PluginPanelEntry
|
||||
} from '../shared/plugins/plugin-panel-bridge'
|
||||
import type { PluginConsentRequest } from '../shared/plugins/plugin-consent-request'
|
||||
import type {
|
||||
LocalhostWorktreeLabelResult,
|
||||
LocalhostWorktreeLabelRoute
|
||||
@@ -896,6 +901,70 @@ export type AppApi = {
|
||||
pickFloatingWorkspaceDirectory: () => Promise<string | null>
|
||||
}
|
||||
|
||||
/** Panel contribution as surfaced by the main-process plugin service. */
|
||||
export type PluginHostPanel = {
|
||||
id: string
|
||||
title: string
|
||||
/** Lucide icon name declared in the plugin manifest. */
|
||||
icon?: string
|
||||
tabKey: `plugin:${string}`
|
||||
}
|
||||
|
||||
/** `pending` = awaiting (re-)consent; `idle` = enabled, worker not running
|
||||
* (lazy); `restarting` = waiting for supervised backoff; `errored` = crashed past the restart budget or failed to start;
|
||||
* `invalid` = unreadable manifest. */
|
||||
export type PluginHostStatus =
|
||||
| 'running'
|
||||
| 'restarting'
|
||||
| 'idle'
|
||||
| 'pending'
|
||||
| 'disabled'
|
||||
| 'errored'
|
||||
| 'invalid'
|
||||
|
||||
/** Wire shape of plugins:list — must stay assignable from the main-process
|
||||
* projection in src/main/plugins/plugin-list-projection.ts. */
|
||||
export type PluginHostListEntry = {
|
||||
pluginKey: string
|
||||
consentFingerprint: string | null
|
||||
name: string
|
||||
version: string
|
||||
publisher: string
|
||||
description?: string
|
||||
status: PluginHostStatus
|
||||
needsReconsent: boolean
|
||||
error?: string
|
||||
isDev: boolean
|
||||
capabilities: { kind: string; description: string }[]
|
||||
panels: PluginHostPanel[]
|
||||
commands: { id: string; title: string }[]
|
||||
hasWorker: boolean
|
||||
restarts: number
|
||||
source?: {
|
||||
kind: 'local-path' | 'git'
|
||||
reference: string
|
||||
resolvedCommit: string | null
|
||||
contentHash: string
|
||||
}
|
||||
}
|
||||
|
||||
export type PluginHostLogLine = { ts: number; level: 'info' | 'warn' | 'error'; line: string }
|
||||
|
||||
export type PluginHostInstallSource =
|
||||
| { kind: 'local-path'; path: string }
|
||||
| { kind: 'git'; url: string; ref: string }
|
||||
|
||||
export type PluginHostInstallResult =
|
||||
| {
|
||||
ok: true
|
||||
pluginKey: string
|
||||
version: string
|
||||
contentHash: string
|
||||
consentFingerprint: string
|
||||
resolvedCommit: string | null
|
||||
}
|
||||
| { ok: false; error: string }
|
||||
|
||||
export type PreloadApi = {
|
||||
app: AppApi
|
||||
orcaProfiles: {
|
||||
@@ -3101,6 +3170,38 @@ export type PreloadApi = {
|
||||
gitBash: {
|
||||
isAvailable: () => Promise<boolean>
|
||||
}
|
||||
plugins: {
|
||||
list: () => Promise<PluginHostListEntry[]>
|
||||
/** Records the consent-dialog answer; approval is keyed to the plugin's
|
||||
* current capability and trusted-worker fingerprint. */
|
||||
consent: (args: PluginConsentRequest) => Promise<PluginHostListEntry[]>
|
||||
setEnabled: (args: { pluginKey: string; enabled: boolean }) => Promise<PluginHostListEntry[]>
|
||||
/** Returns the panel's CSP-wrapped HTML, or null when the plugin or
|
||||
* panel is missing/disabled. Rendered only inside a sandboxed iframe. */
|
||||
readPanelEntry: (args: {
|
||||
pluginKey: string
|
||||
panelId: string
|
||||
}) => Promise<PluginPanelEntry | null>
|
||||
invokeCommand: (args: {
|
||||
pluginKey: string
|
||||
commandId: string
|
||||
args?: unknown
|
||||
}) => Promise<unknown>
|
||||
/** Relays a sandboxed panel's bridge request to main, which enforces the
|
||||
* plugin's consented capabilities before executing. */
|
||||
panelAction: (args: {
|
||||
sessionToken: string
|
||||
action: string
|
||||
params?: unknown
|
||||
}) => Promise<PluginPanelActionOutcome>
|
||||
install: (source: PluginHostInstallSource) => Promise<PluginHostInstallResult>
|
||||
remove: (args: { pluginKey: string }) => Promise<PluginHostListEntry[]>
|
||||
getLogs: (args: { pluginKey: string }) => Promise<PluginHostLogLine[]>
|
||||
/** Re-discovers after settings edits (feature flag, dev paths). */
|
||||
refresh: () => Promise<PluginHostListEntry[]>
|
||||
/** Fires whenever installed plugins, worker states, or panels change. */
|
||||
onChanged: (callback: () => void) => () => void
|
||||
}
|
||||
agentStatus: {
|
||||
/** Listen for agent status updates forwarded from native hook receivers. */
|
||||
onSet: (callback: (data: AgentStatusIpcPayload) => void) => () => void
|
||||
|
||||
+48
-1
@@ -12,6 +12,11 @@ import type { TerminalPaneSplitSource } from '../shared/feature-education-teleme
|
||||
import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime'
|
||||
import type { StartupCommandDelivery } from '../shared/codex-startup-delivery'
|
||||
import type { SleepingAgentLaunchConfig } from '../shared/agent-session-resume'
|
||||
import type {
|
||||
PluginPanelActionOutcome,
|
||||
PluginPanelEntry
|
||||
} from '../shared/plugins/plugin-panel-bridge'
|
||||
import type { PluginConsentRequest } from '../shared/plugins/plugin-consent-request'
|
||||
import type {
|
||||
BaseRefSearchResult,
|
||||
BaseRefDefaultResult,
|
||||
@@ -221,7 +226,13 @@ import type {
|
||||
ReactErrorBoundaryReportArgs,
|
||||
ReactErrorBoundaryReportResult
|
||||
} from '../shared/crash-reporting'
|
||||
import type { PreloadApi } from './api-types'
|
||||
import type {
|
||||
PluginHostInstallResult,
|
||||
PluginHostInstallSource,
|
||||
PluginHostListEntry,
|
||||
PluginHostLogLine,
|
||||
PreloadApi
|
||||
} from './api-types'
|
||||
|
||||
type NativeFileDropCallback = (data: NativeFileDropPayload) => void
|
||||
|
||||
@@ -546,6 +557,42 @@ const api = {
|
||||
isAvailable: (): Promise<boolean> => ipcRenderer.invoke('gitBash:isAvailable')
|
||||
},
|
||||
|
||||
plugins: {
|
||||
list: (): Promise<PluginHostListEntry[]> => ipcRenderer.invoke('plugins:list'),
|
||||
consent: (args: PluginConsentRequest): Promise<PluginHostListEntry[]> =>
|
||||
ipcRenderer.invoke('plugins:consent', args),
|
||||
setEnabled: (args: { pluginKey: string; enabled: boolean }): Promise<PluginHostListEntry[]> =>
|
||||
ipcRenderer.invoke('plugins:setEnabled', args),
|
||||
readPanelEntry: (args: {
|
||||
pluginKey: string
|
||||
panelId: string
|
||||
}): Promise<PluginPanelEntry | null> => ipcRenderer.invoke('plugins:readPanelEntry', args),
|
||||
invokeCommand: (args: {
|
||||
pluginKey: string
|
||||
commandId: string
|
||||
args?: unknown
|
||||
}): Promise<unknown> => ipcRenderer.invoke('plugins:invokeCommand', args),
|
||||
panelAction: (args: {
|
||||
sessionToken: string
|
||||
action: string
|
||||
params?: unknown
|
||||
}): Promise<PluginPanelActionOutcome> => ipcRenderer.invoke('plugins:panelAction', args),
|
||||
install: (source: PluginHostInstallSource): Promise<PluginHostInstallResult> =>
|
||||
ipcRenderer.invoke('plugins:install', source),
|
||||
remove: (args: { pluginKey: string }): Promise<PluginHostListEntry[]> =>
|
||||
ipcRenderer.invoke('plugins:remove', args),
|
||||
getLogs: (args: { pluginKey: string }): Promise<PluginHostLogLine[]> =>
|
||||
ipcRenderer.invoke('plugins:getLogs', args),
|
||||
refresh: (): Promise<PluginHostListEntry[]> => ipcRenderer.invoke('plugins:refresh'),
|
||||
onChanged: (callback: () => void): (() => void) => {
|
||||
const listener = (): void => callback()
|
||||
ipcRenderer.on('plugins:changed', listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener('plugins:changed', listener)
|
||||
}
|
||||
}
|
||||
} satisfies PreloadApi['plugins'],
|
||||
|
||||
repos: {
|
||||
list: () => ipcRenderer.invoke('repos:list'),
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { MethodHandler, RequestContext } from './dispatcher'
|
||||
import {
|
||||
admitPluginPanelCall,
|
||||
createPluginPanelCallAdmission,
|
||||
type PluginPanelCallAdmission
|
||||
} from '../shared/plugins/plugin-panel-call-admission'
|
||||
import {
|
||||
executePluginHostCallRequest,
|
||||
isPluginHostCallRequest,
|
||||
type ResolvePluginHostCallPolicy
|
||||
} from '../main/plugins/plugin-host-call-adapter'
|
||||
|
||||
export const RELAY_PLUGIN_PANEL_HOST_CALL_METHOD = 'plugins.hostCall.panel'
|
||||
export const RELAY_PLUGIN_WORKER_HOST_CALL_METHOD = 'plugins.hostCall.worker'
|
||||
|
||||
export type RelayPluginHostCallDispatcher = {
|
||||
onRequest(method: string, handler: MethodHandler): void
|
||||
}
|
||||
|
||||
export type ResolveRelayPluginHostCallIdentity = (
|
||||
context: RequestContext
|
||||
) => string | null | Promise<string | null>
|
||||
|
||||
/** Relay provisioning is deliberately out of scope here. Its connection-
|
||||
* keyed resolver owns plugin identity, consent, services, and audit authority. */
|
||||
export function registerRelayPluginHostCallHandlers(
|
||||
dispatcher: RelayPluginHostCallDispatcher,
|
||||
resolveIdentity: ResolveRelayPluginHostCallIdentity,
|
||||
resolvePolicy: ResolvePluginHostCallPolicy,
|
||||
options: { panelAdmission?: PluginPanelCallAdmission } = {}
|
||||
): void {
|
||||
const panelAdmission = options.panelAdmission ?? createPluginPanelCallAdmission()
|
||||
const register = (registeredMethod: string, viaPanel: boolean): void => {
|
||||
dispatcher.onRequest(registeredMethod, async (params, context) => {
|
||||
let pluginKey: string | null
|
||||
try {
|
||||
pluginKey = await resolveIdentity(context)
|
||||
} catch {
|
||||
pluginKey = null
|
||||
}
|
||||
if (!pluginKey) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'unavailable',
|
||||
error: 'plugin host authority is not available'
|
||||
}
|
||||
}
|
||||
if (viaPanel) {
|
||||
const admissionRefusal = admitPluginPanelCall(panelAdmission, pluginKey, params)
|
||||
if (admissionRefusal) {
|
||||
return admissionRefusal
|
||||
}
|
||||
}
|
||||
if (!isPluginHostCallRequest(params)) {
|
||||
return { ok: false, code: 'invalid_request', error: 'malformed plugin host call request' }
|
||||
}
|
||||
return executePluginHostCallRequest({
|
||||
pluginKey,
|
||||
request: params,
|
||||
viaPanel,
|
||||
resolvePolicy
|
||||
})
|
||||
})
|
||||
}
|
||||
// Why: transport authority is fixed by the registered RPC method; callers
|
||||
// cannot promote a panel call to the wider worker method set in params.
|
||||
register(RELAY_PLUGIN_PANEL_HOST_CALL_METHOD, true)
|
||||
register(RELAY_PLUGIN_WORKER_HOST_CALL_METHOD, false)
|
||||
}
|
||||
@@ -63,6 +63,7 @@ import { pickRemoteCliEnv } from './remote-cli-env'
|
||||
import { relayLogLine } from './relay-diagnostic-log'
|
||||
import { remoteCliRequestTimeoutMs } from './remote-cli-timeout'
|
||||
import { shouldReadRemoteCliStdin } from './remote-cli-stdin'
|
||||
import { registerRelayPluginHostCallHandlers } from './plugin-host-call-handler'
|
||||
|
||||
const DEFAULT_GRACE_MS = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000
|
||||
const SOCK_NAME = 'relay.sock'
|
||||
@@ -483,6 +484,14 @@ async function main(): Promise<void> {
|
||||
const _workspaceSessionHandler = new WorkspaceSessionHandler(dispatcher)
|
||||
void _workspaceSessionHandler
|
||||
|
||||
// Why: relay-hosted plugin provisioning is a later phase. Register the
|
||||
// enforcement boundary now with no consented identities or runtime services.
|
||||
registerRelayPluginHostCallHandlers(
|
||||
dispatcher,
|
||||
() => null,
|
||||
() => ({ grantedCapabilities: null, services: null })
|
||||
)
|
||||
|
||||
dispatcher.onRequest('orca.cli', async (params, context) => {
|
||||
return await dispatcher.requestAnyClient('orca.cli', params, {
|
||||
excludeClientId: context.clientId,
|
||||
|
||||
@@ -322,6 +322,10 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
|
||||
terminalModelQueryAuthority: true,
|
||||
defaultTuiAgent: null,
|
||||
disabledTuiAgents: [...DEFAULT_DISABLED_TUI_AGENTS],
|
||||
pluginSystemEnabled: false,
|
||||
disabledPlugins: [],
|
||||
pluginConsents: {},
|
||||
devPluginPaths: [],
|
||||
claudeAgentTeamsDefaultDisabledMigrated: true,
|
||||
skipDeleteWorktreeConfirm: false,
|
||||
skipCloseTerminalWithRunningProcessConfirm: false,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* Plugin capability model v0. The manifest declares capabilities, the user
|
||||
* consents against a fingerprint covering capabilities and worker trust, and the
|
||||
* host enforces at every plugin-callable boundary (panel bridge + worker host
|
||||
* API). Electron-free: shared by desktop main, headless serve, the relay
|
||||
* conformance path, and tests.
|
||||
*
|
||||
* v0 is a closed set of unscoped kinds so a typo (or a capability from a newer
|
||||
* Orca) fails manifest validation instead of silently granting nothing.
|
||||
* Scoped kinds (net:fetch hosts, process:exec globs) arrive in later phases.
|
||||
*/
|
||||
|
||||
export const PLUGIN_CAPABILITY_KINDS = [
|
||||
'workspace:read',
|
||||
'terminal:send',
|
||||
'notifications:show',
|
||||
'storage',
|
||||
'secrets',
|
||||
'events:subscribe',
|
||||
'settings:own'
|
||||
] as const
|
||||
|
||||
export type PluginCapabilityKind = (typeof PLUGIN_CAPABILITY_KINDS)[number]
|
||||
|
||||
// Strict object (not a bare enum) so scoped fields can be added per-kind later
|
||||
// without changing the manifest shape.
|
||||
export const pluginCapabilitySchema = z.object({ kind: z.enum(PLUGIN_CAPABILITY_KINDS) }).strict()
|
||||
|
||||
export type PluginCapability = z.infer<typeof pluginCapabilitySchema>
|
||||
|
||||
export function isPluginCapabilityKind(value: string): value is PluginCapabilityKind {
|
||||
return (PLUGIN_CAPABILITY_KINDS as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
/** Plain-language consent copy per capability. Shown verbatim in the install
|
||||
* preview / consent dialog; keep each line honest about what is enforced. */
|
||||
export const PLUGIN_CAPABILITY_DESCRIPTIONS: Record<PluginCapabilityKind, string> = {
|
||||
'workspace:read': 'Read the name, branch, and terminal list of your focused worktree',
|
||||
'terminal:send': 'Type text into a terminal you can see (always a specific terminal)',
|
||||
'notifications:show': 'Show desktop notifications labeled with the plugin name',
|
||||
storage: "Store data in the plugin's own storage folder",
|
||||
secrets: "Store and read secrets in the plugin's own encrypted vault",
|
||||
'events:subscribe':
|
||||
'Get notified when worktrees are created or removed and when agent status changes',
|
||||
'settings:own': "Read and change the plugin's own settings"
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical serialization of a capability set. Order- and duplicate-
|
||||
* insensitive so consent is stable across manifest reformatting;
|
||||
* key-sorted so future scoped fields cannot produce two encodings of the
|
||||
* same grant.
|
||||
*/
|
||||
export function canonicalizeCapabilitySet(capabilities: readonly PluginCapability[]): string {
|
||||
const encoded = capabilities.map((capability) =>
|
||||
JSON.stringify(
|
||||
Object.fromEntries(Object.entries(capability).sort(([a], [b]) => a.localeCompare(b)))
|
||||
)
|
||||
)
|
||||
return JSON.stringify([...new Set(encoded)].sort())
|
||||
}
|
||||
|
||||
export function capabilityKinds(capabilities: readonly PluginCapability[]): PluginCapabilityKind[] {
|
||||
return [...new Set(capabilities.map((capability) => capability.kind))]
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { getPluginHostMethodSpec, isPluginPanelAction } from './plugin-host-api'
|
||||
import type { PluginCapabilityKind } from './plugin-capabilities'
|
||||
|
||||
/**
|
||||
* The capability gate — pure, electron-free, shared by the Electron main
|
||||
* host, the headless serve RPC path, and the relay so a security decision
|
||||
* cannot land on desktop while drifting on the remote path. Deny-by-default:
|
||||
* an empty capability set grants nothing. A conformance test runs identical
|
||||
* cases against the desktop and relay enforcement surfaces.
|
||||
*
|
||||
* Ported from community PR #5801's electron-free gate, rekeyed to the host
|
||||
* API v0 spec table.
|
||||
*/
|
||||
|
||||
export type PluginGateErrorCode =
|
||||
| 'unknown_method'
|
||||
| 'capability_denied'
|
||||
| 'consent_required'
|
||||
| 'panel_forbidden'
|
||||
|
||||
export type PluginGateDecision =
|
||||
| { granted: true }
|
||||
| { granted: false; code: PluginGateErrorCode; error: string }
|
||||
|
||||
export type PluginGateSubject = {
|
||||
/** Capability kinds from the manifest the user consented to; null when the
|
||||
* plugin is unknown, invalid, disabled, or consent is missing/stale. */
|
||||
grantedCapabilities: readonly PluginCapabilityKind[] | null
|
||||
/** True when the call arrives over the sandboxed panel bridge (narrower
|
||||
* method surface than workers). */
|
||||
viaPanel: boolean
|
||||
}
|
||||
|
||||
export function gatePluginHostCall(subject: PluginGateSubject, method: string): PluginGateDecision {
|
||||
const spec = getPluginHostMethodSpec(method)
|
||||
if (!spec) {
|
||||
return { granted: false, code: 'unknown_method', error: `unknown host method: ${method}` }
|
||||
}
|
||||
if (subject.viaPanel && !isPluginPanelAction(method)) {
|
||||
return {
|
||||
granted: false,
|
||||
code: 'panel_forbidden',
|
||||
error: `method ${method} is not available to sandboxed panels`
|
||||
}
|
||||
}
|
||||
// Why: a disabled/unknown/stale-consent plugin must fail exactly like an
|
||||
// ungranted capability — no probe-able distinction for plugin code.
|
||||
if (subject.grantedCapabilities === null) {
|
||||
return {
|
||||
granted: false,
|
||||
code: 'consent_required',
|
||||
error: 'plugin is not enabled with current consent'
|
||||
}
|
||||
}
|
||||
if (!subject.grantedCapabilities.includes(spec.capability)) {
|
||||
return {
|
||||
granted: false,
|
||||
code: 'capability_denied',
|
||||
error: `plugin does not have the "${spec.capability}" capability`
|
||||
}
|
||||
}
|
||||
return { granted: true }
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canonicalizeCapabilitySet, type PluginCapability } from './plugin-capabilities'
|
||||
import { fingerprintPluginConsent } from './plugin-consent-fingerprint'
|
||||
import {
|
||||
getPluginActivationState,
|
||||
needsReconsent,
|
||||
normalizePluginConsents
|
||||
} from './plugin-consent-state'
|
||||
import {
|
||||
parsePluginLockfile,
|
||||
serializePluginLockfile,
|
||||
type PluginLockfile
|
||||
} from './plugin-install-lockfile'
|
||||
|
||||
const workspaceRead: PluginCapability = { kind: 'workspace:read' }
|
||||
const storage: PluginCapability = { kind: 'storage' }
|
||||
|
||||
describe('fingerprintPluginConsent', () => {
|
||||
it('drops malformed persisted consent identities and oversized fingerprints', () => {
|
||||
expect(
|
||||
normalizePluginConsents({
|
||||
__proto__: 'polluted',
|
||||
constructor: 'polluted',
|
||||
invalid: 'sha256-invalid',
|
||||
'orca-samples.demo': 'sha256-valid',
|
||||
'orca-samples.large': 'x'.repeat(257)
|
||||
})
|
||||
).toEqual({ 'orca-samples.demo': 'sha256-valid' })
|
||||
})
|
||||
|
||||
it('is stable across capability order and duplicate declarations', () => {
|
||||
const first = fingerprintPluginConsent({
|
||||
main: undefined,
|
||||
capabilities: [workspaceRead, storage, workspaceRead]
|
||||
})
|
||||
const second = fingerprintPluginConsent({
|
||||
main: undefined,
|
||||
capabilities: [storage, workspaceRead]
|
||||
})
|
||||
|
||||
expect(first).toBe(second)
|
||||
})
|
||||
|
||||
it('changes when a panel-only plugin gains a trusted Node worker', () => {
|
||||
const panelOnly = fingerprintPluginConsent({ main: undefined, capabilities: [] })
|
||||
const withWorker = fingerprintPluginConsent({ main: 'worker.js', capabilities: [] })
|
||||
|
||||
expect(withWorker).not.toBe(panelOnly)
|
||||
const lists = {
|
||||
pluginConsents: { 'orca-samples.demo': panelOnly },
|
||||
disabledPlugins: []
|
||||
}
|
||||
expect(getPluginActivationState('orca-samples.demo', withWorker, lists)).toBe('pending')
|
||||
expect(needsReconsent('orca-samples.demo', withWorker, lists)).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves capability-only fingerprints for existing panel plugins', () => {
|
||||
const capabilities = [workspaceRead, storage]
|
||||
const legacy = `sha256-${createHash('sha256')
|
||||
.update(canonicalizeCapabilitySet(capabilities))
|
||||
.digest('base64')}`
|
||||
|
||||
expect(fingerprintPluginConsent({ main: undefined, capabilities })).toBe(legacy)
|
||||
})
|
||||
})
|
||||
|
||||
describe('plugin install lockfile consent fingerprints', () => {
|
||||
const persistedEntry = {
|
||||
pluginKey: 'orca-samples.demo',
|
||||
version: '1.0.0',
|
||||
source: { kind: 'local-path' as const, path: '/plugins/demo' },
|
||||
resolvedCommit: null,
|
||||
contentHash: '0123456789abcdef0123456789abcdef',
|
||||
capabilityHash: 'sha256-legacy-name',
|
||||
installedAt: 1
|
||||
}
|
||||
|
||||
it('reads the legacy capabilityHash field as a consent fingerprint', () => {
|
||||
const parsed = parsePluginLockfile({
|
||||
version: 1,
|
||||
plugins: { 'orca-samples.demo': persistedEntry }
|
||||
})
|
||||
|
||||
expect(parsed.plugins['orca-samples.demo']?.consentFingerprint).toBe('sha256-legacy-name')
|
||||
})
|
||||
|
||||
it('keeps writing the v1 field name for rollback compatibility', () => {
|
||||
const lock: PluginLockfile = {
|
||||
version: 1,
|
||||
plugins: {
|
||||
'orca-samples.demo': {
|
||||
...persistedEntry,
|
||||
consentFingerprint: 'sha256-current'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(serializePluginLockfile(lock)).toMatchObject({
|
||||
version: 1,
|
||||
plugins: {
|
||||
'orca-samples.demo': {
|
||||
capabilityHash: 'sha256-current'
|
||||
}
|
||||
}
|
||||
})
|
||||
expect(
|
||||
(serializePluginLockfile(lock) as { plugins: Record<string, unknown> }).plugins[
|
||||
'orca-samples.demo'
|
||||
]
|
||||
).not.toHaveProperty('consentFingerprint')
|
||||
})
|
||||
|
||||
it('drops a lockfile whose record key disagrees with its embedded identity', () => {
|
||||
const parsed = parsePluginLockfile({
|
||||
version: 1,
|
||||
plugins: {
|
||||
'orca-samples.other': persistedEntry
|
||||
}
|
||||
})
|
||||
|
||||
expect(parsed.plugins).toEqual({})
|
||||
})
|
||||
|
||||
it('accepts SHA-256 Git object ids in lockfile provenance', () => {
|
||||
const parsed = parsePluginLockfile({
|
||||
version: 1,
|
||||
plugins: {
|
||||
'orca-samples.demo': { ...persistedEntry, resolvedCommit: 'a'.repeat(64) }
|
||||
}
|
||||
})
|
||||
|
||||
expect(parsed.plugins['orca-samples.demo']?.resolvedCommit).toBe('a'.repeat(64))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { canonicalizeCapabilitySet } from './plugin-capabilities'
|
||||
import type { PluginManifest } from './plugin-manifest'
|
||||
|
||||
/**
|
||||
* Consent covers both the declared host capabilities and whether the plugin
|
||||
* executes trusted Node code. A panel-only update that adds `main` crosses a
|
||||
* trust boundary even when its capability list is unchanged.
|
||||
*/
|
||||
export function canonicalizePluginConsent(
|
||||
manifest: Pick<PluginManifest, 'capabilities' | 'main'>
|
||||
): string {
|
||||
const capabilities = canonicalizeCapabilitySet(manifest.capabilities)
|
||||
// Preserve the original capability-only digest for panel/content plugins,
|
||||
// while making the trusted Node tier a domain-separated fingerprint.
|
||||
return manifest.main === undefined ? capabilities : `${capabilities}\0trusted-node-worker`
|
||||
}
|
||||
|
||||
export function fingerprintPluginConsent(
|
||||
manifest: Pick<PluginManifest, 'capabilities' | 'main'>
|
||||
): string {
|
||||
return `sha256-${createHash('sha256').update(canonicalizePluginConsent(manifest)).digest('base64')}`
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { pluginConsentRequestSchema } from './plugin-consent-request'
|
||||
|
||||
describe('pluginConsentRequestSchema', () => {
|
||||
it('requires the exact reviewed consent fingerprint', () => {
|
||||
expect(
|
||||
pluginConsentRequestSchema.safeParse({
|
||||
pluginKey: 'orca-samples.demo',
|
||||
decision: 'approve'
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
pluginConsentRequestSchema.safeParse({
|
||||
pluginKey: 'orca-samples.demo',
|
||||
reviewedFingerprint: '',
|
||||
decision: 'approve'
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts an explicit opaque fingerprint and rejects contract drift', () => {
|
||||
expect(
|
||||
pluginConsentRequestSchema.parse({
|
||||
pluginKey: 'orca-samples.demo',
|
||||
reviewedFingerprint: 'sha256-reviewed',
|
||||
decision: 'keep-disabled'
|
||||
})
|
||||
).toEqual({
|
||||
pluginKey: 'orca-samples.demo',
|
||||
reviewedFingerprint: 'sha256-reviewed',
|
||||
decision: 'keep-disabled'
|
||||
})
|
||||
expect(() =>
|
||||
pluginConsentRequestSchema.parse({
|
||||
pluginKey: 'orca-samples.demo',
|
||||
reviewedFingerprint: 'sha256-reviewed',
|
||||
decision: 'approve',
|
||||
unexpected: true
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* Experimental consent write contract shared by desktop IPC and serve RPC.
|
||||
* The reviewed fingerprint makes approval conditional on the exact manifest
|
||||
* trust boundary the user saw, rather than whichever same-key plugin is current.
|
||||
*/
|
||||
export const pluginConsentRequestSchema = z
|
||||
.object({
|
||||
pluginKey: z.string().min(1),
|
||||
reviewedFingerprint: z.string().min(1).max(256),
|
||||
decision: z.enum(['approve', 'keep-disabled'])
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type PluginConsentRequest = z.infer<typeof pluginConsentRequestSchema>
|
||||
@@ -0,0 +1,70 @@
|
||||
import { isQualifiedPluginKey } from './plugin-manifest'
|
||||
|
||||
/**
|
||||
* Consent + enablement state derivation. Consent is recorded against the
|
||||
* (qualified plugin key → consent fingerprint) pair, never the bare id. The
|
||||
* fingerprint covers capabilities and the trusted-worker execution tier, so
|
||||
* crossing either trust boundary requires approval before plugin code runs.
|
||||
*/
|
||||
|
||||
export type PluginActivationState = 'approved' | 'pending' | 'disabled'
|
||||
|
||||
export type PluginConsentLists = {
|
||||
/** Qualified key → consent fingerprint. */
|
||||
pluginConsents: Readonly<Record<string, string>>
|
||||
disabledPlugins: readonly string[]
|
||||
}
|
||||
|
||||
export function getPluginActivationState(
|
||||
qualifiedKey: string,
|
||||
currentConsentFingerprint: string,
|
||||
lists: PluginConsentLists
|
||||
): PluginActivationState {
|
||||
if (lists.disabledPlugins.includes(qualifiedKey)) {
|
||||
return 'disabled'
|
||||
}
|
||||
return lists.pluginConsents[qualifiedKey] === currentConsentFingerprint ? 'approved' : 'pending'
|
||||
}
|
||||
|
||||
/** True when the plugin was consented before but its capability set changed —
|
||||
* the UI shows "needs re-approval" instead of a first-install prompt. */
|
||||
export function needsReconsent(
|
||||
qualifiedKey: string,
|
||||
currentConsentFingerprint: string,
|
||||
lists: PluginConsentLists
|
||||
): boolean {
|
||||
const recorded = lists.pluginConsents[qualifiedKey]
|
||||
return recorded !== undefined && recorded !== currentConsentFingerprint
|
||||
}
|
||||
|
||||
export function normalizePluginConsents(value: unknown): Record<string, string> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
return {}
|
||||
}
|
||||
const consents: Record<string, string> = {}
|
||||
for (const [key, hash] of Object.entries(value)) {
|
||||
if (
|
||||
typeof hash === 'string' &&
|
||||
hash.length > 0 &&
|
||||
hash.length <= 256 &&
|
||||
isQualifiedPluginKey(key)
|
||||
) {
|
||||
consents[key] = hash
|
||||
}
|
||||
}
|
||||
return consents
|
||||
}
|
||||
|
||||
export function normalizePluginIdList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
...new Set(
|
||||
value.filter(
|
||||
(entry): entry is string =>
|
||||
typeof entry === 'string' && entry.length > 0 && entry.length <= 32 * 1024
|
||||
)
|
||||
)
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { z } from 'zod'
|
||||
import type { PluginEventName } from './plugin-manifest'
|
||||
|
||||
/**
|
||||
* Payload contracts for the v0 plugin event set (worktree lifecycle + agent
|
||||
* status only). Payloads are bounded projections — never raw runtime
|
||||
* objects — so nothing sensitive (absolute repo paths beyond the worktree's
|
||||
* own, remotes, credentials) can leak through the event stream.
|
||||
*/
|
||||
|
||||
export const worktreeCreatedPayloadSchema = z.object({
|
||||
worktreeId: z.string().min(1).max(2048),
|
||||
path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(32 * 1024),
|
||||
branch: z.string().max(1024)
|
||||
})
|
||||
|
||||
export const worktreeRemovedPayloadSchema = z.object({
|
||||
worktreeId: z.string().min(1).max(2048),
|
||||
path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(32 * 1024)
|
||||
})
|
||||
|
||||
export const agentStatusChangedPayloadSchema = z.object({
|
||||
worktreeId: z.string().min(1).max(2048).nullable(),
|
||||
paneKey: z.string().min(1).max(2048),
|
||||
state: z.string().min(1).max(256),
|
||||
receivedAt: z.number().finite().positive()
|
||||
})
|
||||
|
||||
export const PLUGIN_EVENT_PAYLOAD_SCHEMAS: Record<PluginEventName, z.ZodTypeAny> = {
|
||||
'worktree.created': worktreeCreatedPayloadSchema,
|
||||
'worktree.removed': worktreeRemovedPayloadSchema,
|
||||
'agent.status.changed': agentStatusChangedPayloadSchema
|
||||
}
|
||||
|
||||
export type PluginWorktreeCreatedPayload = z.infer<typeof worktreeCreatedPayloadSchema>
|
||||
export type PluginWorktreeRemovedPayload = z.infer<typeof worktreeRemovedPayloadSchema>
|
||||
export type PluginAgentStatusChangedPayload = z.infer<typeof agentStatusChangedPayloadSchema>
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Typed extension-point registry. An extension point is a named, typed slot
|
||||
* (like a Go interface value); plugins register implementations and hosts
|
||||
* resolve them without compile-time knowledge of the implementor. Runtime
|
||||
* registration because plugins load (and unload) dynamically.
|
||||
*
|
||||
* All P0 points are EXPERIMENTAL — excluded from compatibility guarantees.
|
||||
*/
|
||||
|
||||
declare const extensionPointBrand: unique symbol
|
||||
|
||||
export type PluginExtensionPoint<T> = {
|
||||
readonly key: string
|
||||
/** Experimental points may change or vanish between releases. */
|
||||
readonly experimental: boolean
|
||||
// Why: phantom field carries T so register/resolve stay type-safe per point
|
||||
// even though the runtime value is just `{ key, experimental }`.
|
||||
readonly [extensionPointBrand]?: T
|
||||
}
|
||||
|
||||
export function definePluginExtensionPoint<T>(
|
||||
key: string,
|
||||
options: { experimental: boolean }
|
||||
): PluginExtensionPoint<T> {
|
||||
return { key, experimental: options.experimental }
|
||||
}
|
||||
|
||||
/** A contributed command whose handler lives in the plugin's worker. The
|
||||
* proxy lazily activates the worker on first invoke. */
|
||||
export type PluginWorkerCommand = {
|
||||
readonly commandId: string
|
||||
invoke(args?: unknown): Promise<unknown>
|
||||
}
|
||||
|
||||
export const PLUGIN_COMMAND_EXTENSION_POINT = definePluginExtensionPoint<PluginWorkerCommand>(
|
||||
'command',
|
||||
{ experimental: true }
|
||||
)
|
||||
|
||||
export type PluginExtensionRegistration<T> = {
|
||||
pluginId: string
|
||||
/** Contribution id within the plugin; addresses one of several providers. */
|
||||
providerId?: string
|
||||
implementation: T
|
||||
}
|
||||
|
||||
export type PluginExtensionRegistry = {
|
||||
register<T>(
|
||||
point: PluginExtensionPoint<T>,
|
||||
pluginId: string,
|
||||
implementation: T,
|
||||
providerId?: string
|
||||
): () => void
|
||||
resolveAll<T>(point: PluginExtensionPoint<T>): PluginExtensionRegistration<T>[]
|
||||
resolve<T>(point: PluginExtensionPoint<T>, pluginId: string, providerId?: string): T | null
|
||||
clearPlugin(pluginId: string): void
|
||||
}
|
||||
|
||||
export function createPluginExtensionRegistry(): PluginExtensionRegistry {
|
||||
const byPoint = new Map<string, PluginExtensionRegistration<unknown>[]>()
|
||||
|
||||
return {
|
||||
register(point, pluginId, implementation, providerId) {
|
||||
const registrations = byPoint.get(point.key) ?? []
|
||||
const entry = { pluginId, providerId, implementation }
|
||||
byPoint.set(point.key, [...registrations, entry])
|
||||
return () => {
|
||||
const current = byPoint.get(point.key) ?? []
|
||||
byPoint.set(
|
||||
point.key,
|
||||
current.filter((registration) => registration !== entry)
|
||||
)
|
||||
}
|
||||
},
|
||||
resolveAll<T>(point: PluginExtensionPoint<T>) {
|
||||
return (byPoint.get(point.key) ?? []) as PluginExtensionRegistration<T>[]
|
||||
},
|
||||
resolve<T>(point: PluginExtensionPoint<T>, pluginId: string, providerId?: string) {
|
||||
const registrations = (byPoint.get(point.key) ?? []) as PluginExtensionRegistration<T>[]
|
||||
// Why: without a providerId the first registration wins — only safe for
|
||||
// single-provider plugins; multi-provider callers must address by id.
|
||||
const match = registrations.find(
|
||||
(registration) =>
|
||||
registration.pluginId === pluginId &&
|
||||
(providerId === undefined || registration.providerId === providerId)
|
||||
)
|
||||
return match?.implementation ?? null
|
||||
},
|
||||
clearPlugin(pluginId) {
|
||||
for (const [key, registrations] of byPoint) {
|
||||
byPoint.set(
|
||||
key,
|
||||
registrations.filter((registration) => registration.pluginId !== pluginId)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { z } from 'zod'
|
||||
import { PLUGIN_EVENT_NAMES } from './plugin-manifest'
|
||||
import type { PluginCapabilityKind } from './plugin-capabilities'
|
||||
|
||||
/**
|
||||
* Host API v0 — the separately-versioned public facade plugins call. Every
|
||||
* method carries params AND result schemas plus capability/mutation metadata;
|
||||
* handlers (bound in main) delegate to runtime services. The raw runtime-RPC
|
||||
* registry is never exposed: its methods have no result schemas and evolve at
|
||||
* internal velocity.
|
||||
*
|
||||
* This table is the single source of truth for the capability gate, the panel
|
||||
* bridge action set, and the worker SDK. Electron-free by design: desktop
|
||||
* main, headless serve, the relay conformance path, and tests all import it.
|
||||
*
|
||||
* EXPERIMENTAL: additive-only within pluginApi major 1 once frozen; no
|
||||
* stability promises before then.
|
||||
*/
|
||||
|
||||
export const PANEL_ACTION_TEXT_MAX_LENGTH = 4096
|
||||
export const PLUGIN_WORKSPACE_TERMINAL_LIMIT = 50
|
||||
export const PLUGIN_WORKSPACE_LABEL_MAX_LENGTH = 512
|
||||
export const PLUGIN_TERMINAL_ID_MAX_LENGTH = 1024
|
||||
|
||||
const workspaceReadContextParams = z.object({}).strict().optional()
|
||||
const workspaceReadContextResult = z
|
||||
.object({
|
||||
branch: z.string().max(PLUGIN_WORKSPACE_LABEL_MAX_LENGTH),
|
||||
displayName: z.string().max(PLUGIN_WORKSPACE_LABEL_MAX_LENGTH),
|
||||
/** Terminals of the focused worktree, so callers can address a specific
|
||||
* terminal id — the API has no "active terminal" write target. */
|
||||
terminals: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z.string().min(1).max(PLUGIN_TERMINAL_ID_MAX_LENGTH)
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.max(PLUGIN_WORKSPACE_TERMINAL_LIMIT)
|
||||
})
|
||||
.strict()
|
||||
.nullable()
|
||||
|
||||
const terminalSendTextParams = z.object({
|
||||
/** Explicit target. Never "the active terminal": a focus change must not
|
||||
* redirect a delayed plugin write into another pane (design-doc rule). */
|
||||
terminalId: z.string().min(1).max(PLUGIN_TERMINAL_ID_MAX_LENGTH),
|
||||
text: z.string().min(1).max(PANEL_ACTION_TEXT_MAX_LENGTH),
|
||||
enter: z.boolean().default(false)
|
||||
})
|
||||
const terminalSendTextResult = z.object({ accepted: z.boolean() })
|
||||
|
||||
const notificationsShowParams = z.object({
|
||||
title: z.string().min(1).max(120),
|
||||
body: z.string().max(1000).optional()
|
||||
})
|
||||
const notificationsShowResult = z.object({ delivered: z.boolean() })
|
||||
|
||||
const RESERVED_STORAGE_KEYS = new Set(['__proto__', 'prototype', 'constructor'])
|
||||
const storageKeySchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(256)
|
||||
.refine((key) => !RESERVED_STORAGE_KEYS.has(key), 'reserved storage key')
|
||||
const pluginJsonValueSchema = z.json()
|
||||
/** Caps keep per-plugin storage an honest key-value store, not a database. */
|
||||
export const PLUGIN_STORAGE_VALUE_MAX_BYTES = 256 * 1024
|
||||
export const PLUGIN_STORAGE_TOTAL_MAX_BYTES = 5 * 1024 * 1024
|
||||
export const PLUGIN_STORAGE_KEY_LIMIT = 1024
|
||||
|
||||
const storageGetParams = z.object({ key: storageKeySchema })
|
||||
const storageGetResult = z.object({ value: pluginJsonValueSchema })
|
||||
const storageSetParams = z.object({ key: storageKeySchema, value: pluginJsonValueSchema })
|
||||
const storageSetResult = z.object({ ok: z.literal(true) })
|
||||
const storageDeleteParams = z.object({ key: storageKeySchema })
|
||||
const storageDeleteResult = z.object({ ok: z.literal(true) })
|
||||
const storageKeysParams = z.object({}).strict().optional()
|
||||
const storageKeysResult = z.object({ keys: z.array(z.string()).max(PLUGIN_STORAGE_KEY_LIMIT) })
|
||||
|
||||
const secretsGetParams = z.object({ key: storageKeySchema })
|
||||
const secretsGetResult = z.object({ value: z.string().nullable() })
|
||||
const secretsSetParams = z.object({ key: storageKeySchema, value: z.string().max(64 * 1024) })
|
||||
const secretsSetResult = z.object({ ok: z.literal(true) })
|
||||
const secretsDeleteParams = z.object({ key: storageKeySchema })
|
||||
const secretsDeleteResult = z.object({ ok: z.literal(true) })
|
||||
|
||||
const settingsGetParams = z.object({}).strict().optional()
|
||||
const settingsGetResult = z.object({ settings: z.record(z.string(), pluginJsonValueSchema) })
|
||||
const settingsSetParams = z.object({ key: storageKeySchema, value: pluginJsonValueSchema })
|
||||
const settingsSetResult = z.object({ ok: z.literal(true) })
|
||||
|
||||
const eventsSubscribeParams = z.object({
|
||||
events: z.array(z.enum(PLUGIN_EVENT_NAMES)).min(1).max(PLUGIN_EVENT_NAMES.length)
|
||||
})
|
||||
const eventsSubscribeResult = z.object({ subscribed: z.array(z.enum(PLUGIN_EVENT_NAMES)) })
|
||||
|
||||
export type PluginHostMethodSpec = {
|
||||
name: string
|
||||
/** pluginApi minor the method appeared in (`1.0` for the v0 set). */
|
||||
since: string
|
||||
/** Machine-readable resource boundary enforced by the host binding. */
|
||||
scope: 'active-worktree' | 'explicit-terminal' | 'plugin-private' | 'desktop' | 'host-events'
|
||||
stability: 'experimental'
|
||||
capability: PluginCapabilityKind
|
||||
/** Mutations are audit-logged with actor `plugin:<id>`. */
|
||||
mutation: boolean
|
||||
/** Whether sandboxed panels may call this over the postMessage bridge.
|
||||
* Workers can call every method. */
|
||||
panel: boolean
|
||||
params: z.ZodTypeAny
|
||||
result: z.ZodTypeAny
|
||||
}
|
||||
|
||||
const spec = <P extends z.ZodTypeAny, R extends z.ZodTypeAny>(
|
||||
entry: Omit<PluginHostMethodSpec, 'params' | 'result' | 'stability'> & {
|
||||
params: P
|
||||
result: R
|
||||
}
|
||||
): PluginHostMethodSpec => ({ ...entry, stability: 'experimental' })
|
||||
|
||||
export const PLUGIN_HOST_API_V0: readonly PluginHostMethodSpec[] = [
|
||||
spec({
|
||||
name: 'workspace.readContext',
|
||||
since: '1.0',
|
||||
scope: 'active-worktree',
|
||||
capability: 'workspace:read',
|
||||
mutation: false,
|
||||
panel: true,
|
||||
params: workspaceReadContextParams,
|
||||
result: workspaceReadContextResult
|
||||
}),
|
||||
spec({
|
||||
name: 'terminal.sendText',
|
||||
since: '1.0',
|
||||
scope: 'explicit-terminal',
|
||||
capability: 'terminal:send',
|
||||
mutation: true,
|
||||
panel: true,
|
||||
params: terminalSendTextParams,
|
||||
result: terminalSendTextResult
|
||||
}),
|
||||
spec({
|
||||
name: 'notifications.show',
|
||||
since: '1.0',
|
||||
scope: 'desktop',
|
||||
capability: 'notifications:show',
|
||||
mutation: true,
|
||||
panel: true,
|
||||
params: notificationsShowParams,
|
||||
result: notificationsShowResult
|
||||
}),
|
||||
spec({
|
||||
name: 'storage.get',
|
||||
since: '1.0',
|
||||
scope: 'plugin-private',
|
||||
capability: 'storage',
|
||||
mutation: false,
|
||||
panel: false,
|
||||
params: storageGetParams,
|
||||
result: storageGetResult
|
||||
}),
|
||||
spec({
|
||||
name: 'storage.set',
|
||||
since: '1.0',
|
||||
scope: 'plugin-private',
|
||||
capability: 'storage',
|
||||
mutation: true,
|
||||
panel: false,
|
||||
params: storageSetParams,
|
||||
result: storageSetResult
|
||||
}),
|
||||
spec({
|
||||
name: 'storage.delete',
|
||||
since: '1.0',
|
||||
scope: 'plugin-private',
|
||||
capability: 'storage',
|
||||
mutation: true,
|
||||
panel: false,
|
||||
params: storageDeleteParams,
|
||||
result: storageDeleteResult
|
||||
}),
|
||||
spec({
|
||||
name: 'storage.keys',
|
||||
since: '1.0',
|
||||
scope: 'plugin-private',
|
||||
capability: 'storage',
|
||||
mutation: false,
|
||||
panel: false,
|
||||
params: storageKeysParams,
|
||||
result: storageKeysResult
|
||||
}),
|
||||
spec({
|
||||
name: 'secrets.get',
|
||||
since: '1.0',
|
||||
scope: 'plugin-private',
|
||||
capability: 'secrets',
|
||||
mutation: false,
|
||||
panel: false,
|
||||
params: secretsGetParams,
|
||||
result: secretsGetResult
|
||||
}),
|
||||
spec({
|
||||
name: 'secrets.set',
|
||||
since: '1.0',
|
||||
scope: 'plugin-private',
|
||||
capability: 'secrets',
|
||||
mutation: true,
|
||||
panel: false,
|
||||
params: secretsSetParams,
|
||||
result: secretsSetResult
|
||||
}),
|
||||
spec({
|
||||
name: 'secrets.delete',
|
||||
since: '1.0',
|
||||
scope: 'plugin-private',
|
||||
capability: 'secrets',
|
||||
mutation: true,
|
||||
panel: false,
|
||||
params: secretsDeleteParams,
|
||||
result: secretsDeleteResult
|
||||
}),
|
||||
spec({
|
||||
name: 'settings.get',
|
||||
since: '1.0',
|
||||
scope: 'plugin-private',
|
||||
capability: 'settings:own',
|
||||
mutation: false,
|
||||
panel: false,
|
||||
params: settingsGetParams,
|
||||
result: settingsGetResult
|
||||
}),
|
||||
spec({
|
||||
name: 'settings.set',
|
||||
since: '1.0',
|
||||
scope: 'plugin-private',
|
||||
capability: 'settings:own',
|
||||
mutation: true,
|
||||
panel: false,
|
||||
params: settingsSetParams,
|
||||
result: settingsSetResult
|
||||
}),
|
||||
spec({
|
||||
name: 'events.subscribe',
|
||||
since: '1.0',
|
||||
scope: 'host-events',
|
||||
capability: 'events:subscribe',
|
||||
mutation: false,
|
||||
panel: false,
|
||||
params: eventsSubscribeParams,
|
||||
result: eventsSubscribeResult
|
||||
})
|
||||
]
|
||||
|
||||
const SPEC_BY_NAME = new Map(PLUGIN_HOST_API_V0.map((entry) => [entry.name, entry]))
|
||||
|
||||
export function getPluginHostMethodSpec(name: string): PluginHostMethodSpec | null {
|
||||
return SPEC_BY_NAME.get(name) ?? null
|
||||
}
|
||||
|
||||
/** Actions sandboxed panels may request over the postMessage bridge. Derived
|
||||
* from the spec table so the panel surface can never drift from the gate. */
|
||||
export const PLUGIN_PANEL_ACTIONS = PLUGIN_HOST_API_V0.filter((entry) => entry.panel).map(
|
||||
(entry) => entry.name
|
||||
)
|
||||
|
||||
export function isPluginPanelAction(action: string): boolean {
|
||||
return PLUGIN_PANEL_ACTIONS.includes(action)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { z } from 'zod'
|
||||
import { PLUGIN_COMMAND_LIMIT, PLUGIN_EVENT_NAMES, pluginCommandIdSchema } from './plugin-manifest'
|
||||
import { PLUGIN_CAPABILITY_KINDS } from './plugin-capabilities'
|
||||
|
||||
/**
|
||||
* Message protocol between the Orca process and the out-of-process plugin
|
||||
* worker (child_process.fork channel). Zod-validated on both sides because
|
||||
* the child runs third-party code — nothing it sends is trusted structurally.
|
||||
*/
|
||||
|
||||
export const pluginWorkerInitSchema = z.object({
|
||||
type: z.literal('init'),
|
||||
/** Qualified `<publisher>.<id>` key. */
|
||||
pluginId: z.string().min(1),
|
||||
pluginRoot: z.string().min(1),
|
||||
mainEntry: z.string().min(1),
|
||||
/** Consented capability kinds, so the in-worker SDK can fail fast client-
|
||||
* side; the host re-gates every call regardless. */
|
||||
grantedCapabilities: z.array(z.enum(PLUGIN_CAPABILITY_KINDS))
|
||||
})
|
||||
|
||||
export const pluginWorkerInvokeCommandSchema = z.object({
|
||||
type: z.literal('invokeCommand'),
|
||||
callId: z.number().int().nonnegative(),
|
||||
commandId: z.string().min(1),
|
||||
args: z.unknown().optional()
|
||||
})
|
||||
|
||||
export const pluginWorkerDeliverEventSchema = z.object({
|
||||
type: z.literal('deliverEvent'),
|
||||
eventId: z.number().int().nonnegative(),
|
||||
event: z.enum(PLUGIN_EVENT_NAMES),
|
||||
payload: z.unknown()
|
||||
})
|
||||
|
||||
export const pluginWorkerHostResultSchema = z.object({
|
||||
type: z.literal('hostResult'),
|
||||
callId: z.number().int().nonnegative(),
|
||||
ok: z.boolean(),
|
||||
value: z.unknown().optional(),
|
||||
errorCode: z.string().optional(),
|
||||
error: z.string().optional()
|
||||
})
|
||||
|
||||
export const pluginWorkerShutdownSchema = z.object({ type: z.literal('shutdown') })
|
||||
|
||||
export const pluginWorkerParentMessageSchema = z.discriminatedUnion('type', [
|
||||
pluginWorkerInitSchema,
|
||||
pluginWorkerInvokeCommandSchema,
|
||||
pluginWorkerDeliverEventSchema,
|
||||
pluginWorkerHostResultSchema,
|
||||
pluginWorkerShutdownSchema
|
||||
])
|
||||
|
||||
export const pluginWorkerReadySchema = z.object({
|
||||
type: z.literal('ready'),
|
||||
/** Command ids the worker registered handlers for (⊆ manifest commands). */
|
||||
commands: z.array(pluginCommandIdSchema).max(PLUGIN_COMMAND_LIMIT)
|
||||
})
|
||||
|
||||
export const pluginWorkerCommandResultSchema = z.object({
|
||||
type: z.literal('commandResult'),
|
||||
callId: z.number().int().nonnegative(),
|
||||
ok: z.boolean(),
|
||||
// Why: value crosses a fork() IPC boundary, so it is structured-clone data
|
||||
// by construction; zod treats it as opaque and callers re-validate shape.
|
||||
value: z.unknown().optional(),
|
||||
error: z.string().max(8192).optional()
|
||||
})
|
||||
|
||||
export const pluginWorkerEventAckSchema = z.object({
|
||||
type: z.literal('eventAck'),
|
||||
eventId: z.number().int().nonnegative()
|
||||
})
|
||||
|
||||
/** Worker→host API call (the plugin SDK's transport for host methods). */
|
||||
export const pluginWorkerHostCallSchema = z.object({
|
||||
type: z.literal('hostCall'),
|
||||
callId: z.number().int().nonnegative(),
|
||||
method: z.string().min(1),
|
||||
params: z.unknown().optional()
|
||||
})
|
||||
|
||||
export const pluginWorkerLogSchema = z.object({
|
||||
type: z.literal('log'),
|
||||
level: z.enum(['info', 'warn', 'error']),
|
||||
message: z.string().max(8192)
|
||||
})
|
||||
|
||||
export const pluginWorkerFatalSchema = z.object({
|
||||
type: z.literal('fatal'),
|
||||
error: z.string().max(8192)
|
||||
})
|
||||
|
||||
export const pluginWorkerChildMessageSchema = z.discriminatedUnion('type', [
|
||||
pluginWorkerReadySchema,
|
||||
pluginWorkerCommandResultSchema,
|
||||
pluginWorkerEventAckSchema,
|
||||
pluginWorkerHostCallSchema,
|
||||
pluginWorkerLogSchema,
|
||||
pluginWorkerFatalSchema
|
||||
])
|
||||
|
||||
export type PluginWorkerParentMessage = z.infer<typeof pluginWorkerParentMessageSchema>
|
||||
export type PluginWorkerChildMessage = z.infer<typeof pluginWorkerChildMessageSchema>
|
||||
export type PluginWorkerInit = z.infer<typeof pluginWorkerInitSchema>
|
||||
|
||||
export const PLUGIN_WORKER_READY_TIMEOUT_MS = 10_000
|
||||
export const PLUGIN_WORKER_INVOKE_TIMEOUT_MS = 30_000
|
||||
/** Idle reap: a worker with no in-flight work for this long is disposed and
|
||||
* re-forked on the next trigger. */
|
||||
export const PLUGIN_WORKER_IDLE_REAP_MS = 5 * 60_000
|
||||
/** Default cap on concurrently-active workers; excess activations queue. */
|
||||
export const PLUGIN_WORKER_MAX_ACTIVE_DEFAULT = 5
|
||||
@@ -0,0 +1,139 @@
|
||||
import { z } from 'zod'
|
||||
import { isQualifiedPluginKey } from './plugin-manifest'
|
||||
|
||||
/**
|
||||
* Install lockfile: records where each installed plugin came from, the exact
|
||||
* commit it resolved to, the content hash of the installed tree, and the
|
||||
* consent fingerprint the user was shown. A reinstall/update that does not
|
||||
* match is a visible change, never a silent one. Adapted from community PR
|
||||
* #5801's SHA-256 + lockfile installer pieces.
|
||||
*/
|
||||
|
||||
export const pluginInstallSourceSchema = z.discriminatedUnion('kind', [
|
||||
z.object({
|
||||
kind: z.literal('local-path'),
|
||||
path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(32 * 1024)
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('git'),
|
||||
url: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(32 * 1024)
|
||||
.refine(isAllowedPluginGitUrl, 'git URL must use HTTPS or SSH'),
|
||||
/** Requested ref (`#ref` suffix); empty means the remote default branch. */
|
||||
ref: z.string().max(4096).default('')
|
||||
})
|
||||
])
|
||||
|
||||
export type PluginInstallSource = z.infer<typeof pluginInstallSourceSchema>
|
||||
|
||||
/** System Git supports executable remote helpers (`ext::`, custom `foo::`
|
||||
* transports). P0 accepts network Git only over HTTPS or SSH so installing a
|
||||
* source cannot turn URL parsing into arbitrary command execution. */
|
||||
export function isAllowedPluginGitUrl(value: string): boolean {
|
||||
const url = value.trim()
|
||||
if (/^[^\s@/:]+@[A-Za-z0-9.-]+:[^\s]+$/.test(url)) {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (!parsed.hostname || parsed.password) {
|
||||
return false
|
||||
}
|
||||
if (parsed.protocol === 'https:') {
|
||||
return parsed.username.length === 0
|
||||
}
|
||||
return parsed.protocol === 'ssh:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const pluginLockEntrySchema = z
|
||||
.object({
|
||||
/** Qualified `<publisher>.<id>` key. */
|
||||
pluginKey: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key'),
|
||||
version: z.string().min(1).max(128),
|
||||
source: pluginInstallSourceSchema,
|
||||
/** Commit the git source resolved to at install time; null for local. */
|
||||
resolvedCommit: z
|
||||
.string()
|
||||
.regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/)
|
||||
.nullable(),
|
||||
/** Deterministic hash of the installed file tree (also the install dir name). */
|
||||
contentHash: z.string().regex(/^(?:[0-9a-f]{32}|[0-9a-f]{64})$/),
|
||||
/** New descriptive name, accepted for forward compatibility. */
|
||||
consentFingerprint: z.string().min(1).max(256).optional(),
|
||||
/** v1 on-disk name retained so existing Orca builds can read new lockfiles. */
|
||||
capabilityHash: z.string().min(1).max(256).optional(),
|
||||
installedAt: z.number().finite().nonnegative()
|
||||
})
|
||||
.refine((entry) => entry.consentFingerprint || entry.capabilityHash, {
|
||||
message: 'consent fingerprint is required'
|
||||
})
|
||||
.transform(({ capabilityHash, consentFingerprint, ...entry }) => ({
|
||||
...entry,
|
||||
consentFingerprint: consentFingerprint ?? capabilityHash!
|
||||
}))
|
||||
|
||||
export type PluginLockEntry = z.infer<typeof pluginLockEntrySchema>
|
||||
|
||||
export const pluginLockfileSchema = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
plugins: z.record(z.string(), pluginLockEntrySchema)
|
||||
})
|
||||
.superRefine((lock, ctx) => {
|
||||
for (const [key, entry] of Object.entries(lock.plugins)) {
|
||||
if (!isQualifiedPluginKey(key) || entry.pluginKey !== key) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['plugins', key],
|
||||
message: 'lockfile key must match its qualified plugin identity'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export type PluginLockfile = z.infer<typeof pluginLockfileSchema>
|
||||
|
||||
/**
|
||||
* Lockfile v1 called this opaque value `capabilityHash`. Keep writing that
|
||||
* field so rollback to an older host does not make the whole lockfile unreadable.
|
||||
*/
|
||||
export function serializePluginLockfile(lock: PluginLockfile): unknown {
|
||||
return {
|
||||
version: 1,
|
||||
plugins: Object.fromEntries(
|
||||
Object.entries(lock.plugins).map(([key, { consentFingerprint, ...entry }]) => [
|
||||
key,
|
||||
{ ...entry, capabilityHash: consentFingerprint }
|
||||
])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function emptyPluginLockfile(): PluginLockfile {
|
||||
return { version: 1, plugins: {} }
|
||||
}
|
||||
|
||||
export function upsertPluginLock(lock: PluginLockfile, entry: PluginLockEntry): PluginLockfile {
|
||||
return { version: 1, plugins: { ...lock.plugins, [entry.pluginKey]: entry } }
|
||||
}
|
||||
|
||||
export function removePluginLock(lock: PluginLockfile, pluginKey: string): PluginLockfile {
|
||||
const plugins = { ...lock.plugins }
|
||||
delete plugins[pluginKey]
|
||||
return { version: 1, plugins }
|
||||
}
|
||||
|
||||
export function parsePluginLockfile(raw: unknown): PluginLockfile {
|
||||
const parsed = pluginLockfileSchema.safeParse(raw)
|
||||
// A corrupt lockfile must not brick installs; integrity of installed trees
|
||||
// is independently anchored by their hash-addressed directory names.
|
||||
return parsed.success ? parsed.data : emptyPluginLockfile()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user