mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +00:00
* fix(plugins): close trust-boundary holes in the plugin system
Move five security decisions to their chokepoints rather than leaving them
enumerated at individual call sites.
- Kill-list revocation reaches content packs: PluginContentPackRegistry now
takes an isKilled predicate and intersects it with any caller-supplied
approval, so a killed plugin's VM recipes can no longer reach
spawn(..., { shell: true }) through either reconcile() call site.
- Bound kill-list generatedAt to a 24h future skew at the parse chokepoint.
A far-future timestamp previously made every genuine later list look
"older" and disabled revocation permanently, persisted across restarts.
- Protect the whole auto.components.settings.Plugin* translation subtree
instead of an enumerated prefix list, so language packs cannot forge the
consent provenance badge or rewrite install-error security copy.
- Resolve manifest panel icons by own-key only; "constructor"/"__proto__"
previously yielded non-component prototype members that crashed the
right sidebar to its error boundary.
- Give panel liveness frames a reserved control budget so a panel that
saturates its action budget can still answer the watchdog.
Co-authored-by: Orca <help@stably.ai>
* fix(plugins): keep the kill-list future bound off the cache read path
The schema-level generatedAt bound re-judged the on-disk cache against the
device clock at every launch, so a client whose clock ran behind the last
genuine publication discarded its whole cached kill list and started with
zero revocations. Move the bound to the two fetch chokepoints instead.
Co-authored-by: Orca <help@stably.ai>
* fix(plugins): remove the reserved-lane starvation window and the revocation TOCTOU
Review follow-ups on the trust-boundary fixes:
- The reserved liveness lane had a per-window count equal to the ping
interval, so a panel's own pong-shaped traffic could spend it and drop
the next genuine reply — reintroducing the starvation the lane exists to
prevent. The lane is now size-bounded only; rate stays bounded because
every pong is also charged to the data budget.
- Only schema-valid pongs take the lane now, so near-miss pong-shaped junk
cannot drain it. readPanelPongId replaces the zod parse on this
guest-controlled path (a rejected safeParse allocates an issue list, ~90x
the accepted-path cost) and is pinned to the schema by a parity test.
- Re-read the kill list inside approveAtomically: approvedKeys is snapshotted
before an awaited verification phase, so a plugin killed during that wait
could still publish VM recipes and language packs.
- Assert the curated icon resolves to FileText; the old equality also passed
when both sides fell back to Plug.
Co-authored-by: Orca <help@stably.ai>
* fix(plugins): match zod's safe-integer bound in the pong reader
readPanelPongId used Number.isInteger, but zod's .int() rejects anything
above 2**53-1, so pingIds like 1e100 took the reserved lane the schema
would have refused. The parity test never probed that boundary.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
339 lines
13 KiB
TypeScript
339 lines
13 KiB
TypeScript
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 { 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'
|
|
import { PluginContentPackRegistry } from './plugin-content-pack-registry'
|
|
import type { PluginServiceOptions } from './plugin-service-options'
|
|
import type { PluginChangeEvent } from '../../shared/plugins/plugin-change-event'
|
|
import { waitForPluginRefreshSettlement } from './plugin-refresh-settlement'
|
|
import { assertPluginWorkerCommand } from './plugin-command-invocation'
|
|
import { deliverPluginEvent } from './plugin-event-delivery'
|
|
|
|
export type { PluginRuntimeDelegate } from './plugin-host-service-bindings'
|
|
export type { PluginLogLine } from './plugin-log-buffer'
|
|
export type { PluginServiceOptions } from './plugin-service-options'
|
|
|
|
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 contentPacks: PluginContentPackRegistry
|
|
readonly panels: PluginPanelController
|
|
private readonly changeListeners = new Set<(event: PluginChangeEvent) => 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 contentPacksReady = false
|
|
private disposed = false
|
|
|
|
constructor(options: PluginServiceOptions) {
|
|
this.options = options
|
|
this.contentPacks = new PluginContentPackRegistry(this.contentVerifier, (pluginKey) =>
|
|
Boolean(this.options.getPluginKillListEntry?.(pluginKey))
|
|
)
|
|
this.audit = new PluginAuditLog(getPluginsDataDir(options.userDataPath))
|
|
this.panels = new PluginPanelController({
|
|
resolveApprovedPlugin: (pluginKey) => {
|
|
const plugin = this.findValidPlugin(pluginKey)
|
|
return plugin && this.isRuntimeApproved(plugin) ? 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.isRuntimeApproved(plugin),
|
|
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(false),
|
|
onWorkerGone: (pluginKey) => this.eventBus.clear(pluginKey)
|
|
})
|
|
}
|
|
|
|
setRuntimeDelegate(delegate: PluginRuntimeDelegate | null): void {
|
|
this.runtimeDelegate = delegate
|
|
}
|
|
|
|
onChanged(listener: (event: PluginChangeEvent) => void): () => void {
|
|
this.changeListeners.add(listener)
|
|
return () => this.changeListeners.delete(listener)
|
|
}
|
|
|
|
private notifyChanged(contentPacksChanged: boolean): void {
|
|
for (const listener of this.changeListeners) {
|
|
listener({ contentPacksChanged })
|
|
}
|
|
}
|
|
|
|
async initialize(): Promise<void> {
|
|
this.initPromise ??= this.refresh()
|
|
return this.initPromise
|
|
}
|
|
|
|
async whenReady(): Promise<void> {
|
|
await (this.initPromise ?? Promise.resolve()).catch(() => undefined)
|
|
// Client reads wait for the complete transaction so rollback-based content
|
|
// validation cannot expose a partially activated plugin between passes.
|
|
await waitForPluginRefreshSettlement(() => this.refreshChain)
|
|
}
|
|
|
|
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.contentPacksReady = false
|
|
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
|
|
}
|
|
// Publish identity before shutdown so triggers cannot restart old code.
|
|
this.discovered = next
|
|
await this.contentPacks.reconcile(
|
|
next,
|
|
(plugin) => isPluginApproved(enabled, plugin, consentLists),
|
|
this.options.getKeybindings?.()
|
|
)
|
|
this.contentPacksReady = true
|
|
const nextSpecs = collectApprovedWorkerSpecs(next, (plugin) => this.isRuntimeApproved(plugin))
|
|
// Notify before slow shutdown so feature-off unmounts panels immediately.
|
|
this.notifyChanged(true)
|
|
await this.workerController.reconcile(nextSpecs)
|
|
if (this.disposed) {
|
|
return
|
|
}
|
|
this.housekeeping.sync({
|
|
enabled,
|
|
devPaths,
|
|
reapIdle: () => this.workerController.reapIdle(),
|
|
refresh: () => void this.refresh()
|
|
})
|
|
this.notifyChanged(false)
|
|
}
|
|
|
|
getDiscovered(): readonly DiscoveredPlugin[] {
|
|
return this.discovered
|
|
}
|
|
|
|
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()
|
|
})
|
|
}
|
|
|
|
private isRuntimeApproved(plugin: ValidDiscoveredPlugin): boolean {
|
|
return (
|
|
this.contentPacksReady &&
|
|
this.activationState(plugin) === 'approved' &&
|
|
!this.contentPacks.error(plugin.pluginKey) &&
|
|
!this.options.getPluginKillListEntry?.(plugin.pluginKey)
|
|
)
|
|
}
|
|
|
|
workerState(pluginKey: string): { state: PluginRunState; restarts: number } {
|
|
return this.workerController.state(pluginKey)
|
|
}
|
|
|
|
activationError(pluginKey: string): string | null {
|
|
const blocked = this.options.getPluginKillListEntry?.(pluginKey)
|
|
return (
|
|
(blocked ? `Blocked by Orca's plugin safety list: ${blocked.reason}` : null) ??
|
|
this.contentPacks.error(pluginKey) ??
|
|
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.isRuntimeApproved(plugin)) {
|
|
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.isRuntimeApproved(plugin)) {
|
|
throw new Error(`plugin ${pluginKey} is not enabled`)
|
|
}
|
|
assertPluginWorkerCommand(plugin, 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
|
|
}
|
|
deliverPluginEvent({
|
|
event,
|
|
payload,
|
|
plugins: this.discovered,
|
|
eventBus: this.eventBus,
|
|
workerController: this.workerController,
|
|
isRuntimeApproved: (plugin) => this.isRuntimeApproved(plugin),
|
|
logWarning: (pluginKey, line) => this.logBuffer.append(pluginKey, 'warn', line)
|
|
})
|
|
}
|
|
|
|
async deactivatePlugin(pluginKey: string): Promise<void> {
|
|
await this.workerController.deactivate(pluginKey)
|
|
this.notifyChanged(false)
|
|
}
|
|
|
|
/** Reconciles live workers and client projections after consent or
|
|
* enablement changes without re-reading plugin files or starting workers. */
|
|
async reconcileActivationState(): Promise<void> {
|
|
const reconcile = this.refreshChain.then(() => this.performActivationStateReconciliation())
|
|
this.refreshChain = reconcile.catch(() => undefined)
|
|
return reconcile
|
|
}
|
|
|
|
private async performActivationStateReconciliation(): Promise<void> {
|
|
this.contentPacksReady = false
|
|
await this.contentPacks.reconcile(
|
|
this.discovered,
|
|
(plugin) => this.activationState(plugin) === 'approved',
|
|
this.options.getKeybindings?.()
|
|
)
|
|
this.contentPacksReady = true
|
|
const nextSpecs = collectApprovedWorkerSpecs(this.discovered, (plugin) =>
|
|
this.isRuntimeApproved(plugin)
|
|
)
|
|
await this.workerController.reconcile(nextSpecs)
|
|
this.notifyChanged(true)
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|