From f97cc587605c1ea31d0448a23a4bccf61f3ffaf4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 09:36:58 +0100 Subject: [PATCH] fix(browser): auto-start configured browser plugin --- CHANGELOG.md | 1 + docs/cli/browser.md | 9 +- docs/plugins/manifest.md | 4 + docs/tools/browser.md | 4 +- extensions/browser/openclaw.plugin.json | 3 + src/gateway/server-startup-plugins.test.ts | 4 +- src/gateway/server-startup-plugins.ts | 16 ++-- src/plugins/channel-plugin-ids.test.ts | 59 +++++++++++++ src/plugins/compat/registry.ts | 11 +++ src/plugins/gateway-startup-plugin-ids.ts | 83 ++++++++++++++++++- .../installed-plugin-index-record-builder.ts | 3 + src/plugins/manifest.json5-tolerance.test.ts | 2 + src/plugins/manifest.ts | 4 + src/plugins/plugin-registry-snapshot.ts | 25 +++++- src/plugins/plugin-registry.test.ts | 54 ++++++++++-- 15 files changed, 259 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b663cd7d03b..62086f6a5b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - Gateway/models: move local-provider pricing opt-outs, OpenRouter/LiteLLM aliases, and proxy passthrough pricing lookup into plugin manifest metadata so core no longer carries extension-specific pricing tables. Thanks @codex. - Agents/sessions: acquire the session write lock only after cold bootstrap, plugin, and tool setup so fallback runs are not blocked by stalled pre-model startup work. Thanks @codex. +- Browser/plugins: auto-start the bundled browser plugin when root `browser` config is present, including restrictive plugin allowlists, and ignore stale persisted plugin registries whose package paths no longer exist. Thanks @codex. - Gateway/models: skip external OpenRouter and LiteLLM pricing refreshes for local/self-hosted model endpoints so startup does not wait on remote pricing catalogs for local-only Ollama, vLLM, and compatible providers. Thanks @codex. - CLI/plugins: stop security-blocked plugin installs from retrying as hook packs, so normal plugin packages report the scanner failure without a misleading "not a valid hook pack" follow-up. Fixes #61175; supersedes #64102. Thanks @KonsultDigital and @ziyincody. - Agents/Anthropic: strip stale trailing assistant prefill turns from outbound replay so context-engine short circuits cannot send unsupported assistant-prefill payloads to provider APIs. Fixes #72556. Thanks @Veda-openclaw. diff --git a/docs/cli/browser.md b/docs/cli/browser.md index 06a1aaf4ad7..24d53d5b2b9 100644 --- a/docs/cli/browser.md +++ b/docs/cli/browser.md @@ -85,8 +85,8 @@ Notes: If `openclaw browser` is an unknown command, check `plugins.allow` in `~/.openclaw/openclaw.json`. -When `plugins.allow` is present, the bundled browser plugin must be listed -explicitly: +When `plugins.allow` is present, list the bundled browser plugin explicitly +unless the config already has a root `browser` block: ```json5 { @@ -96,8 +96,9 @@ explicitly: } ``` -`browser.enabled=true` does not restore the CLI subcommand when the plugin -allowlist excludes `browser`. +An explicit root `browser` block, for example `browser.enabled=true` or +`browser.profiles.`, also activates the bundled browser plugin under a +restrictive plugin allowlist. Related: [Browser tool](/tools/browser#missing-browser-command-or-tool) diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index b727ed1cb5a..6db29b575fe 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -249,6 +249,7 @@ change correctness while legacy manifest ownership fallbacks still exist. "onCommands": ["models"], "onChannels": ["web"], "onRoutes": ["gateway-webhook"], + "onConfigPaths": ["browser"], "onCapabilities": ["provider", "tool"] } } @@ -261,6 +262,7 @@ change correctness while legacy manifest ownership fallbacks still exist. | `onCommands` | No | `string[]` | Command ids that should include this plugin in activation/load plans. | | `onChannels` | No | `string[]` | Channel ids that should include this plugin in activation/load plans. | | `onRoutes` | No | `string[]` | Route kinds that should include this plugin in activation/load plans. | +| `onConfigPaths` | No | `string[]` | Root-relative config paths that should include this plugin in startup/load plans when the path is present and not explicitly disabled. | | `onCapabilities` | No | `Array<"provider" \| "channel" \| "tool" \| "hook">` | Broad capability hints used by control-plane activation planning. Prefer narrower fields when possible. | Current live consumers: @@ -271,6 +273,8 @@ Current live consumers: embedded harnesses and top-level `cliBackends[]` for CLI runtime aliases - channel-triggered setup/channel planning falls back to legacy `channels[]` ownership when explicit channel activation metadata is missing +- startup plugin planning uses `activation.onConfigPaths` for non-channel root + config surfaces such as the bundled browser plugin's `browser` block - provider-triggered setup/runtime planning falls back to legacy `providers[]` and top-level `cliBackends[]` ownership when explicit provider activation metadata is missing diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 46803255d3b..21ae67123f6 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -104,7 +104,7 @@ turns do not pay the full token cost. ## Missing browser command or tool -If `openclaw browser` is unknown after an upgrade, `browser.request` is missing, or the agent reports the browser tool as unavailable, the usual cause is a `plugins.allow` list that omits `browser`. Add it: +If `openclaw browser` is unknown after an upgrade, `browser.request` is missing, or the agent reports the browser tool as unavailable, the usual cause is a `plugins.allow` list that omits `browser` and no root `browser` config block exists. Add it: ```json5 { @@ -114,7 +114,7 @@ If `openclaw browser` is unknown after an upgrade, `browser.request` is missing, } ``` -`browser.enabled=true`, `plugins.entries.browser.enabled=true`, and `tools.alsoAllow: ["browser"]` do not substitute for allowlist membership — the allowlist gates plugin loading, and tool policy only runs after load. Removing `plugins.allow` entirely also restores the default. +An explicit root `browser` block, for example `browser.enabled=true` or `browser.profiles.`, activates the bundled browser plugin even under a restrictive `plugins.allow`, matching channel config behavior. `plugins.entries.browser.enabled=true` and `tools.alsoAllow: ["browser"]` do not substitute for allowlist membership by themselves. Removing `plugins.allow` entirely also restores the default. ## Profiles: `openclaw` vs `user` diff --git a/extensions/browser/openclaw.plugin.json b/extensions/browser/openclaw.plugin.json index 63856a81a2c..91d3408b29c 100644 --- a/extensions/browser/openclaw.plugin.json +++ b/extensions/browser/openclaw.plugin.json @@ -1,6 +1,9 @@ { "id": "browser", "enabledByDefault": true, + "activation": { + "onConfigPaths": ["browser"] + }, "commandAliases": [{ "name": "browser" }], "skills": ["./skills"], "configSchema": { diff --git a/src/gateway/server-startup-plugins.test.ts b/src/gateway/server-startup-plugins.test.ts index cb22868a4f2..3f4dda1e2f2 100644 --- a/src/gateway/server-startup-plugins.test.ts +++ b/src/gateway/server-startup-plugins.test.ts @@ -3,8 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const applyPluginAutoEnable = vi.hoisted(() => vi.fn((params: { config: unknown }) => ({ config: params.config, - changes: [], - autoEnabledReasons: {}, + changes: [] as string[], + autoEnabledReasons: {} as Record, })), ); const initSubagentRegistry = vi.hoisted(() => vi.fn()); diff --git a/src/gateway/server-startup-plugins.ts b/src/gateway/server-startup-plugins.ts index 43f5a0f50f3..32ffbfdb08b 100644 --- a/src/gateway/server-startup-plugins.ts +++ b/src/gateway/server-startup-plugins.ts @@ -128,21 +128,21 @@ export async function prepareGatewayPluginBootstrap(params: { initSubagentRegistry(); - const gatewayPluginConfigAtStart = params.minimalTestGateway + const gatewayPluginConfig = params.minimalTestGateway ? params.cfgAtStart : applyPluginAutoEnable({ config: params.cfgAtStart, env: process.env, }).config; - const defaultAgentId = resolveDefaultAgentId(gatewayPluginConfigAtStart); - const defaultWorkspaceDir = resolveAgentWorkspaceDir(gatewayPluginConfigAtStart, defaultAgentId); + const defaultAgentId = resolveDefaultAgentId(gatewayPluginConfig); + const defaultWorkspaceDir = resolveAgentWorkspaceDir(gatewayPluginConfig, defaultAgentId); const pluginLookUpTable = params.minimalTestGateway ? undefined : loadPluginLookUpTable({ - config: gatewayPluginConfigAtStart, - activationSourceConfig: params.cfgAtStart, + config: gatewayPluginConfig, workspaceDir: defaultWorkspaceDir, env: process.env, + activationSourceConfig: params.cfgAtStart, }); const deferredConfiguredChannelPluginIds = [ ...(pluginLookUpTable?.startup.configuredDeferredChannelPluginIds ?? []), @@ -156,12 +156,12 @@ export async function prepareGatewayPluginBootstrap(params: { if (!params.minimalTestGateway) { await prestageGatewayBundledRuntimeDeps({ - cfg: gatewayPluginConfigAtStart, + cfg: gatewayPluginConfig, pluginIds: startupPluginIds, log: params.log, }); ({ pluginRegistry, gatewayMethods: baseGatewayMethods } = loadGatewayStartupPlugins({ - cfg: gatewayPluginConfigAtStart, + cfg: gatewayPluginConfig, activationSourceConfig: params.cfgAtStart, workspaceDir: defaultWorkspaceDir, log: params.log, @@ -178,7 +178,7 @@ export async function prepareGatewayPluginBootstrap(params: { } return { - gatewayPluginConfigAtStart, + gatewayPluginConfigAtStart: gatewayPluginConfig, defaultWorkspaceDir, deferredConfiguredChannelPluginIds, startupPluginIds, diff --git a/src/plugins/channel-plugin-ids.test.ts b/src/plugins/channel-plugin-ids.test.ts index f9c655e9895..28c86d70466 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -83,6 +83,9 @@ function createManifestRegistryFixture() { { id: "browser", channels: [], + activation: { + onConfigPaths: ["browser"], + }, origin: "bundled", enabledByDefault: true, providers: [], @@ -485,6 +488,62 @@ describe("resolveGatewayStartupPluginIds", () => { }); }); + it("starts bundled sidecars selected by root config activation paths", () => { + const rawConfig = { + browser: { + enabled: true, + defaultProfile: "docker-cdp", + }, + channels: {}, + } satisfies OpenClawConfig; + const effectiveConfig = { + ...rawConfig, + plugins: { + entries: { + browser: { + enabled: true, + }, + }, + }, + } satisfies OpenClawConfig; + + expectStartupPluginIdsCase({ + config: effectiveConfig, + activationSourceConfig: rawConfig, + expected: ["browser", "memory-core"], + }); + }); + + it("lets bundled root config activation paths bypass restrictive allowlists", () => { + expectStartupPluginIdsCase({ + config: { + browser: { + enabled: true, + }, + channels: {}, + plugins: { + allow: ["telegram"], + }, + }, + expected: ["browser"], + }); + }); + + it("does not bypass restrictive allowlists for disabled root config activation paths", () => { + expectStartupPluginIdsCase({ + config: { + browser: { + enabled: false, + }, + channels: {}, + plugins: { + allow: ["telegram"], + }, + }, + expected: [], + }); + }); + it("does not let weak channel presence start untrusted workspace channel owners", () => { loadPluginManifestRegistry .mockReset() diff --git a/src/plugins/compat/registry.ts b/src/plugins/compat/registry.ts index 49aee47b7fa..bf937d06226 100644 --- a/src/plugins/compat/registry.ts +++ b/src/plugins/compat/registry.ts @@ -171,6 +171,17 @@ export const PLUGIN_COMPAT_RECORDS = [ diagnostics: ["activation plan compat reason"], tests: ["src/plugins/activation-planner.test.ts"], }, + { + code: "activation-config-path-hint", + status: "active", + owner: "plugin-execution", + introduced: "2026-04-27", + replacement: "manifest contribution ownership for root config surfaces", + docsPath: "/plugins/manifest", + surfaces: ["activation.onConfigPaths", "startup plugin selection"], + diagnostics: ["activation plan compat reason"], + tests: ["src/plugins/channel-plugin-ids.test.ts"], + }, { code: "activation-capability-hint", status: "active", diff --git a/src/plugins/gateway-startup-plugin-ids.ts b/src/plugins/gateway-startup-plugin-ids.ts index 0310a2085cd..c152735b83b 100644 --- a/src/plugins/gateway-startup-plugin-ids.ts +++ b/src/plugins/gateway-startup-plugin-ids.ts @@ -9,10 +9,11 @@ import { } from "../memory-host-sdk/dreaming.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { hasExplicitChannelConfig } from "./channel-presence-policy.js"; +import { collectPluginConfigContractMatches } from "./config-contracts.js"; import { resolveEffectivePluginActivationState } from "./config-state.js"; import type { InstalledPluginIndexRecord } from "./installed-plugin-index.js"; import { loadPluginManifestRegistryForInstalledIndex } from "./manifest-registry-installed.js"; -import type { PluginManifestRegistry } from "./manifest-registry.js"; +import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js"; import { createPluginRegistryIdNormalizer, normalizePluginsConfigWithRegistry, @@ -39,6 +40,20 @@ function listDisabledChannelIds(config: OpenClawConfig): Set { ); } +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function isConfigActivationValueEnabled(value: unknown): boolean { + if (value === false) { + return false; + } + if (isRecord(value) && value.enabled === false) { + return false; + } + return true; +} + function listPotentialEnabledChannelIds(config: OpenClawConfig, env: NodeJS.ProcessEnv): string[] { const disabled = listDisabledChannelIds(config); return listPotentialConfiguredChannelIds(config, env, { includePersistedAuthState: false }) @@ -125,6 +140,60 @@ function listManifestChannelIds( return manifestRegistry.plugins.find((plugin) => plugin.id === pluginId)?.channels ?? []; } +function findManifestPlugin( + manifestRegistry: PluginManifestRegistry, + pluginId: string, +): PluginManifestRecord | undefined { + return manifestRegistry.plugins.find((plugin) => plugin.id === pluginId); +} + +function hasConfiguredActivationPath(params: { + manifest: PluginManifestRecord | undefined; + config: OpenClawConfig; +}): boolean { + const paths = params.manifest?.activation?.onConfigPaths; + if (!paths?.length) { + return false; + } + return paths.some((pathPattern) => + collectPluginConfigContractMatches({ + root: params.config, + pathPattern, + }).some((match) => isConfigActivationValueEnabled(match.value)), + ); +} + +function canStartConfiguredRootPlugin(params: { + plugin: InstalledPluginIndexRecord; + manifest: PluginManifestRecord | undefined; + config: OpenClawConfig; + pluginsConfig: ReturnType; + activationSourcePlugins: ReturnType; +}): boolean { + if (params.plugin.origin !== "bundled") { + return false; + } + if (!hasConfiguredActivationPath({ manifest: params.manifest, config: params.config })) { + return false; + } + if (!params.pluginsConfig.enabled || !params.activationSourcePlugins.enabled) { + return false; + } + if ( + params.pluginsConfig.deny.includes(params.plugin.pluginId) || + params.activationSourcePlugins.deny.includes(params.plugin.pluginId) + ) { + return false; + } + if ( + params.pluginsConfig.entries[params.plugin.pluginId]?.enabled === false || + params.activationSourcePlugins.entries[params.plugin.pluginId]?.enabled === false + ) { + return false; + } + return true; +} + function canStartConfiguredChannelPlugin(params: { plugin: InstalledPluginIndexRecord; config: OpenClawConfig; @@ -301,6 +370,7 @@ export function resolveGatewayStartupPluginIdsFromRegistry(params: { }); return params.index.plugins .filter((plugin) => { + const manifest = findManifestPlugin(params.manifestRegistry, plugin.pluginId); if ( hasConfiguredStartupChannel({ plugin, @@ -338,6 +408,17 @@ export function resolveGatewayStartupPluginIdsFromRegistry(params: { ) { return false; } + if ( + canStartConfiguredRootPlugin({ + plugin, + manifest, + config: activationSourceConfig, + pluginsConfig, + activationSourcePlugins, + }) + ) { + return true; + } const activationState = resolveEffectivePluginActivationState({ id: plugin.pluginId, origin: plugin.origin, diff --git a/src/plugins/installed-plugin-index-record-builder.ts b/src/plugins/installed-plugin-index-record-builder.ts index c91cb11713a..d2ea9d763c6 100644 --- a/src/plugins/installed-plugin-index-record-builder.ts +++ b/src/plugins/installed-plugin-index-record-builder.ts @@ -86,6 +86,9 @@ function collectCompatCodes(record: PluginManifestRecord): readonly PluginCompat if (record.activation?.onRoutes?.length) { codes.push("activation-route-hint"); } + if (record.activation?.onConfigPaths?.length) { + codes.push("activation-config-path-hint"); + } if (record.activation?.onCapabilities?.length) { codes.push("activation-capability-hint"); } diff --git a/src/plugins/manifest.json5-tolerance.test.ts b/src/plugins/manifest.json5-tolerance.test.ts index e055fd72bf9..776c9693c64 100644 --- a/src/plugins/manifest.json5-tolerance.test.ts +++ b/src/plugins/manifest.json5-tolerance.test.ts @@ -111,6 +111,7 @@ describe("loadPluginManifest JSON5 tolerance", () => { onCommands: ["models", ""], onChannels: ["web", ""], onRoutes: ["gateway-webhook", ""], + onConfigPaths: ["browser", ""], onCapabilities: ["provider", "tool", "wat"] }, setup: { @@ -133,6 +134,7 @@ describe("loadPluginManifest JSON5 tolerance", () => { onCommands: ["models"], onChannels: ["web"], onRoutes: ["gateway-webhook"], + onConfigPaths: ["browser"], onCapabilities: ["provider", "tool"], }); expect(result.manifest.setup).toEqual({ diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 2b722ed265d..a32122d2004 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -119,6 +119,8 @@ export type PluginManifestActivation = { onChannels?: string[]; /** Route kinds that should include this plugin in activation/load plans. */ onRoutes?: string[]; + /** Root-relative config paths that should include this plugin in startup/load plans. */ + onConfigPaths?: string[]; /** Broad capability hints for activation/load plans. Prefer narrower ownership metadata. */ onCapabilities?: PluginManifestActivationCapability[]; }; @@ -740,6 +742,7 @@ function normalizeManifestActivation(value: unknown): PluginManifestActivation | const onCommands = normalizeTrimmedStringList(value.onCommands); const onChannels = normalizeTrimmedStringList(value.onChannels); const onRoutes = normalizeTrimmedStringList(value.onRoutes); + const onConfigPaths = normalizeTrimmedStringList(value.onConfigPaths); const onCapabilities = normalizeTrimmedStringList(value.onCapabilities).filter( (capability): capability is PluginManifestActivationCapability => capability === "provider" || @@ -754,6 +757,7 @@ function normalizeManifestActivation(value: unknown): PluginManifestActivation | ...(onCommands.length > 0 ? { onCommands } : {}), ...(onChannels.length > 0 ? { onChannels } : {}), ...(onRoutes.length > 0 ? { onRoutes } : {}), + ...(onConfigPaths.length > 0 ? { onConfigPaths } : {}), ...(onCapabilities.length > 0 ? { onCapabilities } : {}), } satisfies PluginManifestActivation; diff --git a/src/plugins/plugin-registry-snapshot.ts b/src/plugins/plugin-registry-snapshot.ts index af24b9f5dc3..5aea077ed30 100644 --- a/src/plugins/plugin-registry-snapshot.ts +++ b/src/plugins/plugin-registry-snapshot.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import { inspectPersistedInstalledPluginIndex, readPersistedInstalledPluginIndexSync, @@ -25,7 +26,8 @@ export type PluginRegistrySnapshotSource = "provided" | "persisted" | "derived"; export type PluginRegistrySnapshotDiagnosticCode = | "persisted-registry-disabled" | "persisted-registry-missing" - | "persisted-registry-stale-policy"; + | "persisted-registry-stale-policy" + | "persisted-registry-stale-source"; export type PluginRegistrySnapshotDiagnostic = { level: "info" | "warn"; @@ -60,6 +62,20 @@ function hasEnvFlag(env: NodeJS.ProcessEnv, name: string): boolean { return Boolean(value && value !== "0" && value !== "false" && value !== "no"); } +function hasMissingPersistedPluginSource(index: InstalledPluginIndex): boolean { + return index.plugins.some((plugin) => { + if (!plugin.enabled) { + return false; + } + return ( + !fs.existsSync(plugin.rootDir) || + !fs.existsSync(plugin.manifestPath) || + (plugin.source ? !fs.existsSync(plugin.source) : false) || + (plugin.setupSource ? !fs.existsSync(plugin.setupSource) : false) + ); + }); +} + export function loadPluginRegistrySnapshotWithMetadata( params: LoadPluginRegistryParams = {}, ): PluginRegistrySnapshotResult { @@ -91,6 +107,13 @@ export function loadPluginRegistrySnapshotWithMetadata( message: "Persisted plugin registry policy does not match current config; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.", }); + } else if (hasMissingPersistedPluginSource(persistedIndex)) { + diagnostics.push({ + level: "warn", + code: "persisted-registry-stale-source", + message: + "Persisted plugin registry points at missing plugin files; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.", + }); } else { return { snapshot: persistedIndex, diff --git a/src/plugins/plugin-registry.test.ts b/src/plugins/plugin-registry.test.ts index 22f7289bdc2..f7a9c6e20b0 100644 --- a/src/plugins/plugin-registry.test.ts +++ b/src/plugins/plugin-registry.test.ts @@ -109,6 +109,7 @@ function createIndex( pluginId = "demo", overrides: Partial = {}, ): InstalledPluginIndex { + const pluginRoot = overrides.plugins?.[0]?.rootDir ?? `/plugins/${pluginId}`; return { version: 1, hostContractVersion: "2026.4.25", @@ -120,9 +121,9 @@ function createIndex( plugins: [ { pluginId, - manifestPath: `/plugins/${pluginId}/openclaw.plugin.json`, + manifestPath: path.join(pluginRoot, "openclaw.plugin.json"), manifestHash: "manifest-hash", - rootDir: `/plugins/${pluginId}`, + rootDir: pluginRoot, origin: "global", enabled: true, startup: { @@ -359,6 +360,47 @@ describe("plugin registry facade", () => { }); it("reads the persisted registry before deriving from discovered candidates", async () => { + const stateDir = makeTempDir(); + const rootDir = makeTempDir(); + const persistedRootDir = makeTempDir(); + const candidate = createCandidate(rootDir); + const config = {} as const; + fs.writeFileSync(path.join(persistedRootDir, "index.ts"), "", "utf8"); + fs.writeFileSync( + path.join(persistedRootDir, "openclaw.plugin.json"), + JSON.stringify({ id: "persisted", configSchema: { type: "object" } }), + "utf8", + ); + await writePersistedInstalledPluginIndex( + createIndex("persisted", { + policyHash: resolveInstalledPluginIndexPolicyHash(config), + plugins: [ + { + ...createIndex("persisted").plugins[0], + manifestPath: path.join(persistedRootDir, "openclaw.plugin.json"), + source: path.join(persistedRootDir, "index.ts"), + rootDir: persistedRootDir, + }, + ], + }), + { stateDir }, + ); + + const result = loadPluginRegistrySnapshotWithMetadata({ + stateDir, + candidates: [candidate], + config, + env: hermeticEnv(), + }); + + expect(result.source).toBe("persisted"); + expect(result.diagnostics).toEqual([]); + expect(listPluginRecords({ index: result.snapshot }).map((plugin) => plugin.pluginId)).toEqual([ + "persisted", + ]); + }); + + it("falls back to the derived registry when persisted source paths are missing", async () => { const stateDir = makeTempDir(); const rootDir = makeTempDir(); const candidate = createCandidate(rootDir); @@ -377,10 +419,12 @@ describe("plugin registry facade", () => { env: hermeticEnv(), }); - expect(result.source).toBe("persisted"); - expect(result.diagnostics).toEqual([]); + expect(result.source).toBe("derived"); + expect(result.diagnostics).toEqual([ + expect.objectContaining({ code: "persisted-registry-stale-source" }), + ]); expect(listPluginRecords({ index: result.snapshot }).map((plugin) => plugin.pluginId)).toEqual([ - "persisted", + "demo", ]); });