From 2652c9eacfd54ac69ec2f2ba653b86849995cd06 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 01:41:57 -0700 Subject: [PATCH] fix(configure): defer web search setup runtime Keep web-search configure and channel command defaults on cold plugin metadata, harden persisted registry reads, and require active config for manifest command defaults.\n\nThanks @vincentkoc --- CHANGELOG.md | 1 + extensions/discord/src/monitor/provider.ts | 7 +- .../plugins/read-only-command-defaults.ts | 44 ++++- src/commands/configure.wizard.test.ts | 25 +++ src/commands/configure.wizard.ts | 45 +++--- .../search-setup-cold-imports.test.ts | 15 ++ src/config/commands.test.ts | 34 +++- src/config/commands.ts | 13 +- src/gateway/server-methods/commands.ts | 5 +- src/plugins/command-registry-state.ts | 34 ++++ src/plugins/commands.test.ts | 40 +++++ .../installed-plugin-index-store.test.ts | 37 +++++ src/plugins/installed-plugin-index-store.ts | 151 ++++++++++-------- 13 files changed, 353 insertions(+), 98 deletions(-) create mode 100644 src/commands/search-setup-cold-imports.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e04dced077..bb22198f6a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai - Providers/plugins: keep onboarding and auth-choice setup lists on cold manifest/install metadata and add Provider Index install metadata for not-yet-installed provider plugins. Thanks @vincentkoc. - Providers/plugins: keep provider setup guidance and configure auth imports on cold manifest metadata, with a regression guard against static provider-runtime imports on setup/configure list paths. Thanks @vincentkoc. - CLI/capabilities: keep capability command registration from importing the models auth runtime until `model auth login` actually runs. Thanks @vincentkoc. +- CLI/configure: keep web-search configure prompts on cold plugin registry metadata until the user chooses managed search setup. Thanks @vincentkoc. - Plugins/chat commands: refresh the persisted plugin registry after `/plugins enable` and `/plugins disable`, matching the CLI mutation path. Thanks @vincentkoc. - Plugins/compat: mark `OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY` as a deprecated break-glass switch and point operators at registry repair instead. Thanks @vincentkoc. - Plugins/registry: ignore stale persisted registry reads when plugin policy no longer matches current config, and stamp generated registry files with a do-not-edit warning. Thanks @vincentkoc. diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index 0a86576a445..547eecde115 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -188,6 +188,7 @@ function formatThreadBindingDurationForConfigLabel(durationMs: number): string { async function appendPluginCommandSpecs(params: { commandSpecs: NativeCommandSpec[]; runtime: RuntimeEnv; + cfg: OpenClawConfig; }): Promise { const merged = [...params.commandSpecs]; const existingNames = new Set( @@ -195,7 +196,7 @@ async function appendPluginCommandSpecs(params: { ); const getPluginCommandSpecs = getPluginCommandSpecsForTesting ?? (await loadPluginRuntime()).getPluginCommandSpecs; - for (const pluginCommand of getPluginCommandSpecs("discord")) { + for (const pluginCommand of getPluginCommandSpecs("discord", { config: params.cfg })) { const normalizedName = normalizeLowercaseStringOrEmpty(pluginCommand.name); if (!normalizedName) { continue; @@ -747,7 +748,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { }) : []; if (nativeEnabled) { - commandSpecs = await appendPluginCommandSpecs({ commandSpecs, runtime }); + commandSpecs = await appendPluginCommandSpecs({ commandSpecs, runtime, cfg }); } const initialCommandCount = commandSpecs.length; if (nativeEnabled && nativeSkillsEnabled && commandSpecs.length > maxDiscordCommands) { @@ -756,7 +757,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { cfg, { skillCommands: [], provider: "discord" }, ); - commandSpecs = await appendPluginCommandSpecs({ commandSpecs, runtime }); + commandSpecs = await appendPluginCommandSpecs({ commandSpecs, runtime, cfg }); runtime.log?.( warn( `discord: ${initialCommandCount} commands exceeds limit; removing per-skill commands and keeping /skill.`, diff --git a/src/channels/plugins/read-only-command-defaults.ts b/src/channels/plugins/read-only-command-defaults.ts index abacae4de21..17ef8265ebf 100644 --- a/src/channels/plugins/read-only-command-defaults.ts +++ b/src/channels/plugins/read-only-command-defaults.ts @@ -1,6 +1,10 @@ +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { isBlockedObjectKey } from "../../infra/prototype-keys.js"; import type { PluginManifestRecord } from "../../plugins/manifest-registry.js"; -import { loadPluginManifestRegistryForPluginRegistry } from "../../plugins/plugin-registry.js"; +import { + isPluginEnabled, + loadPluginManifestRegistryForPluginRegistry, +} from "../../plugins/plugin-registry.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import type { ChannelPlugin } from "./types.plugin.js"; @@ -36,12 +40,17 @@ export function normalizeChannelCommandDefaults( : undefined; const nativeSkillsAutoEnabled = typeof value.nativeSkillsAutoEnabled === "boolean" ? value.nativeSkillsAutoEnabled : undefined; - return nativeCommandsAutoEnabled !== undefined || nativeSkillsAutoEnabled !== undefined - ? { - ...(nativeCommandsAutoEnabled !== undefined ? { nativeCommandsAutoEnabled } : {}), - ...(nativeSkillsAutoEnabled !== undefined ? { nativeSkillsAutoEnabled } : {}), - } - : undefined; + if (nativeCommandsAutoEnabled === undefined && nativeSkillsAutoEnabled === undefined) { + return undefined; + } + const defaults: ChannelCommandDefaults = {}; + if (nativeCommandsAutoEnabled !== undefined) { + defaults.nativeCommandsAutoEnabled = nativeCommandsAutoEnabled; + } + if (nativeSkillsAutoEnabled !== undefined) { + defaults.nativeSkillsAutoEnabled = nativeSkillsAutoEnabled; + } + return defaults; } export function resolveReadOnlyChannelCommandDefaults( @@ -50,13 +59,15 @@ export function resolveReadOnlyChannelCommandDefaults( env?: NodeJS.ProcessEnv; stateDir?: string; workspaceDir?: string; - } = {}, + config: OpenClawConfig; + }, ): ChannelCommandDefaults | undefined { const normalizedChannelId = normalizeOptionalString(channelId) ?? ""; if (!normalizedChannelId || !isSafeManifestChannelId(normalizedChannelId)) { return undefined; } const registry = loadPluginManifestRegistryForPluginRegistry({ + config: options.config, stateDir: options.stateDir, workspaceDir: options.workspaceDir, env: options.env ?? process.env, @@ -66,6 +77,23 @@ export function resolveReadOnlyChannelCommandDefaults( if (!record.channels.includes(normalizedChannelId)) { continue; } + if ( + record.id !== normalizedChannelId && + record.channelCatalogMeta?.id !== normalizedChannelId + ) { + continue; + } + if ( + !isPluginEnabled({ + pluginId: record.id, + config: options.config, + stateDir: options.stateDir, + workspaceDir: options.workspaceDir, + env: options.env ?? process.env, + }) + ) { + continue; + } const channelConfigValue = record.channelConfigs ? readOwnRecordValue(record.channelConfigs as Record, normalizedChannelId) : undefined; diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts index a932e654ae1..96cc3d4bba9 100644 --- a/src/commands/configure.wizard.test.ts +++ b/src/commands/configure.wizard.test.ts @@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => { clackText: vi.fn(), clackConfirm: vi.fn(), resolveSearchProviderOptions: vi.fn(), + resolvePluginContributionOwners: vi.fn(), setupSearch: vi.fn(), readConfigFileSnapshot: vi.fn(), writeConfigFile, @@ -113,6 +114,10 @@ vi.mock("./onboard-search.js", () => ({ setupSearch: mocks.setupSearch, })); +vi.mock("../plugins/plugin-registry.js", () => ({ + resolvePluginContributionOwners: mocks.resolvePluginContributionOwners, +})); + vi.mock("../agents/codex-native-web-search.js", () => ({ isCodexNativeWebSearchRelevant: mocks.isCodexNativeWebSearchRelevant, })); @@ -210,6 +215,7 @@ describe("runConfigureWizard", () => { beforeEach(() => { vi.clearAllMocks(); mocks.ensureControlUiAssetsBuilt.mockResolvedValue({ ok: true }); + mocks.resolvePluginContributionOwners.mockReturnValue(["firecrawl"]); mocks.resolveSearchProviderOptions.mockReturnValue([ { id: "firecrawl", @@ -360,6 +366,25 @@ describe("runConfigureWizard", () => { ); }); + it("does not load managed search provider options when web search is disabled", async () => { + setupBaseWizardState(); + queueWizardPrompts({ + select: ["local"], + confirm: [false, true], + }); + + await runWebConfigureWizard(); + + expect(mocks.resolvePluginContributionOwners).toHaveBeenCalledWith( + expect.objectContaining({ + contribution: "contracts", + matches: "webSearchProviders", + }), + ); + expect(mocks.resolveSearchProviderOptions).not.toHaveBeenCalled(); + expect(mocks.setupSearch).not.toHaveBeenCalled(); + }); + it("defers channel status checks until a channel is selected", async () => { setupBaseWizardState(); queueWizardPrompts({ diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index 236244be557..073aa06eb83 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -9,6 +9,7 @@ import { logConfigUpdated } from "../config/logging.js"; import { ConfigMutationConflictError } from "../config/mutate.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js"; +import { resolvePluginContributionOwners } from "../plugins/plugin-registry.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; @@ -199,9 +200,13 @@ async function promptWebToolsConfig( type WebSearchConfig = NonNullable["web"]>["search"]; const existingSearch = nextConfig.tools?.web?.search; const existingFetch = nextConfig.tools?.web?.fetch; - const { resolveSearchProviderOptions, setupSearch } = await import("./onboard-search.js"); const { isCodexNativeWebSearchRelevant } = await import("../agents/codex-native-web-search.js"); - const searchProviderOptions = resolveSearchProviderOptions(nextConfig); + const hasManagedSearchProviders = + resolvePluginContributionOwners({ + config: nextConfig, + contribution: "contracts", + matches: "webSearchProviders", + }).length > 0; note( [ @@ -215,7 +220,7 @@ async function promptWebToolsConfig( const enableSearch = guardCancel( await confirm({ message: "Enable web_search?", - initialValue: existingSearch?.enabled ?? searchProviderOptions.length > 0, + initialValue: existingSearch?.enabled ?? hasManagedSearchProviders, }), runtime, ); @@ -297,8 +302,10 @@ async function promptWebToolsConfig( } } - if (searchProviderOptions.length === 0) { - if (configureManagedProvider) { + if (configureManagedProvider) { + const { resolveSearchProviderOptions, setupSearch } = await import("./onboard-search.js"); + const searchProviderOptions = resolveSearchProviderOptions(nextConfig); + if (searchProviderOptions.length === 0) { note( [ "No web search providers are currently available under this plugin policy.", @@ -307,23 +314,23 @@ async function promptWebToolsConfig( ].join("\n"), "Web search", ); - } - if (nextSearch.openaiCodex?.enabled !== true) { + if (nextSearch.openaiCodex?.enabled !== true) { + nextSearch = { + ...existingSearch, + enabled: false, + }; + } + } else { + workingConfig = await setupSearch(workingConfig, runtime, prompter); nextSearch = { - ...existingSearch, - enabled: false, + ...workingConfig.tools?.web?.search, + enabled: workingConfig.tools?.web?.search?.provider ? true : existingSearch?.enabled, + openaiCodex: { + ...existingSearch?.openaiCodex, + ...(nextSearch.openaiCodex as Record | undefined), + }, }; } - } else if (configureManagedProvider) { - workingConfig = await setupSearch(workingConfig, runtime, prompter); - nextSearch = { - ...workingConfig.tools?.web?.search, - enabled: workingConfig.tools?.web?.search?.provider ? true : existingSearch?.enabled, - openaiCodex: { - ...existingSearch?.openaiCodex, - ...(nextSearch.openaiCodex as Record | undefined), - }, - }; } } diff --git a/src/commands/search-setup-cold-imports.test.ts b/src/commands/search-setup-cold-imports.test.ts new file mode 100644 index 00000000000..70ec99ada60 --- /dev/null +++ b/src/commands/search-setup-cold-imports.test.ts @@ -0,0 +1,15 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const repoRoot = fileURLToPath(new URL("../..", import.meta.url)); + +describe("search setup cold imports", () => { + it("keeps configure wizard command registration off search provider runtime", () => { + const source = fs.readFileSync(path.join(repoRoot, "src/commands/configure.wizard.ts"), "utf8"); + + expect(source).not.toMatch(/\bfrom\s+["'][^"']*onboard-search\.js["']/); + expect(source).not.toMatch(/\bfrom\s+["'][^"']*web-search-providers\.runtime\.js["']/); + }); +}); diff --git a/src/config/commands.test.ts b/src/config/commands.test.ts index f6650cacd2d..accf03b6aec 100644 --- a/src/config/commands.test.ts +++ b/src/config/commands.test.ts @@ -101,7 +101,7 @@ describe("resolveNativeSkillsEnabled", () => { ).toBe(false); }); - it("uses package channel metadata for bundled auto defaults before runtime loads", () => { + it("uses only enabled package channel metadata for bundled auto defaults before runtime loads", () => { setActivePluginRegistry(createTestRegistry([])); const env = { ...process.env, @@ -117,6 +117,22 @@ describe("resolveNativeSkillsEnabled", () => { globalSetting: "auto", env, }), + ).toBe(false); + expect( + resolveNativeSkillsEnabled({ + providerId: "discord", + globalSetting: "auto", + env, + config: { + plugins: { + entries: { + discord: { + enabled: true, + }, + }, + }, + }, + }), ).toBe(true); expect( resolveNativeCommandsEnabled({ @@ -125,6 +141,22 @@ describe("resolveNativeSkillsEnabled", () => { env, }), ).toBe(false); + expect( + resolveNativeCommandsEnabled({ + providerId: "discord", + globalSetting: "auto", + env, + config: { + plugins: { + entries: { + discord: { + enabled: false, + }, + }, + }, + }, + }), + ).toBe(false); }); it("honors explicit provider settings", () => { diff --git a/src/config/commands.ts b/src/config/commands.ts index 14a7b089f4b..68818077386 100644 --- a/src/config/commands.ts +++ b/src/config/commands.ts @@ -3,6 +3,7 @@ import { resolveReadOnlyChannelCommandDefaults } from "../channels/plugins/read- import type { ChannelId } from "../channels/plugins/types.public.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import type { NativeCommandsSetting } from "./types.js"; +import type { OpenClawConfig } from "./types.openclaw.js"; export { isCommandFlagEnabled, isRestartEnabled, type CommandFlagKey } from "./commands.flags.js"; function resolveAutoDefault( @@ -12,6 +13,7 @@ function resolveAutoDefault( env?: NodeJS.ProcessEnv; stateDir?: string; workspaceDir?: string; + config?: OpenClawConfig; autoDefault?: boolean; }, ): boolean { @@ -23,7 +25,13 @@ function resolveAutoDefault( return options.autoDefault; } const commandDefaults = - getLoadedChannelPlugin(id)?.commands ?? resolveReadOnlyChannelCommandDefaults(id, options); + getLoadedChannelPlugin(id)?.commands ?? + (options?.config + ? resolveReadOnlyChannelCommandDefaults(id, { + ...options, + config: options.config, + }) + : undefined); if (kind === "native") { return commandDefaults?.nativeCommandsAutoEnabled === true; } @@ -37,6 +45,7 @@ export function resolveNativeSkillsEnabled(params: { env?: NodeJS.ProcessEnv; stateDir?: string; workspaceDir?: string; + config?: OpenClawConfig; autoDefault?: boolean; }): boolean { return resolveNativeCommandSetting({ ...params, kind: "nativeSkills" }); @@ -49,6 +58,7 @@ export function resolveNativeCommandsEnabled(params: { env?: NodeJS.ProcessEnv; stateDir?: string; workspaceDir?: string; + config?: OpenClawConfig; autoDefault?: boolean; }): boolean { return resolveNativeCommandSetting({ ...params, kind: "native" }); @@ -62,6 +72,7 @@ function resolveNativeCommandSetting(params: { env?: NodeJS.ProcessEnv; stateDir?: string; workspaceDir?: string; + config?: OpenClawConfig; autoDefault?: boolean; }): boolean { const { providerId, providerSetting, globalSetting, kind = "native", ...options } = params; diff --git a/src/gateway/server-methods/commands.ts b/src/gateway/server-methods/commands.ts index 946ce068b24..5d63cefd146 100644 --- a/src/gateway/server-methods/commands.ts +++ b/src/gateway/server-methods/commands.ts @@ -172,9 +172,10 @@ function mapCommand( function buildPluginCommandEntries(params: { provider?: string; nameSurface: CommandNameSurface; + cfg: OpenClawConfig; }): CommandEntry[] { const pluginTextSpecs = listPluginCommands(); - const pluginNativeSpecs = getPluginCommandSpecs(params.provider); + const pluginNativeSpecs = getPluginCommandSpecs(params.provider, { config: params.cfg }); const entries: CommandEntry[] = []; for (const [index, textSpec] of pluginTextSpecs.entries()) { @@ -233,7 +234,7 @@ export function buildCommandsListResult(params: { ); } - commands.push(...buildPluginCommandEntries({ provider, nameSurface })); + commands.push(...buildPluginCommandEntries({ provider, nameSurface, cfg: params.cfg })); return { commands: commands.slice(0, COMMAND_LIST_MAX_ITEMS) }; } diff --git a/src/plugins/command-registry-state.ts b/src/plugins/command-registry-state.ts index fb1f8cc0db4..33fbdd06f69 100644 --- a/src/plugins/command-registry-state.ts +++ b/src/plugins/command-registry-state.ts @@ -1,3 +1,6 @@ +import { getLoadedChannelPlugin } from "../channels/plugins/index.js"; +import { resolveReadOnlyChannelCommandDefaults } from "../channels/plugins/read-only-command-defaults.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import type { OpenClawPluginCommandDefinition } from "./types.js"; @@ -81,6 +84,37 @@ function resolvePluginNativeName( return command.name; } +export function getPluginCommandSpecs( + provider?: string, + options: { + env?: NodeJS.ProcessEnv; + stateDir?: string; + workspaceDir?: string; + config?: OpenClawConfig; + } = {}, +): Array<{ + name: string; + description: string; + acceptsArgs: boolean; +}> { + const providerName = normalizeOptionalLowercaseString(provider); + const commandDefaults = + providerName && options.config + ? resolveReadOnlyChannelCommandDefaults(providerName, { + ...options, + config: options.config, + }) + : undefined; + if ( + providerName && + (getLoadedChannelPlugin(providerName)?.commands ?? commandDefaults) + ?.nativeCommandsAutoEnabled !== true + ) { + return []; + } + return listProviderPluginCommandSpecs(provider); +} + /** Resolve plugin command specs for a provider's native naming surface without support gating. */ export function listProviderPluginCommandSpecs(provider?: string): Array<{ name: string; diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index 0fd2d36ad1a..0bda679304e 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { @@ -331,6 +332,45 @@ describe("registerPluginCommand", () => { ]); }); + it("requires config before using read-only manifest command defaults", () => { + setActivePluginRegistry(createTestRegistry([])); + registerVoiceCommandForTest({ + nativeNames: { + discord: "discordvoice", + }, + description: "Demo command", + }); + const env = { + ...process.env, + OPENCLAW_BUNDLED_PLUGINS_DIR: path.resolve("extensions"), + OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY: "1", + OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", + OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1", + }; + + expect(getPluginCommandSpecs("discord", { env })).toEqual([]); + expect( + getPluginCommandSpecs("discord", { + env, + config: { + plugins: { + entries: { + discord: { + enabled: true, + }, + }, + }, + }, + }), + ).toEqual([ + { + name: "discordvoice", + description: "Demo command", + acceptsArgs: false, + }, + ]); + }); + it("accepts native progress metadata on plugin commands", () => { const result = registerVoiceCommandForTest({ nativeProgressMessages: { telegram: "Running voice command..." }, diff --git a/src/plugins/installed-plugin-index-store.test.ts b/src/plugins/installed-plugin-index-store.test.ts index e4bfb175792..61af416c615 100644 --- a/src/plugins/installed-plugin-index-store.test.ts +++ b/src/plugins/installed-plugin-index-store.test.ts @@ -102,6 +102,43 @@ describe("installed plugin index persistence", () => { await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toMatchObject(index); }); + it("does not preserve prototype poison keys from persisted index JSON", async () => { + const stateDir = makeTempDir(); + const filePath = resolveInstalledPluginIndexStorePath({ stateDir }); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const index = createIndex({ + installRecords: { + demo: { + source: "npm", + spec: "demo@1.0.0", + }, + }, + }); + Object.defineProperty(index, "__proto__", { + enumerable: true, + value: { polluted: true }, + }); + Object.defineProperty(index.installRecords, "__proto__", { + enumerable: true, + value: { polluted: true }, + }); + fs.writeFileSync(filePath, JSON.stringify(index), "utf8"); + + const persisted = await readPersistedInstalledPluginIndex({ stateDir }); + + expect(persisted).toMatchObject({ + plugins: [expect.objectContaining({ pluginId: "demo" })], + installRecords: { + demo: expect.objectContaining({ source: "npm" }), + }, + }); + expect(Object.prototype.hasOwnProperty.call(persisted as object, "__proto__")).toBe(false); + expect(Object.prototype.hasOwnProperty.call(persisted?.installRecords ?? {}, "__proto__")).toBe( + false, + ); + expect(({} as Record).polluted).toBeUndefined(); + }); + it("returns null for missing or invalid persisted indexes", async () => { const stateDir = makeTempDir(); await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toBeNull(); diff --git a/src/plugins/installed-plugin-index-store.ts b/src/plugins/installed-plugin-index-store.ts index a5d23b3467f..0827ca174c9 100644 --- a/src/plugins/installed-plugin-index-store.ts +++ b/src/plugins/installed-plugin-index-store.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { saveJsonFile } from "../infra/json-file.js"; import { readJsonFile, readJsonFileSync, writeJsonAtomic } from "../infra/json-files.js"; +import { isBlockedObjectKey } from "../infra/prototype-keys.js"; import { safeParseWithSchema } from "../utils/zod-parse.js"; import { resolveInstalledPluginIndexStorePath, @@ -15,6 +16,7 @@ import { loadInstalledPluginIndex, refreshInstalledPluginIndex, type InstalledPluginIndex, + type InstalledPluginInstallRecordInfo, type InstalledPluginIndexRefreshReason, type LoadInstalledPluginIndexParams, type RefreshInstalledPluginIndexParams, @@ -36,71 +38,79 @@ export type InstalledPluginIndexStoreInspection = { const StringArraySchema = z.array(z.string()); -const InstalledPluginIndexStartupSchema = z - .object({ - sidecar: z.boolean(), - memory: z.boolean(), - deferConfiguredChannelFullLoadUntilAfterListen: z.boolean(), - agentHarnesses: StringArraySchema, - }) - .passthrough(); +const InstalledPluginIndexStartupSchema = z.object({ + sidecar: z.boolean(), + memory: z.boolean(), + deferConfiguredChannelFullLoadUntilAfterListen: z.boolean(), + agentHarnesses: StringArraySchema, +}); -const InstalledPluginIndexRecordSchema = z - .object({ - pluginId: z.string(), - packageName: z.string().optional(), - packageVersion: z.string().optional(), - installRecord: z.record(z.string(), z.unknown()).optional(), - installRecordHash: z.string().optional(), - packageInstall: z.unknown().optional(), - packageChannel: z.unknown().optional(), - manifestPath: z.string(), - manifestHash: z.string(), - format: z.string().optional(), - bundleFormat: z.string().optional(), - source: z.string().optional(), - setupSource: z.string().optional(), - packageJson: z - .object({ - path: z.string(), - hash: z.string(), - }) - .optional(), - rootDir: z.string(), - origin: z.string(), - enabled: z.boolean(), - enabledByDefault: z.boolean().optional(), - startup: InstalledPluginIndexStartupSchema, - compat: z.array(z.string()), - }) - .passthrough(); +const InstalledPluginIndexRecordSchema = z.object({ + pluginId: z.string(), + packageName: z.string().optional(), + packageVersion: z.string().optional(), + installRecord: z.record(z.string(), z.unknown()).optional(), + installRecordHash: z.string().optional(), + packageInstall: z.unknown().optional(), + packageChannel: z.unknown().optional(), + manifestPath: z.string(), + manifestHash: z.string(), + format: z.string().optional(), + bundleFormat: z.string().optional(), + source: z.string().optional(), + setupSource: z.string().optional(), + packageJson: z + .object({ + path: z.string(), + hash: z.string(), + }) + .optional(), + rootDir: z.string(), + origin: z.string(), + enabled: z.boolean(), + enabledByDefault: z.boolean().optional(), + startup: InstalledPluginIndexStartupSchema, + compat: z.array(z.string()), +}); const InstalledPluginInstallRecordSchema = z.record(z.string(), z.unknown()); -const PluginDiagnosticSchema = z - .object({ - level: z.union([z.literal("warn"), z.literal("error")]), - message: z.string(), - pluginId: z.string().optional(), - source: z.string().optional(), - }) - .passthrough(); +const PluginDiagnosticSchema = z.object({ + level: z.union([z.literal("warn"), z.literal("error")]), + message: z.string(), + pluginId: z.string().optional(), + source: z.string().optional(), +}); -const InstalledPluginIndexSchema = z - .object({ - version: z.literal(INSTALLED_PLUGIN_INDEX_VERSION), - warning: z.string().optional(), - hostContractVersion: z.string(), - compatRegistryVersion: z.string(), - migrationVersion: z.literal(INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION), - policyHash: z.string(), - generatedAtMs: z.number(), - refreshReason: z.string().optional(), - installRecords: z.record(z.string(), InstalledPluginInstallRecordSchema).optional(), - plugins: z.array(InstalledPluginIndexRecordSchema), - diagnostics: z.array(PluginDiagnosticSchema), - }) - .passthrough(); +const InstalledPluginIndexSchema = z.object({ + version: z.literal(INSTALLED_PLUGIN_INDEX_VERSION), + warning: z.string().optional(), + hostContractVersion: z.string(), + compatRegistryVersion: z.string(), + migrationVersion: z.literal(INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION), + policyHash: z.string(), + generatedAtMs: z.number(), + refreshReason: z.string().optional(), + installRecords: z.record(z.string(), InstalledPluginInstallRecordSchema).optional(), + plugins: z.array(InstalledPluginIndexRecordSchema), + diagnostics: z.array(PluginDiagnosticSchema), +}); + +function copySafeInstallRecords( + records: Readonly> | undefined, +): Record | undefined { + if (!records) { + return undefined; + } + const safeRecords: Record = {}; + for (const [pluginId, record] of Object.entries(records)) { + if (isBlockedObjectKey(pluginId)) { + continue; + } + safeRecords[pluginId] = record; + } + return safeRecords; +} function parseInstalledPluginIndex(value: unknown): InstalledPluginIndex | null { const parsed = safeParseWithSchema(InstalledPluginIndexSchema, value) as @@ -111,11 +121,24 @@ function parseInstalledPluginIndex(value: unknown): InstalledPluginIndex | null if (!parsed) { return null; } - return { - ...parsed, - installRecords: - parsed.installRecords ?? + const installRecords = + copySafeInstallRecords(parsed.installRecords) ?? + copySafeInstallRecords( extractPluginInstallRecordsFromInstalledPluginIndex(parsed as InstalledPluginIndex), + ) ?? + {}; + return { + version: parsed.version, + ...(parsed.warning ? { warning: parsed.warning } : {}), + hostContractVersion: parsed.hostContractVersion, + compatRegistryVersion: parsed.compatRegistryVersion, + migrationVersion: parsed.migrationVersion, + policyHash: parsed.policyHash, + generatedAtMs: parsed.generatedAtMs, + ...(parsed.refreshReason ? { refreshReason: parsed.refreshReason } : {}), + installRecords, + plugins: parsed.plugins, + diagnostics: parsed.diagnostics, }; }